Compare commits
61 Commits
43c21a0cd5
...
34ad7e4af7
| Author | SHA1 | Date | |
|---|---|---|---|
| 34ad7e4af7 | |||
| 9165d26b72 | |||
| d34b3bf70e | |||
| 9cbc521a0d | |||
| 3057d9c2d4 | |||
| ac334db940 | |||
| 200ce74cb5 | |||
| 9869ad2476 | |||
| df4e6f9c56 | |||
| 35d23110d7 | |||
| fc034ad520 | |||
| 24c5731c69 | |||
| c05dd31e95 | |||
| 49b9ab5617 | |||
| 49318e87a5 | |||
| ca3804195d | |||
| c29ba88fec | |||
| fee67c35af | |||
| 3c41b0d487 | |||
| 11a515b588 | |||
| 88ebc14d24 | |||
| 0519936531 | |||
| 7815f151f1 | |||
| ce3bdd3d72 | |||
| c2eb2df8c0 | |||
| 863961405d | |||
| e29e6ef498 | |||
| 8c54921661 | |||
| c9c7cedd55 | |||
| e59e5aa060 | |||
| ca5cf8c439 | |||
| 00ad737e7e | |||
| b607d0198b | |||
| 9a80ce5dff | |||
| f4b6df10ac | |||
| c96a433307 | |||
| 31f723cbd6 | |||
| 436e165679 | |||
| 8bf076697e | |||
| 28ad63fb4a | |||
| 91a9521f3e | |||
| 90792c601c | |||
| 7c00163304 | |||
| 9885162c4e | |||
| a2cbb181d5 | |||
| 1823d0bf1b | |||
| c155a3cc2e | |||
| 920a832424 | |||
| 02c33e5ad5 | |||
| 4caadd24b9 | |||
| 10613a36f8 | |||
| 18a652a789 | |||
| 81627d6b7c | |||
| 5cfd32639b | |||
| 726e143405 | |||
| c4626ca40c | |||
| 117caa9122 | |||
| 6dedaf0e8b | |||
| 54092f184f | |||
| 9160edda33 | |||
| efcd140966 |
26
.cursor/commands/commit.md
Normal file
26
.cursor/commands/commit.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Commit
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Commit changes with a two-tier strategy: prioritize changes from this chat, then fall back to any other uncommitted changes.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Identify changes from this chat**
|
||||||
|
- From the current conversation context, determine which files you (the agent) have created, modified, or deleted in this chat.
|
||||||
|
- List these "chat-touched" file paths.
|
||||||
|
|
||||||
|
2. **Check git status**
|
||||||
|
- Run `git status --short` in each workspace root to see uncommitted changes.
|
||||||
|
|
||||||
|
3. **Choose what to commit**
|
||||||
|
- **If** any uncommitted changes exist in chat-touched files: stage and commit only those chat-touched files. Use a commit message that summarizes the work done in this chat.
|
||||||
|
- **Else** (all chat-touched changes are already committed): stage and commit any other uncommitted changes. Use a commit message that summarizes those remaining changes.
|
||||||
|
|
||||||
|
4. **Perform the commit**
|
||||||
|
- Stage the chosen files with `git add` (exclude `.idea/` and other IDE/config files unless they are part of the intended changes).
|
||||||
|
- Run `git commit` with a clear, conventional commit-style message.
|
||||||
|
- Use PowerShell-safe syntax (e.g. `;` instead of `&&`, avoid bash heredoc).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- If there are no uncommitted changes at all, report that and do nothing.
|
||||||
|
- When multiple workspace roots exist, apply this logic per repo (check status in each root and commit accordingly).
|
||||||
10
.cursor/commands/run.md
Normal file
10
.cursor/commands/run.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Run app on device
|
||||||
|
|
||||||
|
Build, install, and launch the app on the connected USB Android device.
|
||||||
|
|
||||||
|
## What to do
|
||||||
|
|
||||||
|
1. Run `./gradlew installDebug` (use `gradlew.bat` on Windows).
|
||||||
|
2. Ensure the device is connected via USB with USB debugging enabled.
|
||||||
|
3. After a successful install, launch the app with `adb shell am start -n org.db3.airmq/.MainActivity`.
|
||||||
|
4. Report the device name and that the app was launched.
|
||||||
@@ -5,9 +5,22 @@ alwaysApply: true
|
|||||||
|
|
||||||
# AIRMQ Recreation Core Rules
|
# AIRMQ Recreation Core Rules
|
||||||
|
|
||||||
|
## CRITICAL: Path check before any edit
|
||||||
|
|
||||||
|
**BEFORE editing any file, verify its path.**
|
||||||
|
|
||||||
|
- Path contains `airmq-android` but **NOT** `airmq-android-2026` → **NEVER EDIT.** Read-only reference.
|
||||||
|
- Path contains `airmq-android-2026` → OK to edit.
|
||||||
|
|
||||||
|
Example: `C:\Users\sysop\Desktop\airmq-android\app\...` → **DO NOT MODIFY.**
|
||||||
|
|
||||||
|
## Hard repository constraint
|
||||||
|
|
||||||
|
ALL CHANGES ONLY IN `airmq-android-2026` REPO; NO CHANGES EVER IN `airmq-android`.
|
||||||
|
|
||||||
## Repository boundaries
|
## Repository boundaries
|
||||||
|
|
||||||
1. Do not modify anything under `C:\Users\sysop\Desktop\airmq-android`.
|
1. **NEVER** modify anything under `C:\Users\sysop\Desktop\airmq-android`.
|
||||||
2. Treat `airmq-android` as read-only reference only.
|
2. Treat `airmq-android` as read-only reference only.
|
||||||
3. Create and apply all code/config/build changes only under `C:\Users\sysop\Desktop\airmq-android-2026`.
|
3. Create and apply all code/config/build changes only under `C:\Users\sysop\Desktop\airmq-android-2026`.
|
||||||
|
|
||||||
@@ -16,6 +29,11 @@ alwaysApply: true
|
|||||||
- When referencing legacy implementation details, copy behavior intentionally into the new project rather than editing old files.
|
- When referencing legacy implementation details, copy behavior intentionally into the new project rather than editing old files.
|
||||||
- If a task appears to require modifying the old project, stop and propose an equivalent change in `airmq-android-2026` instead.
|
- If a task appears to require modifying the old project, stop and propose an equivalent change in `airmq-android-2026` instead.
|
||||||
|
|
||||||
|
## Naming convention
|
||||||
|
|
||||||
|
- Use `AirMQ` everywhere for product naming.
|
||||||
|
- Never use mixed-case variants in text, docs, comments, identifiers, filenames, or symbols.
|
||||||
|
|
||||||
## Baseline architecture and platform stack
|
## Baseline architecture and platform stack
|
||||||
|
|
||||||
Use this stack as the default foundation for all implementation work in `airmq-android-2026`:
|
Use this stack as the default foundation for all implementation work in `airmq-android-2026`:
|
||||||
@@ -23,7 +41,18 @@ Use this stack as the default foundation for all implementation work in `airmq-a
|
|||||||
1. Jetpack Compose for UI.
|
1. Jetpack Compose for UI.
|
||||||
2. Compose Navigation 3 as the in-app navigation system.
|
2. Compose Navigation 3 as the in-app navigation system.
|
||||||
3. Firebase for mobile backend capabilities (for example auth, analytics, crash reporting, messaging, or remote config as needed).
|
3. Firebase for mobile backend capabilities (for example auth, analytics, crash reporting, messaging, or remote config as needed).
|
||||||
4. Google Maps platform for map rendering and map-related interactions.
|
4. OpenStreetMap platform for map rendering and map-related interactions.
|
||||||
5. Apollo GraphQL for GraphQL schema integration, client generation, and network operations.
|
5. Apollo GraphQL for GraphQL schema integration, client generation, and network operations.
|
||||||
|
|
||||||
If a requested change conflicts with this baseline, ask for explicit approval before introducing an alternative.
|
If a requested change conflicts with this baseline, ask for explicit approval before introducing an alternative.
|
||||||
|
|
||||||
|
## Temporary or implicit implementations
|
||||||
|
|
||||||
|
When implementing something implicitly or obviously temporary, document it in `temp.md` at the project root with all important details:
|
||||||
|
|
||||||
|
- What was implemented
|
||||||
|
- Why it is temporary or implicit
|
||||||
|
- Location (files, components, modules)
|
||||||
|
- Any follow-up work or conditions for proper replacement
|
||||||
|
|
||||||
|
Create `temp.md` if it does not exist.
|
||||||
|
|||||||
41
.cursor/rules/screen-ui-contract.mdc
Normal file
41
.cursor/rules/screen-ui-contract.mdc
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
description: Screen UI contract structure for State, Action, and Event
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Screen UI Contract Rule
|
||||||
|
|
||||||
|
Every screen must define its contract in a `*ScreenContract.kt` file using `State`, `Action`, and `Event`.
|
||||||
|
|
||||||
|
## Required structure
|
||||||
|
|
||||||
|
1. Define `State` with all static UI data required to render the screen.
|
||||||
|
2. Define `Action` (enum or sealed interface) for what the ViewModel does.
|
||||||
|
3. Define `Event` (enum or sealed interface) for user interactions.
|
||||||
|
|
||||||
|
## State guidelines
|
||||||
|
|
||||||
|
- `State` must include stable screen data (for example `deviceName`, `deviceId`, `isSharingEnabled`).
|
||||||
|
- Do not put user interaction triggers in `State`; those belong to `Event`.
|
||||||
|
|
||||||
|
## Action guidelines
|
||||||
|
|
||||||
|
- Use `Action` for ViewModel-driven outcomes such as navigation or side effects.
|
||||||
|
- Example: `Action.NavigateTo*Screen`, `Action.Show*Toast`
|
||||||
|
|
||||||
|
## Event guidelines
|
||||||
|
|
||||||
|
- Use `Event` for user-originated input.
|
||||||
|
- Examples: `Event.*ButtonClicked`, `Event.*InputChanged(value)`.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class State(val deviceName: String, val deviceId: String, val isSharingEnabled: Boolean)
|
||||||
|
|
||||||
|
sealed interface Action { data object NavigateToScreenX : Action }
|
||||||
|
|
||||||
|
sealed interface Event {
|
||||||
|
data object XButtonClicked : Event
|
||||||
|
data class InputChanged(val value: String) : Event
|
||||||
|
}
|
||||||
|
```
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -7,9 +7,16 @@
|
|||||||
/.idea/workspace.xml
|
/.idea/workspace.xml
|
||||||
/.idea/navEditor.xml
|
/.idea/navEditor.xml
|
||||||
/.idea/assetWizardSettings.xml
|
/.idea/assetWizardSettings.xml
|
||||||
|
/.idea/deviceManager.xml
|
||||||
|
/.idea/gradle.xml
|
||||||
|
/.idea/misc.xml
|
||||||
|
.idea/inspectionProfiles/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/build
|
/build
|
||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
local.properties
|
local.properties
|
||||||
|
**/build/
|
||||||
|
.kotlin/
|
||||||
|
/.idea/**/workspace.xml
|
||||||
|
|||||||
17
.idea/gradle.xml
generated
17
.idea/gradle.xml
generated
@@ -1,17 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="GradleSettings">
|
|
||||||
<option name="linkedExternalProjectsSettings">
|
|
||||||
<GradleProjectSettings>
|
|
||||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
|
||||||
<option name="modules">
|
|
||||||
<set>
|
|
||||||
<option value="$PROJECT_DIR$" />
|
|
||||||
<option value="$PROJECT_DIR$/app" />
|
|
||||||
</set>
|
|
||||||
</option>
|
|
||||||
</GradleProjectSettings>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
8
.idea/markdown.xml
generated
Normal file
8
.idea/markdown.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="MarkdownSettings">
|
||||||
|
<option name="previewPanelProviderInfo">
|
||||||
|
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
.idea/misc.xml
generated
10
.idea/misc.xml
generated
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
|
||||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
|
||||||
</component>
|
|
||||||
<component name="ProjectType">
|
|
||||||
<option name="id" value="Android" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -1,9 +1,28 @@
|
|||||||
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.hilt.android)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.google.services)
|
||||||
|
alias(libs.plugins.firebase.crashlytics)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
fun gitCommitCount(): String {
|
||||||
|
return runCatching {
|
||||||
|
val process = ProcessBuilder("git", "rev-list", "--count", "HEAD")
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
val output = process.inputStream.bufferedReader().use { it.readText().trim() }
|
||||||
|
if (process.waitFor() == 0 && output.isNotBlank()) output else "local"
|
||||||
|
}.getOrElse { "local" }
|
||||||
|
}
|
||||||
|
|
||||||
|
val debugBuildId = gitCommitCount()
|
||||||
|
|
||||||
|
extensions.configure<ApplicationExtension> {
|
||||||
namespace = "org.db3.airmq"
|
namespace = "org.db3.airmq"
|
||||||
compileSdk {
|
compileSdk {
|
||||||
version = release(36) {
|
version = release(36) {
|
||||||
@@ -16,12 +35,16 @@ android {
|
|||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "3.0.0-pre"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
versionNameSuffix = "_debug_$debugBuildId"
|
||||||
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
@@ -40,22 +63,36 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// AirMQ SDK
|
||||||
|
implementation(project(":sdk"))
|
||||||
|
|
||||||
|
// OSM
|
||||||
|
implementation(libs.osmdroid.android)
|
||||||
|
|
||||||
|
// Android
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.foundation)
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
implementation(platform(libs.firebase.bom))
|
implementation(libs.hilt.android)
|
||||||
implementation(libs.firebase.analytics)
|
implementation(libs.androidx.hilt.navigation.compose)
|
||||||
implementation(libs.firebase.crashlytics)
|
implementation(libs.androidx.credentials)
|
||||||
implementation(libs.firebase.messaging)
|
implementation(libs.play.services.location)
|
||||||
implementation(libs.google.maps.compose)
|
implementation(libs.kotlinx.coroutines.play.services)
|
||||||
implementation(libs.play.services.maps)
|
implementation(libs.androidx.credentials.play.services.auth)
|
||||||
implementation(libs.apollo.runtime)
|
implementation(libs.googleid)
|
||||||
|
ksp(libs.hilt.compiler)
|
||||||
|
|
||||||
|
// Tests
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
47
app/google-services.json
Normal file
47
app/google-services.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "223884730019",
|
||||||
|
"project_id": "db3-airmq-debug",
|
||||||
|
"storage_bucket": "db3-airmq-debug.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:223884730019:android:ebe25591cd4c41c2562916",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "org.db3.airmq"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "223884730019-nrh7lbjumja79jlci98u54jjc9utc988.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "org.db3.airmq",
|
||||||
|
"certificate_hash": "d9df75253752259b83100ffb1c809615b9216be2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "223884730019-0ddlndpmfesrt1vk5vdsqgeudkmoanls.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyCcgdvdp8xec2dKUqlQHl9peQAyOWRVga4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "223884730019-0ddlndpmfesrt1vk5vdsqgeudkmoanls.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package org.db3.airmq
|
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
import org.junit.Assert.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instrumented test, which will execute on an Android device.
|
|
||||||
*
|
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class ExampleInstrumentedTest {
|
|
||||||
@Test
|
|
||||||
fun useAppContext() {
|
|
||||||
// Context of the app under test.
|
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
assertEquals("org.db3.airmq", appContext.packageName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
app/src/debug/AndroidManifest.xml
Normal file
8
app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
tools:replace="android:usesCleartextTraffic" />
|
||||||
|
</manifest>
|
||||||
@@ -2,7 +2,12 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".AirMQApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
|||||||
1496
app/src/main/graphql/schema.graphqls
Normal file
1496
app/src/main/graphql/schema.graphqls
Normal file
File diff suppressed because it is too large
Load Diff
7
app/src/main/kotlin/org/db3/airmq/AirMQApplication.kt
Normal file
7
app/src/main/kotlin/org/db3/airmq/AirMQApplication.kt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package org.db3.airmq
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class AirMQApplication : Application()
|
||||||
@@ -6,16 +6,27 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import org.db3.airmq.features.navigation.AirMqNavGraph
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.db3.airmq.features.entry.CityInitializer
|
||||||
|
import org.db3.airmq.features.navigation.AirMQNavGraph
|
||||||
|
import org.db3.airmq.sdk.city.CityService
|
||||||
import org.db3.airmq.ui.theme.AirMQTheme
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var cityService: CityService
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
AirMQTheme {
|
AirMQTheme {
|
||||||
AirMqNavGraph(modifier = Modifier.fillMaxSize())
|
CityInitializer(cityService = cityService) {
|
||||||
|
AirMQNavGraph(modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,409 @@
|
|||||||
package org.db3.airmq.features.city
|
package org.db3.airmq.features.city
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.android.gms.location.LocationServices
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.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.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.city.CityScreenContract.Action
|
||||||
|
import org.db3.airmq.features.city.CityScreenContract.Event
|
||||||
|
import org.db3.airmq.features.city.CityScreenContract.State
|
||||||
|
import org.db3.airmq.sdk.city.domain.City
|
||||||
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CityScreen(onBackToDashboard: () -> Unit) {
|
fun CityScreen(
|
||||||
MockScreenScaffold(
|
onNavigateBack: () -> Unit,
|
||||||
title = "City",
|
viewModel: CityViewModel = hiltViewModel()
|
||||||
subtitle = "Mock city management screen.",
|
) {
|
||||||
actions = listOf(ScreenAction("Back to Dashboard", onBackToDashboard))
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity = context as? android.app.Activity
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { result ->
|
||||||
|
val granted = result.values.any { it }
|
||||||
|
scope.launch {
|
||||||
|
val location = if (granted && activity != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
LocationServices.getFusedLocationProviderClient(activity).getLastLocation().await()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
viewModel.onEvent(Event.EnableDetectAutomaticallyResult(location))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val onDetectAutomaticallyChange: (Boolean) -> Unit = { enabled ->
|
||||||
|
if (enabled) {
|
||||||
|
val permissions = arrayOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
)
|
||||||
|
val allGranted = permissions.all {
|
||||||
|
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (allGranted && activity != null) {
|
||||||
|
scope.launch {
|
||||||
|
val location = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
LocationServices.getFusedLocationProviderClient(activity).getLastLocation().await()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
viewModel.onEvent(Event.EnableDetectAutomaticallyResult(location))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(permissions)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
viewModel.onEvent(Event.DetectAutomaticallyChanged(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(viewModel) {
|
||||||
|
viewModel.actions.collect { action ->
|
||||||
|
when (action) {
|
||||||
|
Action.NavigateBack -> onNavigateBack()
|
||||||
|
is Action.ShowToast -> Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CityScreenScaffold(
|
||||||
|
state = uiState,
|
||||||
|
onEvent = viewModel::onEvent,
|
||||||
|
onDetectAutomaticallyChange = onDetectAutomaticallyChange
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun CityScreenScaffold(
|
||||||
|
state: State,
|
||||||
|
onEvent: (Event) -> Unit,
|
||||||
|
onDetectAutomaticallyChange: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
TopBar(
|
||||||
|
title = stringResource(R.string.title_city),
|
||||||
|
onBackClick = { onEvent(Event.BackClicked) }
|
||||||
|
)
|
||||||
|
if (state.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CityScreenContent(
|
||||||
|
uiState = state,
|
||||||
|
onEvent = onEvent,
|
||||||
|
onDetectAutomaticallyChange = onDetectAutomaticallyChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TopBar(
|
||||||
|
title: String,
|
||||||
|
onBackClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBackClick) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.content_back)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 48.dp),
|
||||||
|
fontSize = 24.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CityScreenContent(
|
||||||
|
uiState: State,
|
||||||
|
onEvent: (Event) -> Unit,
|
||||||
|
onDetectAutomaticallyChange: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
val expandedRegions = remember { mutableStateMapOf<Int, Boolean>() }
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
WarningRow()
|
||||||
|
DetectAutomaticallyRow(
|
||||||
|
enabled = uiState.detectAutomatically,
|
||||||
|
onCheckedChange = onDetectAutomaticallyChange
|
||||||
|
)
|
||||||
|
if (uiState.hasOnlyDefaultCity) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(36.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = uiState.selectedCity,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = Color(0xFF6B6B6B)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||||
|
) {
|
||||||
|
uiState.regions.forEachIndexed { index, region ->
|
||||||
|
val isExpanded = expandedRegions.getOrDefault(index, false)
|
||||||
|
item(key = "region_$index") {
|
||||||
|
RegionRow(
|
||||||
|
countryName = region.countryName,
|
||||||
|
isExpanded = isExpanded,
|
||||||
|
onClick = {
|
||||||
|
expandedRegions[index] = !isExpanded
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isExpanded) {
|
||||||
|
region.cities.forEach { city ->
|
||||||
|
item(key = "city_${city.id}") {
|
||||||
|
CityRow(
|
||||||
|
city = city,
|
||||||
|
localeLanguage = uiState.localeLanguage,
|
||||||
|
onClick = { onEvent(Event.CitySelected(city)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WarningRow() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_warning),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
tint = Color(0x99333333)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.text_city_warning),
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = Color(0x99333333)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(1.dp)
|
||||||
|
.background(Color(0x1F000000))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DetectAutomaticallyRow(
|
||||||
|
enabled: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.text_detect_automatically),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = Color(0xFF222222)
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = enabled,
|
||||||
|
onCheckedChange = onCheckedChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(1.dp)
|
||||||
|
.background(Color(0x1F000000))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RegionRow(
|
||||||
|
countryName: String,
|
||||||
|
isExpanded: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_arrow_down_dark),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.then(if (isExpanded) Modifier.rotate(180f) else Modifier),
|
||||||
|
tint = Color(0xFF1F5DA5)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = countryName,
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = Color(0xFF1F5DA5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CityRow(
|
||||||
|
city: City,
|
||||||
|
localeLanguage: String,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = city.getLocalizedName(localeLanguage),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = Color(0xFF222222)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${city.locationCount ?: "—"} x ",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Color(0x99333333)
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_device_basic_active_10),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = Color(0x99333333)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "City – list")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewCityList() {
|
||||||
|
AirMQTheme {
|
||||||
|
CityScreenScaffold(
|
||||||
|
state = CityScreenContract.previewState(),
|
||||||
|
onEvent = {},
|
||||||
|
onDetectAutomaticallyChange = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "City – loading")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewCityLoading() {
|
||||||
|
AirMQTheme {
|
||||||
|
CityScreenScaffold(
|
||||||
|
state = CityScreenContract.previewState(isLoading = true),
|
||||||
|
onEvent = {},
|
||||||
|
onDetectAutomaticallyChange = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "City – default only")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewCityDefaultOnly() {
|
||||||
|
AirMQTheme {
|
||||||
|
CityScreenScaffold(
|
||||||
|
state = CityScreenContract.previewState(
|
||||||
|
regions = emptyList(),
|
||||||
|
hasOnlyDefaultCity = true,
|
||||||
|
selectedCity = "Minsk"
|
||||||
|
),
|
||||||
|
onEvent = {},
|
||||||
|
onDetectAutomaticallyChange = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package org.db3.airmq.features.city
|
||||||
|
|
||||||
|
import android.location.Location
|
||||||
|
import org.db3.airmq.sdk.city.domain.City
|
||||||
|
import org.db3.airmq.sdk.city.domain.Region
|
||||||
|
|
||||||
|
object CityScreenContract {
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val regions: List<Region> = emptyList(),
|
||||||
|
val selectedCity: String = "",
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val hasOnlyDefaultCity: Boolean = false,
|
||||||
|
val localeLanguage: String = "en",
|
||||||
|
val detectAutomatically: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
fun previewState(
|
||||||
|
regions: List<Region> = listOf(
|
||||||
|
Region(
|
||||||
|
countryName = "Belarus",
|
||||||
|
countryCode = "BY",
|
||||||
|
cities = listOf(
|
||||||
|
City("minsk", "BY", "Minsk", "Мінск", "Минск", 53.9, 27.5, 12),
|
||||||
|
City("gomel", "BY", "Gomel", "Гомель", "Гомель", 52.4, 31.0, 5)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Region(
|
||||||
|
countryName = "Russia",
|
||||||
|
countryCode = "RU",
|
||||||
|
cities = listOf(
|
||||||
|
City("moscow", "RU", "Moscow", null, "Москва", 55.7, 37.6, 45)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
selectedCity: String = "Minsk",
|
||||||
|
isLoading: Boolean = false,
|
||||||
|
hasOnlyDefaultCity: Boolean = false,
|
||||||
|
localeLanguage: String = "en",
|
||||||
|
detectAutomatically: Boolean = false
|
||||||
|
): State = State(
|
||||||
|
regions = regions,
|
||||||
|
selectedCity = selectedCity,
|
||||||
|
isLoading = isLoading,
|
||||||
|
hasOnlyDefaultCity = hasOnlyDefaultCity,
|
||||||
|
localeLanguage = localeLanguage,
|
||||||
|
detectAutomatically = detectAutomatically
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface Action {
|
||||||
|
data object NavigateBack : Action
|
||||||
|
data class ShowToast(val message: String) : Action
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Event {
|
||||||
|
data object BackClicked : Event
|
||||||
|
data class CitySelected(val city: City) : Event
|
||||||
|
data class DetectAutomaticallyChanged(val enabled: Boolean) : Event
|
||||||
|
/** Called when permission flow completes for enabling auto-detect. Location is null if denied or unavailable. */
|
||||||
|
data class EnableDetectAutomaticallyResult(val location: Location?) : Event
|
||||||
|
}
|
||||||
|
}
|
||||||
110
app/src/main/kotlin/org/db3/airmq/features/city/CityViewModel.kt
Normal file
110
app/src/main/kotlin/org/db3/airmq/features/city/CityViewModel.kt
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package org.db3.airmq.features.city
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import android.location.Location
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.sdk.city.CityService
|
||||||
|
import org.db3.airmq.sdk.city.domain.Region
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class CityViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
|
private val cityService: CityService
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(CityScreenContract.State())
|
||||||
|
val uiState: StateFlow<CityScreenContract.State> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _actions = MutableSharedFlow<CityScreenContract.Action>(extraBufferCapacity = 1)
|
||||||
|
val actions: SharedFlow<CityScreenContract.Action> = _actions.asSharedFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadCities()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: CityScreenContract.Event) {
|
||||||
|
when (event) {
|
||||||
|
CityScreenContract.Event.BackClicked -> _actions.tryEmit(CityScreenContract.Action.NavigateBack)
|
||||||
|
is CityScreenContract.Event.CitySelected -> selectCity(event.city)
|
||||||
|
is CityScreenContract.Event.DetectAutomaticallyChanged -> {
|
||||||
|
if (event.enabled) return // Handled via EnableDetectAutomaticallyResult after permission flow
|
||||||
|
setDetectAutomatically(false)
|
||||||
|
}
|
||||||
|
is CityScreenContract.Event.EnableDetectAutomaticallyResult -> enableDetectAutomaticallyWithLocation(event.location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCities() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val localeLanguage = Locale.getDefault().language
|
||||||
|
cityService.syncCitiesFromRemote(localeLanguage)
|
||||||
|
val regions = cityService.getCitiesGroupedByCountry(localeLanguage)
|
||||||
|
val selectedCity = cityService.getSelectedCity()
|
||||||
|
val hasOnlyDefaultCity = regions.isEmpty()
|
||||||
|
val detectAutomatically = cityService.getDetectAutomatically()
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
regions = regions,
|
||||||
|
selectedCity = selectedCity,
|
||||||
|
isLoading = false,
|
||||||
|
hasOnlyDefaultCity = hasOnlyDefaultCity,
|
||||||
|
detectAutomatically = detectAutomatically,
|
||||||
|
localeLanguage = localeLanguage
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasOnlyDefaultCity) {
|
||||||
|
_actions.tryEmit(
|
||||||
|
CityScreenContract.Action.ShowToast(
|
||||||
|
appContext.getString(R.string.city_list_unavailable)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectCity(city: org.db3.airmq.sdk.city.domain.City) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
cityService.setSelectedCity(city.id)
|
||||||
|
_actions.tryEmit(CityScreenContract.Action.NavigateBack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setDetectAutomatically(enabled: Boolean) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
cityService.setDetectAutomatically(enabled)
|
||||||
|
_uiState.value = _uiState.value.copy(detectAutomatically = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableDetectAutomaticallyWithLocation(location: Location?) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
if (location != null) {
|
||||||
|
cityService.refreshCityFromLocation(location)
|
||||||
|
cityService.setDetectAutomatically(true)
|
||||||
|
val selectedCity = cityService.getSelectedCity()
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
detectAutomatically = true,
|
||||||
|
selectedCity = selectedCity
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_actions.tryEmit(
|
||||||
|
CityScreenContract.Action.ShowToast(appContext.getString(R.string.toast_error))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
package org.db3.airmq.features.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
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.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
|
import org.db3.airmq.ui.theme.LegacyButtonContained
|
||||||
|
import org.db3.airmq.ui.theme.LegacyButtonGradientEnd
|
||||||
|
import org.db3.airmq.ui.theme.LegacyButtonGradientStart
|
||||||
|
import org.db3.airmq.ui.theme.LegacyButtonOnContained
|
||||||
|
import org.db3.airmq.ui.theme.LegacyButtonOnOutlined
|
||||||
|
import org.db3.airmq.ui.theme.LegacyButtonOnText
|
||||||
|
import org.db3.airmq.ui.theme.LegacyOutlineLight
|
||||||
|
|
||||||
|
enum class AirMQButtonStyle {
|
||||||
|
Contained,
|
||||||
|
Outlined,
|
||||||
|
Text,
|
||||||
|
Gradient
|
||||||
|
}
|
||||||
|
|
||||||
|
private val LegacyButtonShape = RoundedCornerShape(18.dp)
|
||||||
|
private val LegacyButtonHeight = 36.dp
|
||||||
|
private val LegacyDisabledContainer = Color(0xFFE0E0E0)
|
||||||
|
private val LegacyDisabledContent = Color(0x61000000)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AirMQButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
style: AirMQButtonStyle = AirMQButtonStyle.Contained,
|
||||||
|
leadingIconRes: Int? = null
|
||||||
|
) {
|
||||||
|
when (style) {
|
||||||
|
AirMQButtonStyle.Contained -> AirMQContainedButton(
|
||||||
|
text = text,
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
enabled = enabled,
|
||||||
|
leadingIconRes = leadingIconRes
|
||||||
|
)
|
||||||
|
AirMQButtonStyle.Outlined -> AirMQOutlinedButton(
|
||||||
|
text = text,
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
enabled = enabled,
|
||||||
|
leadingIconRes = leadingIconRes
|
||||||
|
)
|
||||||
|
AirMQButtonStyle.Text -> AirMQTextButton(
|
||||||
|
text = text,
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
enabled = enabled,
|
||||||
|
leadingIconRes = leadingIconRes
|
||||||
|
)
|
||||||
|
AirMQButtonStyle.Gradient -> AirMQGradientButton(
|
||||||
|
text = text,
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
enabled = enabled,
|
||||||
|
leadingIconRes = leadingIconRes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AirMQSocialButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
leadingIconRes: Int? = null,
|
||||||
|
iconTint: Color = Color.Unspecified,
|
||||||
|
containerColor: Color = Color.White,
|
||||||
|
contentColor: Color = Color(0xFF202124)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = LegacyButtonShape,
|
||||||
|
modifier = modifier.height(LegacyButtonHeight),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
disabledContainerColor = LegacyDisabledContainer,
|
||||||
|
disabledContentColor = LegacyDisabledContent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
AirMQButtonLabel(
|
||||||
|
text = text,
|
||||||
|
leadingIconRes = leadingIconRes,
|
||||||
|
iconTint = if (enabled) iconTint else LegacyDisabledContent,
|
||||||
|
textColor = if (enabled) contentColor else LegacyDisabledContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AirMQOutlinedLightButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
leadingIconRes: Int? = null
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = LegacyButtonShape,
|
||||||
|
border = BorderStroke(
|
||||||
|
width = 1.dp,
|
||||||
|
color = if (enabled) Color.White.copy(alpha = 0.55f) else LegacyDisabledContent
|
||||||
|
),
|
||||||
|
modifier = modifier.height(LegacyButtonHeight),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = Color.White,
|
||||||
|
disabledContentColor = LegacyDisabledContent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
AirMQButtonLabel(
|
||||||
|
text = text,
|
||||||
|
leadingIconRes = leadingIconRes,
|
||||||
|
iconTint = if (enabled) Color.White else LegacyDisabledContent,
|
||||||
|
textColor = if (enabled) Color.White else LegacyDisabledContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AirMQContainedButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
leadingIconRes: Int? = null
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = LegacyButtonShape,
|
||||||
|
modifier = modifier.height(LegacyButtonHeight),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = LegacyButtonContained,
|
||||||
|
contentColor = LegacyButtonOnContained,
|
||||||
|
disabledContainerColor = LegacyDisabledContainer,
|
||||||
|
disabledContentColor = LegacyDisabledContent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
AirMQButtonLabel(
|
||||||
|
text = text,
|
||||||
|
leadingIconRes = leadingIconRes,
|
||||||
|
iconTint = if (enabled) LegacyButtonOnContained else LegacyDisabledContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AirMQOutlinedButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
leadingIconRes: Int? = null
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = LegacyButtonShape,
|
||||||
|
border = BorderStroke(
|
||||||
|
width = 1.dp,
|
||||||
|
color = if (enabled) LegacyOutlineLight else LegacyDisabledContent
|
||||||
|
),
|
||||||
|
modifier = modifier.height(LegacyButtonHeight),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = LegacyButtonOnOutlined,
|
||||||
|
disabledContentColor = LegacyDisabledContent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
AirMQButtonLabel(
|
||||||
|
text = text,
|
||||||
|
leadingIconRes = leadingIconRes,
|
||||||
|
iconTint = if (enabled) LegacyButtonOnOutlined else LegacyDisabledContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AirMQTextButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
leadingIconRes: Int? = null
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = LegacyButtonShape,
|
||||||
|
modifier = modifier.height(LegacyButtonHeight),
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = LegacyButtonOnText,
|
||||||
|
disabledContentColor = LegacyDisabledContent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
AirMQButtonLabel(
|
||||||
|
text = text,
|
||||||
|
leadingIconRes = leadingIconRes,
|
||||||
|
iconTint = if (enabled) LegacyButtonOnText else LegacyDisabledContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AirMQGradientButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
leadingIconRes: Int? = null
|
||||||
|
) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val isPressed by interactionSource.collectIsPressedAsState()
|
||||||
|
val overlay = when {
|
||||||
|
!enabled -> Color.Black.copy(alpha = 0.12f)
|
||||||
|
isPressed -> Color.Black.copy(alpha = 0.2f)
|
||||||
|
else -> Color.Transparent
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = LegacyButtonShape,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier = modifier
|
||||||
|
.height(LegacyButtonHeight)
|
||||||
|
.clip(LegacyButtonShape),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
contentColor = LegacyButtonOnContained,
|
||||||
|
disabledContainerColor = Color.Transparent,
|
||||||
|
disabledContentColor = LegacyDisabledContent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(LegacyButtonHeight)
|
||||||
|
.clip(LegacyButtonShape)
|
||||||
|
.background(
|
||||||
|
brush = Brush.horizontalGradient(
|
||||||
|
colors = listOf(LegacyButtonGradientStart, LegacyButtonGradientEnd)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.background(overlay),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
AirMQButtonLabel(
|
||||||
|
text = text,
|
||||||
|
leadingIconRes = leadingIconRes,
|
||||||
|
iconTint = if (enabled) LegacyButtonOnContained else LegacyDisabledContent,
|
||||||
|
textColor = if (enabled) LegacyButtonOnContained else LegacyDisabledContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AirMQButtonLabel(
|
||||||
|
text: String,
|
||||||
|
leadingIconRes: Int?,
|
||||||
|
iconTint: Color,
|
||||||
|
textColor: Color = Color.Unspecified
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (leadingIconRes != null) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = leadingIconRes),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = iconTint,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text.uppercase(),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = textColor,
|
||||||
|
textAlign = TextAlign.Start
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Buttons - All Styles", showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun AirMQButtonsPreviewAllStyles() {
|
||||||
|
AirMQTheme {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
AirMQButton(
|
||||||
|
text = "Contained",
|
||||||
|
onClick = {},
|
||||||
|
style = AirMQButtonStyle.Contained,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
AirMQButton(
|
||||||
|
text = "Outlined",
|
||||||
|
onClick = {},
|
||||||
|
style = AirMQButtonStyle.Outlined,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
AirMQButton(
|
||||||
|
text = "Text",
|
||||||
|
onClick = {},
|
||||||
|
style = AirMQButtonStyle.Text,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
AirMQButton(
|
||||||
|
text = "Gradient",
|
||||||
|
onClick = {},
|
||||||
|
style = AirMQButtonStyle.Gradient,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Buttons - Gradient With Icon", showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun AirMQButtonsPreviewGradientWithIcon() {
|
||||||
|
AirMQTheme {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
AirMQButton(
|
||||||
|
text = "Sign In",
|
||||||
|
onClick = {},
|
||||||
|
style = AirMQButtonStyle.Gradient,
|
||||||
|
leadingIconRes = R.drawable.ic_account,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
AirMQButton(
|
||||||
|
text = "Sign In",
|
||||||
|
onClick = {},
|
||||||
|
style = AirMQButtonStyle.Gradient,
|
||||||
|
leadingIconRes = R.drawable.ic_account,
|
||||||
|
enabled = false,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Buttons - Social", showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun AirMQButtonsPreviewSocial() {
|
||||||
|
AirMQTheme {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
AirMQSocialButton(
|
||||||
|
text = "Sign in with Google",
|
||||||
|
leadingIconRes = R.drawable.ic_google,
|
||||||
|
iconTint = Color.Unspecified,
|
||||||
|
containerColor = Color.White,
|
||||||
|
contentColor = Color(0xFF202124),
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
AirMQSocialButton(
|
||||||
|
text = "Sign in with email",
|
||||||
|
leadingIconRes = R.drawable.ic_account,
|
||||||
|
iconTint = Color.Unspecified,
|
||||||
|
containerColor = Color.White,
|
||||||
|
contentColor = Color(0xFF202124),
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
AirMQOutlinedLightButton(
|
||||||
|
text = "Continue anonymously",
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Buttons - Social Icon Comparison", showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun AirMQButtonsPreviewSocialIconComparison() {
|
||||||
|
AirMQTheme {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
AirMQSocialButton(
|
||||||
|
text = "Sign in with Google",
|
||||||
|
leadingIconRes = R.drawable.ic_google,
|
||||||
|
iconTint = Color.Unspecified,
|
||||||
|
containerColor = Color.White,
|
||||||
|
contentColor = Color(0xFF202124),
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
AirMQSocialButton(
|
||||||
|
text = "Sign in with Google",
|
||||||
|
leadingIconRes = null,
|
||||||
|
containerColor = Color.White,
|
||||||
|
contentColor = Color(0xFF202124),
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -18,7 +17,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
|
|
||||||
data class ScreenAction(
|
data class ScreenAction(
|
||||||
val label: String,
|
val label: String,
|
||||||
val onClick: () -> Unit
|
val onClick: () -> Unit,
|
||||||
|
val style: AirMQButtonStyle = AirMQButtonStyle.Contained
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -61,9 +61,12 @@ private fun ScreenContent(
|
|||||||
}
|
}
|
||||||
content?.invoke()
|
content?.invoke()
|
||||||
actions.forEach { action ->
|
actions.forEach { action ->
|
||||||
Button(onClick = action.onClick, modifier = Modifier.fillMaxWidth()) {
|
AirMQButton(
|
||||||
Text(text = action.label)
|
text = action.label,
|
||||||
}
|
onClick = action.onClick,
|
||||||
|
style = action.style,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
package org.db3.airmq.features.common.chart
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
data class ChartConfig(
|
||||||
|
val lineColor: Color,
|
||||||
|
val fillColor: Color,
|
||||||
|
val backgroundColor: Color,
|
||||||
|
val labelColor: Color? = null,
|
||||||
|
val bottomLabelColor: Color? = null,
|
||||||
|
val centerLabel: String? = null,
|
||||||
|
val multiLineColors: List<Color>? = null,
|
||||||
|
val roundCorners: Boolean = true,
|
||||||
|
val leftTimeLabel: String = "Yesterday",
|
||||||
|
val rightTimeLabel: String = "Now",
|
||||||
|
val unit: String = ""
|
||||||
|
) {
|
||||||
|
val effectiveLabelColor: Color get() = labelColor ?: lineColor
|
||||||
|
val effectiveBottomLabelColor: Color get() = bottomLabelColor ?: labelColor ?: lineColor
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.db3.airmq.features.common.chart
|
||||||
|
|
||||||
|
/** Single data point for chart */
|
||||||
|
data class ChartDataPoint(val timestamp: Long, val value: Float)
|
||||||
|
|
||||||
|
/** Single-line or multi-line dataset */
|
||||||
|
sealed class ChartDataset {
|
||||||
|
data class Single(val points: List<ChartDataPoint>) : ChartDataset()
|
||||||
|
data class Multi(val lines: List<ChartLine>) : ChartDataset()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ChartLine(val label: String, val points: List<ChartDataPoint>)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package org.db3.airmq.features.common.chart
|
||||||
|
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates sine wave data for chart previews and testing.
|
||||||
|
*
|
||||||
|
* @param count Number of data points to generate
|
||||||
|
* @param amplitude Amplitude of the sine wave
|
||||||
|
* @param offset Base value (vertical offset)
|
||||||
|
* @param periodCount Number of full periods across the dataset
|
||||||
|
* @return List of ChartDataPoint with timestamps spread over last 24 hours
|
||||||
|
*/
|
||||||
|
fun generateSineWaveData(
|
||||||
|
count: Int = 24,
|
||||||
|
amplitude: Float = 5f,
|
||||||
|
offset: Float = 20f,
|
||||||
|
periodCount: Float = 2f
|
||||||
|
): List<ChartDataPoint> {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val msPerDay = 24 * 60 * 60 * 1000L
|
||||||
|
val step = msPerDay / count.toLong()
|
||||||
|
return (0 until count).map { i ->
|
||||||
|
val timestamp = now - msPerDay + (i * step)
|
||||||
|
val value = offset + amplitude * sin(2 * PI * periodCount * i / count).toFloat()
|
||||||
|
ChartDataPoint(timestamp = timestamp, value = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates multiline dust data (PM1, PM2.5, PM10) for chart previews.
|
||||||
|
* Uses distinct amplitudes/offsets so each line is clearly visible.
|
||||||
|
*/
|
||||||
|
fun generateMultiLineDustData(count: Int = 24): List<ChartLine> {
|
||||||
|
val basePoints = generateSineWaveData(count = count, amplitude = 8f, offset = 15f, periodCount = 1.5f)
|
||||||
|
return listOf(
|
||||||
|
ChartLine("PM 10", basePoints.map { ChartDataPoint(it.timestamp, it.value + 10f) }),
|
||||||
|
ChartLine("PM 2.5", basePoints.map { ChartDataPoint(it.timestamp, it.value - 2f) }),
|
||||||
|
ChartLine("PM 1", basePoints.map { ChartDataPoint(it.timestamp, it.value - 12f) })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates exactly 2 points - used to verify empty state threshold (no data when < 3).
|
||||||
|
*/
|
||||||
|
fun generateTwoPointsData(): List<ChartDataPoint> {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val msPerDay = 24 * 60 * 60 * 1000L
|
||||||
|
return listOf(
|
||||||
|
ChartDataPoint(now - msPerDay, 18f),
|
||||||
|
ChartDataPoint(now, 22f)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package org.db3.airmq.features.common.chart
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns display unit for sensor type (legacy Chart.getUnits mapping).
|
||||||
|
*/
|
||||||
|
fun getUnitsForSensor(sensorType: String): String = when (sensorType) {
|
||||||
|
"sensor_temperature" -> "°C"
|
||||||
|
"sensor_humidity" -> "%"
|
||||||
|
"sensor_pressure" -> "mmHg"
|
||||||
|
"sensor_dust" -> "µg/m³"
|
||||||
|
"sensor_radioactivity" -> "μSv/h"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun getPlaces(value: Float, rangeY: Float): Int = when {
|
||||||
|
value >= 1000 -> 0
|
||||||
|
value >= 100 -> if (rangeY > 5) 0 else 1
|
||||||
|
value >= 10 -> when {
|
||||||
|
rangeY >= 5 -> 0
|
||||||
|
rangeY >= 1 -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
else -> when {
|
||||||
|
rangeY >= 5 -> 0
|
||||||
|
rangeY >= 1 -> 2
|
||||||
|
else -> 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun formatRounded(f: Float, range: Float): String {
|
||||||
|
val places = getPlaces(f, range)
|
||||||
|
val rounded = roundToPlaces(f.toDouble(), places)
|
||||||
|
val str = rounded.toString()
|
||||||
|
return if (places == 0) str.replace(".0", "") else str
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun roundToPlaces(value: Double, places: Int): Float {
|
||||||
|
if (places == 0) {
|
||||||
|
val c = (value + 0.5).toInt()
|
||||||
|
val n = value + 0.5
|
||||||
|
return if ((n - c).toLong() % 2 == 0L) value.toInt().toFloat() else c.toFloat()
|
||||||
|
}
|
||||||
|
var factor = 1.0
|
||||||
|
repeat(places) { factor *= 10 }
|
||||||
|
return (kotlin.math.ceil(value * factor) / factor).toFloat()
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
package org.db3.airmq.features.common.metric
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
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.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
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.width
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import org.db3.airmq.features.common.metric.getColorFromMetrics
|
||||||
|
import org.db3.airmq.features.common.metric.getSensorIconRes
|
||||||
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
|
import org.db3.airmq.ui.theme.LegacyNavGradientEnd
|
||||||
|
import org.db3.airmq.ui.theme.LegacyNavGradientStart
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single metric gauge with ring, value, units, and icon.
|
||||||
|
* Mirrors legacy [RoundedDiagram] layout and behavior.
|
||||||
|
*
|
||||||
|
* @param value Current value; null shows "?"
|
||||||
|
* @param sensorType Sensor type for icon, units, and progress logic
|
||||||
|
* @param selected When true, shows frosted background
|
||||||
|
* @param onClick Called when gauge is tapped
|
||||||
|
* @param modifier Modifier
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MetricGauge(
|
||||||
|
value: Float?,
|
||||||
|
sensorType: SensorType,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val (progress, progressColor) = when (sensorType) {
|
||||||
|
SensorType.HUMIDITY -> {
|
||||||
|
val p = value?.coerceIn(0f, 100f) ?: 0f
|
||||||
|
p to Color.White
|
||||||
|
}
|
||||||
|
SensorType.DUST -> {
|
||||||
|
val p = value?.coerceIn(0f, 100f) ?: 0f
|
||||||
|
val color = value?.let { getColorFromMetrics(it, sensorType) } ?: Color.White
|
||||||
|
p to color
|
||||||
|
}
|
||||||
|
else -> 0f to Color.White
|
||||||
|
}
|
||||||
|
|
||||||
|
val valueText = when {
|
||||||
|
value == null -> "?"
|
||||||
|
sensorType == SensorType.RADIOACTIVITY -> "%.2f".format(value)
|
||||||
|
else -> kotlin.math.round(value).toInt().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.widthIn(min = 96.dp)
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) { onClick() }
|
||||||
|
) {
|
||||||
|
if (selected) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(Color.White.copy(alpha = 0.1f))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
RingGauge(
|
||||||
|
progress = progress,
|
||||||
|
progressColor = progressColor,
|
||||||
|
modifier = Modifier.size(77.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = valueText,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = sensorType.units(),
|
||||||
|
color = Color.White.copy(alpha = 0.54f),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = getSensorIconRes(sensorType)),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.offset(y = 38.dp)
|
||||||
|
.size(24.dp),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page 1: dust, radioactivity, temperature. */
|
||||||
|
private val Page1Sensors = listOf(
|
||||||
|
SensorType.DUST,
|
||||||
|
SensorType.RADIOACTIVITY,
|
||||||
|
SensorType.TEMPERATURE
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Page 2: humidity, pressure. */
|
||||||
|
private val Page2Sensors = listOf(
|
||||||
|
SensorType.HUMIDITY,
|
||||||
|
SensorType.PRESSURE
|
||||||
|
)
|
||||||
|
|
||||||
|
/** @deprecated Use MetricGaugePager. Kept for backward compatibility. */
|
||||||
|
@Composable
|
||||||
|
fun MetricGaugeRow(
|
||||||
|
selectedSensor: SensorType?,
|
||||||
|
values: Map<SensorType, Float?>,
|
||||||
|
onGaugeSelected: (SensorType) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
for (sensorType in Page1Sensors) {
|
||||||
|
MetricGauge(
|
||||||
|
value = values[sensorType],
|
||||||
|
sensorType = sensorType,
|
||||||
|
selected = selectedSensor == sensorType,
|
||||||
|
onClick = { onGaugeSelected(sensorType) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paged metric gauges: 2 pages, 5 gauges total.
|
||||||
|
* Page 0: dust, radioactivity, temperature.
|
||||||
|
* Page 1: humidity, pressure.
|
||||||
|
*
|
||||||
|
* @param selectedSensor Currently selected sensor
|
||||||
|
* @param values Map of sensor type to value
|
||||||
|
* @param currentPage Current page index (0 or 1)
|
||||||
|
* @param onGaugeSelected Called when a gauge is tapped
|
||||||
|
* @param onPageChanged Called when page changes (e.g. after swipe)
|
||||||
|
* @param modifier Modifier
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun MetricGaugePager(
|
||||||
|
selectedSensor: SensorType,
|
||||||
|
values: Map<SensorType, Float?>,
|
||||||
|
currentPage: Int,
|
||||||
|
onGaugeSelected: (SensorType) -> Unit,
|
||||||
|
onPageChanged: (Int) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val pagerState = rememberPagerState(pageCount = { 2 })
|
||||||
|
LaunchedEffect(currentPage) {
|
||||||
|
if (pagerState.currentPage != currentPage) {
|
||||||
|
pagerState.animateScrollToPage(currentPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
|
onPageChanged(pagerState.currentPage)
|
||||||
|
}
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.height(132.dp),
|
||||||
|
userScrollEnabled = true
|
||||||
|
) { page ->
|
||||||
|
val sensors = if (page == 0) Page1Sensors else Page2Sensors
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
for (sensorType in sensors) {
|
||||||
|
MetricGauge(
|
||||||
|
value = values[sensorType],
|
||||||
|
sensorType = sensorType,
|
||||||
|
selected = selectedSensor == sensorType,
|
||||||
|
onClick = { onGaugeSelected(sensorType) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp, bottom = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
PageIndicatorDot(selected = pagerState.currentPage == 0)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
PageIndicatorDot(selected = pagerState.currentPage == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PageIndicatorDot(
|
||||||
|
selected: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (selected) Color.White
|
||||||
|
else Color.White.copy(alpha = 0.38f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "Dust – value 6")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMetricGaugeDust() {
|
||||||
|
AirMQTheme {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||||
|
colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
MetricGauge(
|
||||||
|
value = 6f,
|
||||||
|
sensorType = SensorType.DUST,
|
||||||
|
selected = false,
|
||||||
|
onClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "Radiation – value 0")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMetricGaugeRadiation() {
|
||||||
|
AirMQTheme {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||||
|
colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
MetricGauge(
|
||||||
|
value = 0f,
|
||||||
|
sensorType = SensorType.RADIOACTIVITY,
|
||||||
|
selected = false,
|
||||||
|
onClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "Temperature – value 3")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMetricGaugeTemperature() {
|
||||||
|
AirMQTheme {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||||
|
colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
MetricGauge(
|
||||||
|
value = 3f,
|
||||||
|
sensorType = SensorType.TEMPERATURE,
|
||||||
|
selected = false,
|
||||||
|
onClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "Humidity – value 65")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMetricGaugeHumidity() {
|
||||||
|
AirMQTheme {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||||
|
colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
MetricGauge(
|
||||||
|
value = 65f,
|
||||||
|
sensorType = SensorType.HUMIDITY,
|
||||||
|
selected = false,
|
||||||
|
onClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "No data")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMetricGaugeNoData() {
|
||||||
|
AirMQTheme {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||||
|
colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
MetricGauge(
|
||||||
|
value = null,
|
||||||
|
sensorType = SensorType.DUST,
|
||||||
|
selected = false,
|
||||||
|
onClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "Selected")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMetricGaugeSelected() {
|
||||||
|
AirMQTheme {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||||
|
colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
MetricGauge(
|
||||||
|
value = 6f,
|
||||||
|
sensorType = SensorType.DUST,
|
||||||
|
selected = true,
|
||||||
|
onClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "Gauge pager – page 1")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMetricGaugePager() {
|
||||||
|
AirMQTheme {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||||
|
colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
MetricGaugePager(
|
||||||
|
selectedSensor = SensorType.DUST,
|
||||||
|
values = mapOf(
|
||||||
|
SensorType.DUST to 6f,
|
||||||
|
SensorType.RADIOACTIVITY to 0f,
|
||||||
|
SensorType.TEMPERATURE to 3f,
|
||||||
|
SensorType.HUMIDITY to 65f,
|
||||||
|
SensorType.PRESSURE to 745f
|
||||||
|
),
|
||||||
|
currentPage = 0,
|
||||||
|
onGaugeSelected = {},
|
||||||
|
onPageChanged = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "Gauge row")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMetricGaugeRow() {
|
||||||
|
AirMQTheme {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||||
|
colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
MetricGaugeRow(
|
||||||
|
selectedSensor = SensorType.DUST,
|
||||||
|
values = mapOf(
|
||||||
|
SensorType.DUST to 6f,
|
||||||
|
SensorType.RADIOACTIVITY to 0f,
|
||||||
|
SensorType.TEMPERATURE to 3f
|
||||||
|
),
|
||||||
|
onGaugeSelected = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "Dust orange band")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMetricGaugeDustOrange() {
|
||||||
|
AirMQTheme {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||||
|
colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
MetricGauge(
|
||||||
|
value = 40f,
|
||||||
|
sensorType = SensorType.DUST,
|
||||||
|
selected = false,
|
||||||
|
onClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package org.db3.airmq.features.common.metric
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.common.chart.getUnitsForSensor
|
||||||
|
import org.db3.airmq.ui.theme.SensorGreen
|
||||||
|
import org.db3.airmq.ui.theme.SensorOrange
|
||||||
|
import org.db3.airmq.ui.theme.SensorPink
|
||||||
|
import org.db3.airmq.ui.theme.SensorPurple
|
||||||
|
import org.db3.airmq.ui.theme.SensorRed
|
||||||
|
import org.db3.airmq.ui.theme.SensorYellow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sensor types supported by the metric gauge.
|
||||||
|
* Maps to legacy Chart sensor constants.
|
||||||
|
*/
|
||||||
|
enum class SensorType(val legacyKey: String) {
|
||||||
|
DUST("sensor_dust"),
|
||||||
|
RADIOACTIVITY("sensor_radioactivity"),
|
||||||
|
TEMPERATURE("sensor_temperature"),
|
||||||
|
HUMIDITY("sensor_humidity"),
|
||||||
|
PRESSURE("sensor_pressure"),
|
||||||
|
CO2("sensor_co2"),
|
||||||
|
VOC("sensor_voc");
|
||||||
|
|
||||||
|
fun units(): String = getUnitsForSensor(legacyKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AQI color bands for dust (PM2.5 µg/m³) per EPA scale.
|
||||||
|
* Returns progress ring color based on value.
|
||||||
|
*/
|
||||||
|
fun getColorFromMetrics(value: Float, sensorType: SensorType): Color = when (sensorType) {
|
||||||
|
SensorType.DUST -> when {
|
||||||
|
value <= 12f -> SensorGreen
|
||||||
|
value <= 35.4f -> SensorYellow
|
||||||
|
value <= 55.4f -> SensorOrange
|
||||||
|
value <= 150.4f -> SensorRed
|
||||||
|
value <= 250.4f -> SensorPink
|
||||||
|
else -> SensorPurple
|
||||||
|
}
|
||||||
|
else -> Color.White
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drawable resource ID for the sensor icon.
|
||||||
|
*/
|
||||||
|
fun getSensorIconRes(sensorType: SensorType): Int = when (sensorType) {
|
||||||
|
SensorType.TEMPERATURE -> R.drawable.ic_temperature
|
||||||
|
SensorType.HUMIDITY -> R.drawable.ic_humidity
|
||||||
|
SensorType.PRESSURE -> R.drawable.ic_pressure
|
||||||
|
SensorType.RADIOACTIVITY -> R.drawable.ic_radiation
|
||||||
|
else -> R.drawable.ic_dust
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package org.db3.airmq.features.common.metric
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.db3.airmq.ui.theme.SensorGreen
|
||||||
|
|
||||||
|
private const val StartAngle = 135f
|
||||||
|
private const val FullSweepAngle = 270f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circular progress gauge drawn as a 270° arc (7 o'clock to 4 o'clock).
|
||||||
|
* Mirrors legacy [RingDiagram] behavior.
|
||||||
|
*
|
||||||
|
* @param progress Progress 0–100, maps to arc sweep
|
||||||
|
* @param progressColor Color of the progress arc
|
||||||
|
* @param modifier Modifier
|
||||||
|
* @param size Total size of the gauge
|
||||||
|
* @param strokeWidth Stroke width of the arc
|
||||||
|
* @param backgroundColor Background ring color (default white38)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RingGauge(
|
||||||
|
progress: Float,
|
||||||
|
progressColor: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
size: Dp = 77.dp,
|
||||||
|
strokeWidth: Dp = 5.dp,
|
||||||
|
backgroundColor: Color = Color.White.copy(alpha = 0.38f)
|
||||||
|
) {
|
||||||
|
val animatedProgress by animateFloatAsState(
|
||||||
|
targetValue = progress.coerceIn(0f, 100f),
|
||||||
|
animationSpec = tween(durationMillis = 400),
|
||||||
|
label = "ring_progress"
|
||||||
|
)
|
||||||
|
val sweepDegrees = (FullSweepAngle / 100f) * animatedProgress
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
modifier = modifier.size(size)
|
||||||
|
) {
|
||||||
|
val strokePx = strokeWidth.toPx()
|
||||||
|
val halfStroke = strokePx / 2f
|
||||||
|
val topLeft = Offset(halfStroke, halfStroke)
|
||||||
|
val arcSize = androidx.compose.ui.geometry.Size(size.toPx() - strokePx, size.toPx() - strokePx)
|
||||||
|
|
||||||
|
// Background ring
|
||||||
|
drawArc(
|
||||||
|
color = backgroundColor,
|
||||||
|
startAngle = StartAngle,
|
||||||
|
sweepAngle = FullSweepAngle,
|
||||||
|
useCenter = false,
|
||||||
|
topLeft = topLeft,
|
||||||
|
size = arcSize,
|
||||||
|
style = Stroke(width = strokePx, cap = StrokeCap.Round)
|
||||||
|
)
|
||||||
|
// Progress ring
|
||||||
|
if (sweepDegrees > 0f) {
|
||||||
|
drawArc(
|
||||||
|
color = progressColor,
|
||||||
|
startAngle = StartAngle,
|
||||||
|
sweepAngle = sweepDegrees,
|
||||||
|
useCenter = false,
|
||||||
|
topLeft = topLeft,
|
||||||
|
size = arcSize,
|
||||||
|
style = Stroke(width = strokePx, cap = StrokeCap.Round)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "RingGauge – 40% progress")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewRingGauge() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(100.dp)
|
||||||
|
.padding(12.dp)
|
||||||
|
) {
|
||||||
|
RingGauge(
|
||||||
|
progress = 40f,
|
||||||
|
progressColor = SensorGreen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
package org.db3.airmq.features.constructor
|
package org.db3.airmq.features.constructor
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.db3.airmq.R
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import org.db3.airmq.features.common.MockScreenScaffold
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import org.db3.airmq.features.common.ScreenAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChartConstructorScreen(onBackToWidgetConstructor: () -> Unit) {
|
fun ChartConstructorScreen(onBackToWidgetConstructor: () -> Unit) {
|
||||||
MockScreenScaffold(
|
MockScreenScaffold(
|
||||||
title = "Chart Constructor",
|
title = stringResource(id = R.string.widget_chart_constructor_title),
|
||||||
subtitle = "Mock chart widget constructor variant.",
|
subtitle = stringResource(id = R.string.coming_soon),
|
||||||
actions = listOf(ScreenAction("Back to Widget Constructor", onBackToWidgetConstructor))
|
actions = listOf(ScreenAction(stringResource(id = R.string.back_to_widget_constructor), onBackToWidgetConstructor))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package org.db3.airmq.features.constructor
|
package org.db3.airmq.features.constructor
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.db3.airmq.R
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import org.db3.airmq.features.common.MockScreenScaffold
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import org.db3.airmq.features.common.ScreenAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MapConstructorScreen(onBackToWidgetConstructor: () -> Unit) {
|
fun MapConstructorScreen(onBackToWidgetConstructor: () -> Unit) {
|
||||||
MockScreenScaffold(
|
MockScreenScaffold(
|
||||||
title = "Map Constructor",
|
title = stringResource(id = R.string.widget_map_constructor_title),
|
||||||
subtitle = "Mock map widget constructor variant.",
|
subtitle = stringResource(id = R.string.coming_soon),
|
||||||
actions = listOf(ScreenAction("Back to Widget Constructor", onBackToWidgetConstructor))
|
actions = listOf(ScreenAction(stringResource(id = R.string.back_to_widget_constructor), onBackToWidgetConstructor))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package org.db3.airmq.features.constructor
|
package org.db3.airmq.features.constructor
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.db3.airmq.R
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import org.db3.airmq.features.common.MockScreenScaffold
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import org.db3.airmq.features.common.ScreenAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NewsConstructorScreen(onBackToWidgetConstructor: () -> Unit) {
|
fun NewsConstructorScreen(onBackToWidgetConstructor: () -> Unit) {
|
||||||
MockScreenScaffold(
|
MockScreenScaffold(
|
||||||
title = "News Constructor",
|
title = stringResource(id = R.string.widget_news_constructor_title),
|
||||||
subtitle = "Mock news widget constructor variant.",
|
subtitle = stringResource(id = R.string.coming_soon),
|
||||||
actions = listOf(ScreenAction("Back to Widget Constructor", onBackToWidgetConstructor))
|
actions = listOf(ScreenAction(stringResource(id = R.string.back_to_widget_constructor), onBackToWidgetConstructor))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package org.db3.airmq.features.constructor
|
package org.db3.airmq.features.constructor
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.db3.airmq.R
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import org.db3.airmq.features.common.MockScreenScaffold
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import org.db3.airmq.features.common.ScreenAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SelectMapWidgetLocationScreen(onDone: () -> Unit) {
|
fun SelectMapWidgetLocationScreen(onDone: () -> Unit) {
|
||||||
MockScreenScaffold(
|
MockScreenScaffold(
|
||||||
title = "Select Map Widget Location",
|
title = stringResource(id = R.string.widget_select_map_location),
|
||||||
subtitle = "Mock map location picker for widget.",
|
subtitle = stringResource(id = R.string.coming_soon),
|
||||||
actions = listOf(ScreenAction("Done", onDone))
|
actions = listOf(ScreenAction(stringResource(id = R.string.toast_done), onDone))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package org.db3.airmq.features.constructor
|
package org.db3.airmq.features.constructor
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.db3.airmq.R
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import org.db3.airmq.features.common.MockScreenScaffold
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import org.db3.airmq.features.common.ScreenAction
|
||||||
|
|
||||||
@@ -13,14 +15,14 @@ fun WidgetConstructorScreen(
|
|||||||
onBackToManage: () -> Unit
|
onBackToManage: () -> Unit
|
||||||
) {
|
) {
|
||||||
MockScreenScaffold(
|
MockScreenScaffold(
|
||||||
title = "Widget Constructor",
|
title = stringResource(id = R.string.title_widget_constructor),
|
||||||
subtitle = "Select constructor variant.",
|
subtitle = stringResource(id = R.string.coming_soon),
|
||||||
actions = listOf(
|
actions = listOf(
|
||||||
ScreenAction("Select Map Widget Location", onOpenSelectMapWidgetLocation),
|
ScreenAction(stringResource(id = R.string.widget_select_map_location), onOpenSelectMapWidgetLocation),
|
||||||
ScreenAction("Open Map Constructor", onOpenMapConstructor),
|
ScreenAction(stringResource(id = R.string.widget_open_map_constructor), onOpenMapConstructor),
|
||||||
ScreenAction("Open Chart Constructor", onOpenChartConstructor),
|
ScreenAction(stringResource(id = R.string.widget_open_chart_constructor), onOpenChartConstructor),
|
||||||
ScreenAction("Open News Constructor", onOpenNewsConstructor),
|
ScreenAction(stringResource(id = R.string.widget_open_news_constructor), onOpenNewsConstructor),
|
||||||
ScreenAction("Back to Manage", onBackToManage)
|
ScreenAction(stringResource(id = R.string.back_to_manage), onBackToManage)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package org.db3.airmq.features.dashboard
|
||||||
|
|
||||||
|
import org.db3.airmq.features.common.chart.ChartDataPoint
|
||||||
|
import org.db3.airmq.features.common.chart.ChartDataset
|
||||||
|
import org.db3.airmq.features.common.chart.ChartLine
|
||||||
|
import org.db3.airmq.features.common.metric.SensorType
|
||||||
|
import org.db3.airmq.sdk.dashboard.SensorSampleRow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps city-average [SensorSampleRow] lists to [ChartDataset] and gauge values for the dashboard.
|
||||||
|
*/
|
||||||
|
internal object DashboardChartMapper {
|
||||||
|
|
||||||
|
/** Synthetic rows for Compose previews (no network). */
|
||||||
|
fun previewStaticRows(): List<SensorSampleRow> {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
return (0 until 12).map { i ->
|
||||||
|
val t = now - (12 - i) * 3_600_000L
|
||||||
|
SensorSampleRow(
|
||||||
|
epochMillis = t,
|
||||||
|
temp = 15f + i % 5,
|
||||||
|
hum = 50f + i,
|
||||||
|
press = 745f - i * 0.5f,
|
||||||
|
pms1 = 4f + i * 0.2f,
|
||||||
|
pms25 = 8f + i * 0.5f,
|
||||||
|
pms10 = 7f + i * 0.3f,
|
||||||
|
radRg = 0.12f,
|
||||||
|
ppm = null,
|
||||||
|
ikav = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chartDataset(rows: List<SensorSampleRow>, sensor: SensorType): ChartDataset {
|
||||||
|
val sorted = rows.sortedBy { it.epochMillis }
|
||||||
|
return when (sensor) {
|
||||||
|
SensorType.DUST -> {
|
||||||
|
val pm10 = sorted.mapNotNull { r ->
|
||||||
|
r.pms10?.let { ChartDataPoint(r.epochMillis, it) }
|
||||||
|
}
|
||||||
|
val pm25 = sorted.mapNotNull { r ->
|
||||||
|
r.pms25?.let { ChartDataPoint(r.epochMillis, it) }
|
||||||
|
}
|
||||||
|
val pm1 = sorted.mapNotNull { r ->
|
||||||
|
r.pms1?.let { ChartDataPoint(r.epochMillis, it) }
|
||||||
|
}
|
||||||
|
ChartDataset.Multi(
|
||||||
|
listOf(
|
||||||
|
ChartLine("PM10", pm10),
|
||||||
|
ChartLine("PM2.5", pm25),
|
||||||
|
ChartLine("PM1", pm1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SensorType.TEMPERATURE -> ChartDataset.Single(
|
||||||
|
sorted.mapNotNull { r -> r.temp?.let { ChartDataPoint(r.epochMillis, it) } }
|
||||||
|
)
|
||||||
|
SensorType.HUMIDITY -> ChartDataset.Single(
|
||||||
|
sorted.mapNotNull { r -> r.hum?.let { ChartDataPoint(r.epochMillis, it) } }
|
||||||
|
)
|
||||||
|
SensorType.PRESSURE -> ChartDataset.Single(
|
||||||
|
sorted.mapNotNull { r -> r.press?.let { ChartDataPoint(r.epochMillis, it) } }
|
||||||
|
)
|
||||||
|
SensorType.RADIOACTIVITY -> ChartDataset.Single(
|
||||||
|
sorted.mapNotNull { r ->
|
||||||
|
(r.radRg ?: r.ppm ?: r.ikav)?.let { ChartDataPoint(r.epochMillis, it) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SensorType.CO2, SensorType.VOC -> ChartDataset.Single(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun gaugeValues(last: SensorSampleRow?): Map<SensorType, Float?> {
|
||||||
|
if (last == null) return SensorType.entries.associateWith { null }
|
||||||
|
return mapOf(
|
||||||
|
SensorType.DUST to last.pms25,
|
||||||
|
SensorType.RADIOACTIVITY to (last.radRg ?: last.ppm ?: last.ikav),
|
||||||
|
SensorType.TEMPERATURE to last.temp,
|
||||||
|
SensorType.HUMIDITY to last.hum,
|
||||||
|
SensorType.PRESSURE to last.press,
|
||||||
|
SensorType.CO2 to null,
|
||||||
|
SensorType.VOC to null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,183 @@
|
|||||||
package org.db3.airmq.features.dashboard
|
package org.db3.airmq.features.dashboard
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.common.chart.AirMQChart
|
||||||
|
import org.db3.airmq.features.common.metric.MetricGaugePager
|
||||||
|
import org.db3.airmq.features.common.metric.SensorType
|
||||||
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
|
import org.db3.airmq.ui.theme.DashboardCityChipGradientEnd
|
||||||
|
import org.db3.airmq.ui.theme.DashboardCityChipGradientStart
|
||||||
|
import org.db3.airmq.ui.theme.LegacyNavGradientEnd
|
||||||
|
import org.db3.airmq.ui.theme.LegacyNavGradientStart
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardScreen(
|
fun DashboardScreen(
|
||||||
onOpenMap: () -> Unit,
|
|
||||||
onOpenManage: () -> Unit,
|
|
||||||
onOpenCity: () -> Unit,
|
onOpenCity: () -> Unit,
|
||||||
onOpenDevice: () -> Unit,
|
onOpenDevice: () -> Unit,
|
||||||
onOpenNews: () -> Unit,
|
onOpenNews: () -> Unit,
|
||||||
onOpenWidgetConstructor: () -> Unit
|
onOpenWidgetConstructor: () -> Unit,
|
||||||
|
viewModel: DashboardViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
MockScreenScaffold(
|
val state by viewModel.uiState.collectAsState()
|
||||||
title = "Dashboard",
|
|
||||||
subtitle = "Bottom-tab equivalent: dashboard",
|
LaunchedEffect(viewModel) {
|
||||||
actions = listOf(
|
viewModel.actions.collect { action ->
|
||||||
ScreenAction("Open Map", onOpenMap),
|
when (action) {
|
||||||
ScreenAction("Open Manage", onOpenManage),
|
DashboardScreenContract.Action.OpenCity -> onOpenCity()
|
||||||
ScreenAction("Open City", onOpenCity),
|
DashboardScreenContract.Action.OpenNews -> { /* handled elsewhere */ }
|
||||||
ScreenAction("Open Device", onOpenDevice),
|
DashboardScreenContract.Action.OpenWidgetConstructor -> { /* handled elsewhere */ }
|
||||||
ScreenAction("Open News", onOpenNews),
|
}
|
||||||
ScreenAction("Open Widget Constructor", onOpenWidgetConstructor)
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
|
DashboardContent(
|
||||||
|
state = state,
|
||||||
|
onEvent = viewModel::onEvent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DashboardContent(
|
||||||
|
state: DashboardScreenContract.State,
|
||||||
|
onEvent: (DashboardScreenContract.Event) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.statusBarsPadding()
|
||||||
|
) {
|
||||||
|
CitySelector(
|
||||||
|
city = state.city,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 12.dp)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
onClick = { onEvent(DashboardScreenContract.Event.CitySelectorClicked) }
|
||||||
|
)
|
||||||
|
MetricGaugePager(
|
||||||
|
selectedSensor = state.selectedSensor,
|
||||||
|
values = state.gaugeValues,
|
||||||
|
currentPage = state.currentPage,
|
||||||
|
onGaugeSelected = { onEvent(DashboardScreenContract.Event.GaugeSelected(it)) },
|
||||||
|
onPageChanged = { onEvent(DashboardScreenContract.Event.PageChanged(it)) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(144.dp)
|
||||||
|
) {
|
||||||
|
AirMQChart(
|
||||||
|
data = state.chartData,
|
||||||
|
config = state.chartConfig.copy(
|
||||||
|
leftTimeLabel = stringResource(R.string.text_yesterday),
|
||||||
|
rightTimeLabel = stringResource(R.string.text_now)
|
||||||
|
),
|
||||||
|
sensorType = state.selectedSensor.legacyKey,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CitySelector(
|
||||||
|
city: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
val chipHeight = 44.dp
|
||||||
|
val chipShape = RoundedCornerShape(chipHeight / 2)
|
||||||
|
val chipBrush = Brush.horizontalGradient(
|
||||||
|
colors = listOf(DashboardCityChipGradientStart, DashboardCityChipGradientEnd)
|
||||||
|
)
|
||||||
|
val borderWidth = 2.dp
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(chipHeight)
|
||||||
|
.border(width = borderWidth, brush = chipBrush, shape = chipShape)
|
||||||
|
.clip(chipShape)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = city,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "Dashboard – dust selected")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewDashboardDust() {
|
||||||
|
AirMQTheme {
|
||||||
|
DashboardContent(
|
||||||
|
state = DashboardScreenContract.previewState(selectedSensor = SensorType.DUST),
|
||||||
|
onEvent = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "Dashboard – page 2 humidity")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewDashboardPage2() {
|
||||||
|
AirMQTheme {
|
||||||
|
DashboardContent(
|
||||||
|
state = DashboardScreenContract.previewState(
|
||||||
|
selectedSensor = SensorType.HUMIDITY,
|
||||||
|
currentPage = 1
|
||||||
|
),
|
||||||
|
onEvent = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package org.db3.airmq.features.dashboard
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.common.chart.ChartConfig
|
||||||
|
import org.db3.airmq.features.common.chart.ChartDataset
|
||||||
|
import org.db3.airmq.features.common.metric.SensorType
|
||||||
|
import org.db3.airmq.ui.theme.ChartBackground
|
||||||
|
import org.db3.airmq.ui.theme.ChartFill
|
||||||
|
import org.db3.airmq.ui.theme.SensorDust1
|
||||||
|
import org.db3.airmq.ui.theme.SensorDust10
|
||||||
|
import org.db3.airmq.ui.theme.SensorDust25
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
object DashboardScreenContract {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun previewState(
|
||||||
|
city: String = "Minsk",
|
||||||
|
selectedSensor: SensorType = SensorType.DUST,
|
||||||
|
currentPage: Int = 0
|
||||||
|
): State {
|
||||||
|
val previewRows = DashboardChartMapper.previewStaticRows()
|
||||||
|
val chartData = DashboardChartMapper.chartDataset(previewRows, selectedSensor)
|
||||||
|
val center = LocalContext.current.getString(
|
||||||
|
when (selectedSensor) {
|
||||||
|
SensorType.DUST -> R.string.sensor_dust
|
||||||
|
SensorType.RADIOACTIVITY -> R.string.sensor_radioactivity
|
||||||
|
SensorType.TEMPERATURE -> R.string.sensor_temperature
|
||||||
|
SensorType.HUMIDITY -> R.string.sensor_humidity
|
||||||
|
SensorType.PRESSURE -> R.string.sensor_pressure
|
||||||
|
SensorType.CO2 -> R.string.sensor_co2
|
||||||
|
SensorType.VOC -> R.string.sensor_voc
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val chartConfig = ChartConfig(
|
||||||
|
lineColor = Color.White,
|
||||||
|
fillColor = ChartFill,
|
||||||
|
backgroundColor = ChartBackground,
|
||||||
|
labelColor = Color.White,
|
||||||
|
leftTimeLabel = "Yesterday",
|
||||||
|
rightTimeLabel = "Now",
|
||||||
|
unit = selectedSensor.units(),
|
||||||
|
centerLabel = center,
|
||||||
|
multiLineColors = if (selectedSensor == SensorType.DUST) {
|
||||||
|
listOf(SensorDust10, SensorDust25, SensorDust1)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return State(
|
||||||
|
city = city,
|
||||||
|
gaugeValues = mapOf(
|
||||||
|
SensorType.DUST to 6f,
|
||||||
|
SensorType.RADIOACTIVITY to 0f,
|
||||||
|
SensorType.TEMPERATURE to 3f,
|
||||||
|
SensorType.HUMIDITY to 65f,
|
||||||
|
SensorType.PRESSURE to 745f
|
||||||
|
),
|
||||||
|
selectedSensor = selectedSensor,
|
||||||
|
currentPage = currentPage,
|
||||||
|
chartData = chartData,
|
||||||
|
chartConfig = chartConfig,
|
||||||
|
chartSensorLabel = chartConfig.centerLabel ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val city: String,
|
||||||
|
val gaugeValues: Map<SensorType, Float?>,
|
||||||
|
val selectedSensor: SensorType,
|
||||||
|
val currentPage: Int,
|
||||||
|
val chartData: ChartDataset?,
|
||||||
|
val chartConfig: ChartConfig,
|
||||||
|
val chartSensorLabel: String
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface Action {
|
||||||
|
data object OpenCity : Action
|
||||||
|
data object OpenNews : Action
|
||||||
|
data object OpenWidgetConstructor : Action
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Event {
|
||||||
|
data object CitySelectorClicked : Event
|
||||||
|
data class GaugeSelected(val sensor: SensorType) : Event
|
||||||
|
data class PageChanged(val page: Int) : Event
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package org.db3.airmq.features.dashboard
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.common.chart.ChartConfig
|
||||||
|
import org.db3.airmq.features.common.chart.ChartDataset
|
||||||
|
import org.db3.airmq.features.common.metric.SensorType
|
||||||
|
import org.db3.airmq.sdk.auth.ApiTokenStore
|
||||||
|
import org.db3.airmq.sdk.city.CityService
|
||||||
|
import org.db3.airmq.sdk.city.DashboardCityContext
|
||||||
|
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository
|
||||||
|
import org.db3.airmq.sdk.dashboard.SensorSampleRow
|
||||||
|
import org.db3.airmq.ui.theme.ChartBackground
|
||||||
|
import org.db3.airmq.ui.theme.ChartFill
|
||||||
|
import org.db3.airmq.ui.theme.SensorDust1
|
||||||
|
import org.db3.airmq.ui.theme.SensorDust10
|
||||||
|
import org.db3.airmq.ui.theme.SensorDust25
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class DashboardViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val cityService: CityService,
|
||||||
|
private val apiTokenStore: ApiTokenStore,
|
||||||
|
private val dashboardMetricsRepository: DashboardMetricsRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(initialState())
|
||||||
|
val uiState: StateFlow<DashboardScreenContract.State> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _actions = MutableSharedFlow<DashboardScreenContract.Action>(extraBufferCapacity = 1)
|
||||||
|
val actions: SharedFlow<DashboardScreenContract.Action> = _actions.asSharedFlow()
|
||||||
|
|
||||||
|
private var cachedAverageRows: List<SensorSampleRow> = emptyList()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(
|
||||||
|
cityService.observeDashboardCityContext(),
|
||||||
|
apiTokenStore.observeToken(),
|
||||||
|
) { _, _ -> }
|
||||||
|
.collectLatest {
|
||||||
|
val ctx = cityService.getResolvedDashboardCityContext()
|
||||||
|
loadDashboardData(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: DashboardScreenContract.Event) {
|
||||||
|
when (event) {
|
||||||
|
DashboardScreenContract.Event.CitySelectorClicked -> _actions.tryEmit(DashboardScreenContract.Action.OpenCity)
|
||||||
|
is DashboardScreenContract.Event.GaugeSelected -> {
|
||||||
|
val sensor = event.sensor
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
selectedSensor = sensor,
|
||||||
|
chartData = DashboardChartMapper.chartDataset(cachedAverageRows, sensor),
|
||||||
|
chartConfig = chartConfigFor(sensor),
|
||||||
|
chartSensorLabel = chartLabelFor(sensor),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is DashboardScreenContract.Event.PageChanged -> {
|
||||||
|
_uiState.update { it.copy(currentPage = event.page) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initialState(): DashboardScreenContract.State {
|
||||||
|
val selected = SensorType.DUST
|
||||||
|
return DashboardScreenContract.State(
|
||||||
|
city = cityService.getDashboardCityDisplayName(),
|
||||||
|
gaugeValues = SensorType.entries.associateWith { null },
|
||||||
|
selectedSensor = selected,
|
||||||
|
currentPage = 0,
|
||||||
|
chartData = ChartDataset.Single(emptyList()),
|
||||||
|
chartConfig = chartConfigFor(selected),
|
||||||
|
chartSensorLabel = chartLabelFor(selected),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadDashboardData(ctx: DashboardCityContext) {
|
||||||
|
if (apiTokenStore.getToken().isNullOrBlank()) {
|
||||||
|
cachedAverageRows = emptyList()
|
||||||
|
val sensor = _uiState.value.selectedSensor
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
city = ctx.displayName,
|
||||||
|
gaugeValues = SensorType.entries.associateWith { null },
|
||||||
|
chartData = DashboardChartMapper.chartDataset(emptyList(), sensor),
|
||||||
|
chartConfig = chartConfigFor(sensor),
|
||||||
|
chartSensorLabel = chartLabelFor(sensor),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = dashboardMetricsRepository.fetchCityDashboard(ctx)
|
||||||
|
val data = result.getOrNull()
|
||||||
|
cachedAverageRows = data?.averageRows.orEmpty()
|
||||||
|
val sensor = _uiState.value.selectedSensor
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
city = ctx.displayName,
|
||||||
|
gaugeValues = DashboardChartMapper.gaugeValues(data?.lastRow),
|
||||||
|
chartData = DashboardChartMapper.chartDataset(cachedAverageRows, sensor),
|
||||||
|
chartConfig = chartConfigFor(sensor),
|
||||||
|
chartSensorLabel = chartLabelFor(sensor),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chartConfigFor(sensor: SensorType): ChartConfig {
|
||||||
|
val label = chartLabelFor(sensor)
|
||||||
|
val base = ChartConfig(
|
||||||
|
lineColor = Color.White,
|
||||||
|
fillColor = ChartFill,
|
||||||
|
backgroundColor = ChartBackground,
|
||||||
|
labelColor = Color.White,
|
||||||
|
leftTimeLabel = "Yesterday",
|
||||||
|
rightTimeLabel = "Now",
|
||||||
|
unit = sensor.units(),
|
||||||
|
centerLabel = label,
|
||||||
|
)
|
||||||
|
return if (sensor == SensorType.DUST) {
|
||||||
|
base.copy(multiLineColors = listOf(SensorDust10, SensorDust25, SensorDust1))
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chartLabelFor(sensor: SensorType): String = context.getString(
|
||||||
|
when (sensor) {
|
||||||
|
SensorType.DUST -> R.string.sensor_dust
|
||||||
|
SensorType.RADIOACTIVITY -> R.string.sensor_radioactivity
|
||||||
|
SensorType.TEMPERATURE -> R.string.sensor_temperature
|
||||||
|
SensorType.HUMIDITY -> R.string.sensor_humidity
|
||||||
|
SensorType.PRESSURE -> R.string.sensor_pressure
|
||||||
|
SensorType.CO2 -> R.string.sensor_co2
|
||||||
|
SensorType.VOC -> R.string.sensor_voc
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
package org.db3.airmq.features.debug
|
package org.db3.airmq.features.debug
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.db3.airmq.R
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import org.db3.airmq.features.common.MockScreenScaffold
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import org.db3.airmq.features.common.ScreenAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DebugScreen(onBackToSettings: () -> Unit) {
|
fun DebugScreen(onBackToSettings: () -> Unit) {
|
||||||
MockScreenScaffold(
|
MockScreenScaffold(
|
||||||
title = "Debug",
|
title = stringResource(id = R.string.title_debug),
|
||||||
subtitle = "Debug-only tools placeholder.",
|
subtitle = stringResource(id = R.string.coming_soon),
|
||||||
actions = listOf(ScreenAction("Back to Settings", onBackToSettings))
|
actions = listOf(ScreenAction(stringResource(id = R.string.back_to_settings), onBackToSettings))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package org.db3.airmq.features.device
|
package org.db3.airmq.features.device
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.db3.airmq.R
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import org.db3.airmq.features.common.MockScreenScaffold
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import org.db3.airmq.features.common.ScreenAction
|
||||||
|
|
||||||
@@ -11,11 +13,11 @@ fun DeviceScreen(
|
|||||||
onShowOnMap: () -> Unit
|
onShowOnMap: () -> Unit
|
||||||
) {
|
) {
|
||||||
MockScreenScaffold(
|
MockScreenScaffold(
|
||||||
title = "Device",
|
title = stringResource(id = R.string.title_device),
|
||||||
subtitle = "Mock deviceId: $deviceId",
|
subtitle = stringResource(id = R.string.coming_soon),
|
||||||
actions = listOf(
|
actions = listOf(
|
||||||
ScreenAction("Select Location", onOpenLocation),
|
ScreenAction(stringResource(id = R.string.title_location), onOpenLocation),
|
||||||
ScreenAction("Show on Map", onShowOnMap)
|
ScreenAction(stringResource(id = R.string.button_view_on_map), onShowOnMap)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,732 @@
|
|||||||
|
package org.db3.airmq.features.device
|
||||||
|
|
||||||
|
import android.R as AndroidR
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
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.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Snackbar
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.sdk.device.domain.DeviceModel
|
||||||
|
import org.db3.airmq.features.common.AirMQButton
|
||||||
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
|
import org.db3.airmq.features.common.AirMQButtonStyle
|
||||||
|
import org.db3.airmq.ui.theme.LegacyBackground
|
||||||
|
import org.db3.airmq.ui.theme.LegacyBlack12
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
|
private const val INFO_URL = "https://docs.google.com/document/d/1JC41nHE5foYhS6beDFTA5a8dQqX3IQoqHJYZbPRtmuM/edit"
|
||||||
|
|
||||||
|
private fun roundLocation(d: Double): String =
|
||||||
|
"%.5f".format(d).replace(',', '.')
|
||||||
|
|
||||||
|
private fun DeviceModel.iconRes(): Int = when (this) {
|
||||||
|
DeviceModel.Basic -> R.drawable.ic_device_basic_active_10
|
||||||
|
DeviceModel.Mobile -> R.drawable.ic_device_mobile_active_10
|
||||||
|
DeviceModel.Solar -> R.drawable.ic_device_solar_active_10
|
||||||
|
DeviceModel.Radiation -> R.drawable.ic_device_radiation_active_10
|
||||||
|
DeviceModel.Custom -> R.drawable.ic_device_custom_active_10
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeviceSettingsScreen(
|
||||||
|
deviceId: String,
|
||||||
|
onOpenLocation: () -> Unit,
|
||||||
|
onShowOnMap: () -> Unit,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: DeviceSettingsViewModel = hiltViewModel(
|
||||||
|
creationCallback = { factory: DeviceSettingsViewModel.Factory ->
|
||||||
|
factory.create(deviceId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LaunchedEffect(viewModel) {
|
||||||
|
viewModel.actions.collectLatest { action ->
|
||||||
|
when (action) {
|
||||||
|
is DeviceSettingsScreenContract.Action.ShowError -> {
|
||||||
|
snackbarHostState.showSnackbar(action.message)
|
||||||
|
}
|
||||||
|
is DeviceSettingsScreenContract.Action.ShowSuccess -> { /* no snackbar for success */ }
|
||||||
|
is DeviceSettingsScreenContract.Action.OpenLocation -> onOpenLocation()
|
||||||
|
is DeviceSettingsScreenContract.Action.ShowOnMap -> onShowOnMap()
|
||||||
|
is DeviceSettingsScreenContract.Action.OpenInfoUrl -> {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, INFO_URL.toUri()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
topBar = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.background(LegacyBackground)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.content_back)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.title_device_config),
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(start = 8.dp),
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(48.dp))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snackbarHost = {
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
snackbar = { data ->
|
||||||
|
Snackbar(
|
||||||
|
snackbarData = data,
|
||||||
|
containerColor = MaterialTheme.colorScheme.inverseSurface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.inverseOnSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
uiState.device == null && uiState.isLoading -> {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
uiState.device == null && !uiState.isLoading -> {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.text_nothing_to_show),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
DeviceSettingsContent(
|
||||||
|
state = uiState,
|
||||||
|
onEvent = viewModel::onEvent
|
||||||
|
)
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.3f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PreviewHeaderColor = Color(0xFF1F5DA5)
|
||||||
|
private val PreviewGreyColor = Color(0x61000000)
|
||||||
|
private val PreviewBlack54 = Color(0x8A000000)
|
||||||
|
private val PreviewOrange = Color(0xFFFF6F00)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceSettingsContent(
|
||||||
|
state: DeviceSettingsScreenContract.State,
|
||||||
|
onEvent: (DeviceSettingsScreenContract.Event) -> Unit,
|
||||||
|
labelColorOverride: Color? = null,
|
||||||
|
greyColorOverride: Color? = null,
|
||||||
|
iconResOverride: Int? = null
|
||||||
|
) {
|
||||||
|
val device = state.device!!
|
||||||
|
var showRenameDialog by remember { mutableStateOf(false) }
|
||||||
|
var showConfigComingSoonDialog by remember { mutableStateOf(false) }
|
||||||
|
var renameText by remember(device.name) { mutableStateOf(device.name) }
|
||||||
|
val headerColor = labelColorOverride ?: colorResource(R.color.headerColor)
|
||||||
|
val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val greyColor = greyColorOverride ?: colorResource(R.color.black38)
|
||||||
|
|
||||||
|
if (showRenameDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showRenameDialog = false },
|
||||||
|
title = { Text(stringResource(R.string.dialog_rename_title)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.hint_new_name),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
BasicTextField(
|
||||||
|
value = renameText,
|
||||||
|
onValueChange = { renameText = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
androidx.compose.material3.TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (renameText.trim().length >= 3) {
|
||||||
|
onEvent(DeviceSettingsScreenContract.Event.RenameSubmitted(renameText.trim()))
|
||||||
|
showRenameDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.button_rename))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
androidx.compose.material3.TextButton(onClick = { showRenameDialog = false }) {
|
||||||
|
Text(stringResource(R.string.button_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showConfigComingSoonDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showConfigComingSoonDialog = false },
|
||||||
|
title = { Text(stringResource(R.string.coming_soon)) },
|
||||||
|
text = { Text(stringResource(R.string.coming_soon)) },
|
||||||
|
confirmButton = {
|
||||||
|
androidx.compose.material3.TextButton(onClick = { showConfigComingSoonDialog = false }) {
|
||||||
|
Text(stringResource(R.string.button_ok))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
val statusIcon = when {
|
||||||
|
state.isConnected -> R.drawable.device_chip_online
|
||||||
|
else -> R.drawable.device_chip_offline
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.background(LegacyBackground)
|
||||||
|
) {
|
||||||
|
HorizontalDivider(color = LegacyBlack12)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(72.dp)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
iconResOverride ?: device.model.iconRes()
|
||||||
|
),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = device.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = device.model.displayName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp),
|
||||||
|
color = greyColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = if (state.isConnected) stringResource(R.string.text_device_connected) else "",
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp),
|
||||||
|
color = greyColor
|
||||||
|
)
|
||||||
|
if (!state.isConnected) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(statusIcon),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = LegacyBlack12)
|
||||||
|
|
||||||
|
ConfigRow(
|
||||||
|
label = stringResource(R.string.text_device_name),
|
||||||
|
value = device.name,
|
||||||
|
onClick = { showRenameDialog = true },
|
||||||
|
trailingIcon = R.drawable.ic_edit,
|
||||||
|
iconTint = (labelColorOverride?.let { PreviewBlack54 } ?: colorResource(R.color.black54)),
|
||||||
|
labelColor = headerColor
|
||||||
|
)
|
||||||
|
|
||||||
|
ConfigRowReadOnly(
|
||||||
|
label = stringResource(R.string.text_device_id),
|
||||||
|
value = device.id,
|
||||||
|
labelColor = headerColor
|
||||||
|
)
|
||||||
|
|
||||||
|
ConfigRowReadOnly(
|
||||||
|
label = stringResource(R.string.text_device_ip),
|
||||||
|
value = device.deviceAddress,
|
||||||
|
labelColor = headerColor
|
||||||
|
)
|
||||||
|
|
||||||
|
ConfigRowWithIndicator(
|
||||||
|
label = stringResource(R.string.text_device_config_version),
|
||||||
|
value = device.configVersion,
|
||||||
|
showIndicator = state.configVersionNeedsUpdate,
|
||||||
|
indicatorColor = labelColorOverride?.let { PreviewOrange } ?: colorResource(R.color.colorOrange),
|
||||||
|
onClick = { if (state.configVersionNeedsUpdate) showConfigComingSoonDialog = true },
|
||||||
|
trailingIcon = if (state.configVersionNeedsUpdate) {
|
||||||
|
R.drawable.ic_upload
|
||||||
|
} else null,
|
||||||
|
iconTint = labelColorOverride?.let { PreviewBlack54 } ?: colorResource(R.color.black54),
|
||||||
|
labelColor = headerColor,
|
||||||
|
indicatorDrawableRes = null
|
||||||
|
)
|
||||||
|
|
||||||
|
ConfigRowReadOnly(
|
||||||
|
label = stringResource(R.string.text_device_wifi),
|
||||||
|
value = state.wifiSsid,
|
||||||
|
labelColor = headerColor
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(color = LegacyBlack12)
|
||||||
|
|
||||||
|
ConfigRowWithIndicator(
|
||||||
|
label = stringResource(R.string.text_device_location),
|
||||||
|
value = if (device.hasLocation()) {
|
||||||
|
stringResource(
|
||||||
|
R.string.text_device_location_set,
|
||||||
|
roundLocation(device.latitude!!),
|
||||||
|
roundLocation(device.longitude!!)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.text_device_location_unset)
|
||||||
|
},
|
||||||
|
showIndicator = !device.hasLocation(),
|
||||||
|
indicatorColor = labelColorOverride?.let { PreviewOrange } ?: colorResource(R.color.colorOrange),
|
||||||
|
onClick = { onEvent(DeviceSettingsScreenContract.Event.OpenLocationClicked) },
|
||||||
|
trailingIcon = if (device.hasLocation()) null else R.drawable.ic_edit,
|
||||||
|
iconTint = labelColorOverride?.let { PreviewBlack54 } ?: colorResource(R.color.black54),
|
||||||
|
labelColor = headerColor,
|
||||||
|
indicatorDrawableRes = null
|
||||||
|
)
|
||||||
|
|
||||||
|
ToggleRow(
|
||||||
|
title = stringResource(R.string.text_device_narodmon_title),
|
||||||
|
subtitle = stringResource(R.string.text_device_narodmon),
|
||||||
|
checked = device.isNarodmonOn,
|
||||||
|
onCheckedChange = { onEvent(DeviceSettingsScreenContract.Event.NarodmonToggled(it)) },
|
||||||
|
onInfoClick = { onEvent(DeviceSettingsScreenContract.Event.NarodmonInfoClicked) },
|
||||||
|
labelColor = headerColor,
|
||||||
|
infoIconTint = labelColorOverride?.let { PreviewBlack54 } ?: colorResource(R.color.black54),
|
||||||
|
infoIconResOverride = null
|
||||||
|
)
|
||||||
|
|
||||||
|
ToggleRow(
|
||||||
|
title = stringResource(R.string.text_device_luftdata_title),
|
||||||
|
subtitle = stringResource(R.string.text_device_luftdata),
|
||||||
|
checked = device.isLuftdataOn,
|
||||||
|
onCheckedChange = { onEvent(DeviceSettingsScreenContract.Event.LuftdataToggled(it)) },
|
||||||
|
onInfoClick = { onEvent(DeviceSettingsScreenContract.Event.LuftdataInfoClicked) },
|
||||||
|
labelColor = headerColor,
|
||||||
|
infoIconTint = labelColorOverride?.let { PreviewBlack54 } ?: colorResource(R.color.black54),
|
||||||
|
infoIconResOverride = null
|
||||||
|
)
|
||||||
|
|
||||||
|
VisibilityRow(
|
||||||
|
label = stringResource(R.string.text_device_visibility),
|
||||||
|
visibilityText = when {
|
||||||
|
!device.hasLocation() -> stringResource(R.string.text_device_visibility_not_registered)
|
||||||
|
device.isPublic -> stringResource(R.string.text_device_visibility_visible)
|
||||||
|
else -> stringResource(R.string.text_device_visibility_private)
|
||||||
|
},
|
||||||
|
publishButtonText = if (device.isPublic) {
|
||||||
|
stringResource(R.string.button_hide)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.button_publish)
|
||||||
|
},
|
||||||
|
isPublishButtonEnabled = device.hasLocation(),
|
||||||
|
onPublishClick = {
|
||||||
|
if (device.isPublic) {
|
||||||
|
onEvent(DeviceSettingsScreenContract.Event.VisibilityHideClicked)
|
||||||
|
} else {
|
||||||
|
onEvent(DeviceSettingsScreenContract.Event.VisibilityPublishClicked)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labelColor = greyColor
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
AirMQButton(
|
||||||
|
text = stringResource(R.string.button_view_on_map),
|
||||||
|
onClick = { onEvent(DeviceSettingsScreenContract.Event.ShowOnMapClicked) },
|
||||||
|
style = AirMQButtonStyle.Contained,
|
||||||
|
enabled = device.hasLocation(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 80.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VisibilityRow(
|
||||||
|
label: String,
|
||||||
|
visibilityText: String,
|
||||||
|
publishButtonText: String,
|
||||||
|
isPublishButtonEnabled: Boolean,
|
||||||
|
onPublishClick: () -> Unit,
|
||||||
|
labelColor: Color
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp),
|
||||||
|
color = labelColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = visibilityText,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AirMQButton(
|
||||||
|
text = publishButtonText,
|
||||||
|
onClick = onPublishClick,
|
||||||
|
enabled = isPublishButtonEnabled,
|
||||||
|
style = AirMQButtonStyle.Text,
|
||||||
|
modifier = Modifier.width(170.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConfigRow(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
trailingIcon: Int,
|
||||||
|
iconTint: Color,
|
||||||
|
labelColor: Color
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium),
|
||||||
|
color = labelColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(trailingIcon),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = iconTint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConfigRowReadOnly(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
labelColor: Color
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium),
|
||||||
|
color = labelColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConfigRowWithIndicator(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
showIndicator: Boolean,
|
||||||
|
indicatorColor: Color,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
trailingIcon: Int?,
|
||||||
|
iconTint: Color,
|
||||||
|
labelColor: Color,
|
||||||
|
indicatorDrawableRes: Int? = null
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium),
|
||||||
|
color = labelColor
|
||||||
|
)
|
||||||
|
if (showIndicator) {
|
||||||
|
Spacer(modifier = Modifier.size(5.dp))
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(indicatorDrawableRes ?: R.drawable.circle_indicator),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(10.dp),
|
||||||
|
tint = indicatorColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (trailingIcon != null) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(trailingIcon),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = iconTint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToggleRow(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
onInfoClick: () -> Unit,
|
||||||
|
labelColor: Color,
|
||||||
|
infoIconTint: Color,
|
||||||
|
infoIconResOverride: Int? = null
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp)
|
||||||
|
.clickable { onCheckedChange(!checked) }
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium),
|
||||||
|
color = labelColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onInfoClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(infoIconResOverride ?: R.drawable.ic_pref_info),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = infoIconTint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = onCheckedChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "Device config – no location")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewDeviceSettingsContentDefault() {
|
||||||
|
AirMQTheme {
|
||||||
|
DeviceSettingsContent(
|
||||||
|
state = DeviceSettingsScreenContract.previewState(),
|
||||||
|
onEvent = {},
|
||||||
|
labelColorOverride = PreviewHeaderColor,
|
||||||
|
greyColorOverride = PreviewGreyColor,
|
||||||
|
iconResOverride = AndroidR.drawable.ic_menu_mylocation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "Device config – with location")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewDeviceSettingsContentWithLocation() {
|
||||||
|
AirMQTheme {
|
||||||
|
DeviceSettingsContent(
|
||||||
|
state = DeviceSettingsScreenContract.previewState(
|
||||||
|
device = DeviceSettingsScreenContract.previewDevice(hasLocation = true, isPublic = false)
|
||||||
|
),
|
||||||
|
onEvent = {},
|
||||||
|
labelColorOverride = PreviewHeaderColor,
|
||||||
|
greyColorOverride = PreviewGreyColor,
|
||||||
|
iconResOverride = AndroidR.drawable.ic_menu_mylocation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "Device config – public visibility")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewDeviceSettingsContentPublicVisibility() {
|
||||||
|
AirMQTheme {
|
||||||
|
DeviceSettingsContent(
|
||||||
|
state = DeviceSettingsScreenContract.previewState(
|
||||||
|
device = DeviceSettingsScreenContract.previewDevice(hasLocation = true, isPublic = true)
|
||||||
|
),
|
||||||
|
onEvent = {},
|
||||||
|
labelColorOverride = PreviewHeaderColor,
|
||||||
|
greyColorOverride = PreviewGreyColor,
|
||||||
|
iconResOverride = AndroidR.drawable.ic_menu_mylocation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "Device config – config needs update")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewDeviceSettingsContentConfigNeedsUpdate() {
|
||||||
|
AirMQTheme {
|
||||||
|
DeviceSettingsContent(
|
||||||
|
state = DeviceSettingsScreenContract.previewState(
|
||||||
|
configVersionNeedsUpdate = true
|
||||||
|
),
|
||||||
|
onEvent = {},
|
||||||
|
labelColorOverride = PreviewHeaderColor,
|
||||||
|
greyColorOverride = PreviewGreyColor,
|
||||||
|
iconResOverride = AndroidR.drawable.ic_menu_mylocation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, name = "Device config – Narodmon/Luftdata on")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewDeviceSettingsContentTogglesOn() {
|
||||||
|
AirMQTheme {
|
||||||
|
DeviceSettingsContent(
|
||||||
|
state = DeviceSettingsScreenContract.previewState(
|
||||||
|
device = DeviceSettingsScreenContract.previewDevice(
|
||||||
|
isNarodmonOn = true,
|
||||||
|
isLuftdataOn = true
|
||||||
|
)
|
||||||
|
),
|
||||||
|
onEvent = {},
|
||||||
|
labelColorOverride = PreviewHeaderColor,
|
||||||
|
greyColorOverride = PreviewGreyColor,
|
||||||
|
iconResOverride = AndroidR.drawable.ic_menu_mylocation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package org.db3.airmq.features.device
|
||||||
|
|
||||||
|
import org.db3.airmq.sdk.device.domain.Device
|
||||||
|
import org.db3.airmq.sdk.device.domain.DeviceModel
|
||||||
|
import org.db3.airmq.sdk.device.domain.OnlineFreshness
|
||||||
|
|
||||||
|
object DeviceSettingsScreenContract {
|
||||||
|
|
||||||
|
fun previewState(
|
||||||
|
device: Device? = previewDevice(),
|
||||||
|
isLoading: Boolean = false,
|
||||||
|
isPendingSync: Boolean = false,
|
||||||
|
configVersionNeedsUpdate: Boolean = false,
|
||||||
|
isConnected: Boolean = true,
|
||||||
|
wifiSsid: String = "Захарова 42"
|
||||||
|
): State = State(
|
||||||
|
device = device,
|
||||||
|
isLoading = isLoading,
|
||||||
|
isPendingSync = isPendingSync,
|
||||||
|
configVersionNeedsUpdate = configVersionNeedsUpdate,
|
||||||
|
isConnected = isConnected,
|
||||||
|
wifiSsid = wifiSsid
|
||||||
|
)
|
||||||
|
|
||||||
|
fun previewDevice(
|
||||||
|
id: String = "12312",
|
||||||
|
name: String = "Захарова 42",
|
||||||
|
model: DeviceModel = DeviceModel.Mobile,
|
||||||
|
deviceAddress: String = "127.0.0.1",
|
||||||
|
configVersion: String = "42",
|
||||||
|
isNarodmonOn: Boolean = false,
|
||||||
|
isLuftdataOn: Boolean = false,
|
||||||
|
hasLocation: Boolean = false,
|
||||||
|
isPublic: Boolean = false
|
||||||
|
): Device = Device(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
model = model,
|
||||||
|
firmwareVersion = "1.0",
|
||||||
|
deviceAddress = deviceAddress,
|
||||||
|
configVersion = configVersion,
|
||||||
|
isNarodmonOn = isNarodmonOn,
|
||||||
|
isLuftdataOn = isLuftdataOn,
|
||||||
|
locationId = null,
|
||||||
|
latitude = if (hasLocation) 53.9 else null,
|
||||||
|
longitude = if (hasLocation) 27.5 else null,
|
||||||
|
isPublic = isPublic,
|
||||||
|
city = null,
|
||||||
|
dataSharingEnabled = false,
|
||||||
|
isOnline = true,
|
||||||
|
onlineFreshness = OnlineFreshness.Fresh,
|
||||||
|
ownerId = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val device: Device? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val isPendingSync: Boolean = false,
|
||||||
|
val configVersionNeedsUpdate: Boolean = false,
|
||||||
|
val isConnected: Boolean = false,
|
||||||
|
val wifiSsid: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface Action {
|
||||||
|
data class ShowError(val message: String) : Action
|
||||||
|
data object ShowSuccess : Action
|
||||||
|
data object OpenLocation : Action
|
||||||
|
data object ShowOnMap : Action
|
||||||
|
data object OpenInfoUrl : Action
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Event {
|
||||||
|
data class RenameSubmitted(val name: String) : Event
|
||||||
|
data object RenameRowClicked : Event
|
||||||
|
data object ConfigUpdateClicked : Event
|
||||||
|
data class NarodmonToggled(val enabled: Boolean) : Event
|
||||||
|
data class LuftdataToggled(val enabled: Boolean) : Event
|
||||||
|
data object NarodmonInfoClicked : Event
|
||||||
|
data object LuftdataInfoClicked : Event
|
||||||
|
data class LocationSet(val latitude: Double, val longitude: Double) : Event
|
||||||
|
data object LocationRemoved : Event
|
||||||
|
data object FirmwareUpdateClicked : Event
|
||||||
|
data object OpenLocationClicked : Event
|
||||||
|
data object ShowOnMapClicked : Event
|
||||||
|
data object VisibilityPublishClicked : Event
|
||||||
|
data object VisibilityHideClicked : Event
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package org.db3.airmq.features.device
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.db3.airmq.features.device.usecases.GetDeviceUseCase
|
||||||
|
import org.db3.airmq.features.device.usecases.ObservePendingSyncUseCase
|
||||||
|
import org.db3.airmq.features.device.usecases.RenameDeviceUseCase
|
||||||
|
import org.db3.airmq.features.device.usecases.SetDeviceLocationUseCase
|
||||||
|
import org.db3.airmq.features.device.usecases.SetDeviceVisibilityUseCase
|
||||||
|
import org.db3.airmq.features.device.usecases.SetLuftdataUseCase
|
||||||
|
import org.db3.airmq.features.device.usecases.SetNarodmonUseCase
|
||||||
|
import org.db3.airmq.features.device.usecases.TriggerFirmwareUpdateUseCase
|
||||||
|
import org.db3.airmq.sdk.device.domain.Device
|
||||||
|
import org.db3.airmq.sdk.device.domain.DeviceModel
|
||||||
|
import org.db3.airmq.sdk.device.domain.OnlineFreshness
|
||||||
|
|
||||||
|
@HiltViewModel(assistedFactory = DeviceSettingsViewModel.Factory::class)
|
||||||
|
class DeviceSettingsViewModel @AssistedInject constructor(
|
||||||
|
@Assisted private val deviceId: String,
|
||||||
|
private val getDeviceUseCase: GetDeviceUseCase,
|
||||||
|
private val renameDeviceUseCase: RenameDeviceUseCase,
|
||||||
|
private val setDeviceLocationUseCase: SetDeviceLocationUseCase,
|
||||||
|
private val setNarodmonUseCase: SetNarodmonUseCase,
|
||||||
|
private val setLuftdataUseCase: SetLuftdataUseCase,
|
||||||
|
private val setDeviceVisibilityUseCase: SetDeviceVisibilityUseCase,
|
||||||
|
private val triggerFirmwareUpdateUseCase: TriggerFirmwareUpdateUseCase,
|
||||||
|
private val observePendingSyncUseCase: ObservePendingSyncUseCase
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(deviceId: String): DeviceSettingsViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(DeviceSettingsScreenContract.State())
|
||||||
|
val uiState: StateFlow<DeviceSettingsScreenContract.State> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _actions = MutableSharedFlow<DeviceSettingsScreenContract.Action>(extraBufferCapacity = 1)
|
||||||
|
val actions: SharedFlow<DeviceSettingsScreenContract.Action> = _actions.asSharedFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(
|
||||||
|
getDeviceUseCase(deviceId),
|
||||||
|
observePendingSyncUseCase(deviceId)
|
||||||
|
) { device, hasPending ->
|
||||||
|
device to hasPending
|
||||||
|
}.collect { (device, hasPending) ->
|
||||||
|
val displayDevice = device ?: stubDevice()
|
||||||
|
val configVersionNeedsUpdate = deriveConfigVersionNeedsUpdate(displayDevice)
|
||||||
|
val isConnected = true // Stub: always connected for UI testing
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
device = displayDevice,
|
||||||
|
isPendingSync = hasPending,
|
||||||
|
configVersionNeedsUpdate = configVersionNeedsUpdate,
|
||||||
|
isConnected = isConnected,
|
||||||
|
wifiSsid = if (device == null) "Захарова 42" else it.wifiSsid.takeIf { s -> s.isNotEmpty() } ?: "—"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stubDevice(): Device = Device(
|
||||||
|
id = "12312",
|
||||||
|
name = "Захарова 42",
|
||||||
|
model = DeviceModel.Mobile,
|
||||||
|
firmwareVersion = "1.0",
|
||||||
|
deviceAddress = "127.0.0.1",
|
||||||
|
configVersion = "42",
|
||||||
|
isNarodmonOn = false,
|
||||||
|
isLuftdataOn = false,
|
||||||
|
locationId = null,
|
||||||
|
latitude = null,
|
||||||
|
longitude = null,
|
||||||
|
isPublic = false,
|
||||||
|
city = null,
|
||||||
|
dataSharingEnabled = false,
|
||||||
|
isOnline = true,
|
||||||
|
onlineFreshness = OnlineFreshness.Fresh,
|
||||||
|
ownerId = null
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun deriveConfigVersionNeedsUpdate(device: Device): Boolean {
|
||||||
|
// Stub: compare configVersion to expected (e.g. 42); for testing assume up-to-date
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: DeviceSettingsScreenContract.Event) {
|
||||||
|
when (event) {
|
||||||
|
is DeviceSettingsScreenContract.Event.RenameSubmitted -> renameDevice(event.name)
|
||||||
|
is DeviceSettingsScreenContract.Event.RenameRowClicked -> { /* UI opens dialog */ }
|
||||||
|
is DeviceSettingsScreenContract.Event.ConfigUpdateClicked -> configUpdateStub()
|
||||||
|
is DeviceSettingsScreenContract.Event.NarodmonToggled -> setNarodmon(event.enabled)
|
||||||
|
is DeviceSettingsScreenContract.Event.LuftdataToggled -> setLuftdata(event.enabled)
|
||||||
|
is DeviceSettingsScreenContract.Event.NarodmonInfoClicked,
|
||||||
|
is DeviceSettingsScreenContract.Event.LuftdataInfoClicked -> _actions.tryEmit(
|
||||||
|
DeviceSettingsScreenContract.Action.OpenInfoUrl
|
||||||
|
)
|
||||||
|
is DeviceSettingsScreenContract.Event.LocationSet -> setLocation(event.latitude, event.longitude)
|
||||||
|
is DeviceSettingsScreenContract.Event.LocationRemoved -> removeLocation()
|
||||||
|
is DeviceSettingsScreenContract.Event.FirmwareUpdateClicked -> triggerFirmwareUpdate()
|
||||||
|
is DeviceSettingsScreenContract.Event.OpenLocationClicked -> _actions.tryEmit(
|
||||||
|
DeviceSettingsScreenContract.Action.OpenLocation
|
||||||
|
)
|
||||||
|
is DeviceSettingsScreenContract.Event.ShowOnMapClicked -> _actions.tryEmit(
|
||||||
|
DeviceSettingsScreenContract.Action.ShowOnMap
|
||||||
|
)
|
||||||
|
is DeviceSettingsScreenContract.Event.VisibilityPublishClicked -> setVisibility(true)
|
||||||
|
is DeviceSettingsScreenContract.Event.VisibilityHideClicked -> setVisibility(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configUpdateStub() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
delay(800)
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renameDevice(name: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
delay(500) // Stub simulation
|
||||||
|
val result = renameDeviceUseCase(deviceId, name)
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
if (result.isSuccess) {
|
||||||
|
_actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess)
|
||||||
|
} else {
|
||||||
|
_actions.tryEmit(
|
||||||
|
DeviceSettingsScreenContract.Action.ShowError(
|
||||||
|
result.exceptionOrNull()?.message ?: "Failed to rename"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setNarodmon(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
delay(300)
|
||||||
|
val result = setNarodmonUseCase(deviceId, enabled)
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
if (result.isSuccess) {
|
||||||
|
_actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess)
|
||||||
|
} else {
|
||||||
|
_actions.tryEmit(
|
||||||
|
DeviceSettingsScreenContract.Action.ShowError(
|
||||||
|
result.exceptionOrNull()?.message ?: "Failed to update Narodmon"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setLuftdata(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
delay(300)
|
||||||
|
val result = setLuftdataUseCase(deviceId, enabled)
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
if (result.isSuccess) {
|
||||||
|
_actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess)
|
||||||
|
} else {
|
||||||
|
_actions.tryEmit(
|
||||||
|
DeviceSettingsScreenContract.Action.ShowError(
|
||||||
|
result.exceptionOrNull()?.message ?: "Failed to update Sensor.community"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setLocation(latitude: Double, longitude: Double) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
val result = setDeviceLocationUseCase.setLocation(deviceId, latitude, longitude)
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
if (result.isSuccess) {
|
||||||
|
_actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess)
|
||||||
|
} else {
|
||||||
|
_actions.tryEmit(
|
||||||
|
DeviceSettingsScreenContract.Action.ShowError(
|
||||||
|
result.exceptionOrNull()?.message ?: "Failed to set location"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeLocation() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
val result = setDeviceLocationUseCase.removeLocation(deviceId)
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
if (result.isSuccess) {
|
||||||
|
_actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess)
|
||||||
|
} else {
|
||||||
|
_actions.tryEmit(
|
||||||
|
DeviceSettingsScreenContract.Action.ShowError(
|
||||||
|
result.exceptionOrNull()?.message ?: "Failed to remove location"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setVisibility(isPublic: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
delay(300)
|
||||||
|
val result = setDeviceVisibilityUseCase(deviceId, isPublic)
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
if (result.isFailure) {
|
||||||
|
_actions.tryEmit(
|
||||||
|
DeviceSettingsScreenContract.Action.ShowError(
|
||||||
|
result.exceptionOrNull()?.message ?: "Failed to update visibility"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun triggerFirmwareUpdate() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
val result = triggerFirmwareUpdateUseCase(deviceId)
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
if (result.isSuccess) {
|
||||||
|
_actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess)
|
||||||
|
} else {
|
||||||
|
_actions.tryEmit(
|
||||||
|
DeviceSettingsScreenContract.Action.ShowError(
|
||||||
|
result.exceptionOrNull()?.message ?: "Failed to trigger firmware update"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.db3.airmq.features.device.usecases
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.db3.airmq.sdk.device.domain.Device
|
||||||
|
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case to observe a single device by ID.
|
||||||
|
*/
|
||||||
|
class GetDeviceUseCase @Inject constructor(
|
||||||
|
private val repository: DeviceRepository
|
||||||
|
) {
|
||||||
|
operator fun invoke(deviceId: String): Flow<Device?> = repository.observeDevice(deviceId)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.db3.airmq.features.device.usecases
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.db3.airmq.sdk.device.domain.Device
|
||||||
|
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case to observe the current user's devices from local database.
|
||||||
|
*/
|
||||||
|
class GetMyDevicesUseCase @Inject constructor(
|
||||||
|
private val repository: DeviceRepository
|
||||||
|
) {
|
||||||
|
operator fun invoke(): Flow<List<Device>> = repository.observeDevices()
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.db3.airmq.features.device.usecases
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case to observe whether a device has pending mutations (offline changes to sync).
|
||||||
|
*/
|
||||||
|
class ObservePendingSyncUseCase @Inject constructor(
|
||||||
|
private val repository: DeviceRepository
|
||||||
|
) {
|
||||||
|
operator fun invoke(deviceId: String): Flow<Boolean> =
|
||||||
|
repository.observeHasPendingMutations(deviceId)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package org.db3.airmq.features.device.usecases
|
||||||
|
|
||||||
|
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case to rename a device.
|
||||||
|
* Validates input and delegates to repository.
|
||||||
|
*/
|
||||||
|
class RenameDeviceUseCase @Inject constructor(
|
||||||
|
private val repository: DeviceRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(deviceId: String, newName: String): Result<Unit> {
|
||||||
|
val trimmed = newName.trim()
|
||||||
|
if (trimmed.length < 3) {
|
||||||
|
return Result.failure(IllegalArgumentException("Name must be at least 3 characters"))
|
||||||
|
}
|
||||||
|
return repository.renameDevice(deviceId, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.db3.airmq.features.device.usecases
|
||||||
|
|
||||||
|
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case to enable or disable data sharing for a device.
|
||||||
|
*/
|
||||||
|
class SetDataSharingUseCase @Inject constructor(
|
||||||
|
private val repository: DeviceRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(deviceId: String, enabled: Boolean): Result<Unit> =
|
||||||
|
repository.setDataSharing(deviceId, enabled)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package org.db3.airmq.features.device.usecases
|
||||||
|
|
||||||
|
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case to set or remove device location.
|
||||||
|
*/
|
||||||
|
class SetDeviceLocationUseCase @Inject constructor(
|
||||||
|
private val repository: DeviceRepository
|
||||||
|
) {
|
||||||
|
suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result<Unit> =
|
||||||
|
repository.setLocation(deviceId, latitude, longitude)
|
||||||
|
|
||||||
|
suspend fun removeLocation(deviceId: String): Result<Unit> =
|
||||||
|
repository.removeLocation(deviceId)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.db3.airmq.features.device.usecases
|
||||||
|
|
||||||
|
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case to set device visibility (publish/hide location on map).
|
||||||
|
* Only effective when device has location.
|
||||||
|
*/
|
||||||
|
class SetDeviceVisibilityUseCase @Inject constructor(
|
||||||
|
private val repository: DeviceRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(deviceId: String, isPublic: Boolean): Result<Unit> =
|
||||||
|
repository.setVisibility(deviceId, isPublic)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.db3.airmq.features.device.usecases
|
||||||
|
|
||||||
|
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case to enable or disable Sensor.community (Luftdata) data sharing for a device.
|
||||||
|
*/
|
||||||
|
class SetLuftdataUseCase @Inject constructor(
|
||||||
|
private val repository: DeviceRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(deviceId: String, enabled: Boolean): Result<Unit> =
|
||||||
|
repository.setLuftdata(deviceId, enabled)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.db3.airmq.features.device.usecases
|
||||||
|
|
||||||
|
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case to enable or disable Narodmon.ru data sharing for a device.
|
||||||
|
*/
|
||||||
|
class SetNarodmonUseCase @Inject constructor(
|
||||||
|
private val repository: DeviceRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(deviceId: String, enabled: Boolean): Result<Unit> =
|
||||||
|
repository.setNarodmon(deviceId, enabled)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.db3.airmq.features.device.usecases
|
||||||
|
|
||||||
|
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case to trigger firmware update for a device.
|
||||||
|
* Requires connectivity - blocks when offline.
|
||||||
|
*/
|
||||||
|
class TriggerFirmwareUpdateUseCase @Inject constructor(
|
||||||
|
private val repository: DeviceRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(deviceId: String): Result<Unit> =
|
||||||
|
repository.triggerFirmwareUpdate(deviceId)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package org.db3.airmq.features.entry
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.telephony.TelephonyManager
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.android.gms.location.LocationServices
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.db3.airmq.sdk.city.CityService
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable that runs the run-once city resolution flow when city_init is false.
|
||||||
|
* Requests location permission, gets location or country, and calls CityService.initialize().
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CityInitializer(
|
||||||
|
cityService: CityService,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity = context as? android.app.Activity
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { result ->
|
||||||
|
val granted = result.values.any { it }
|
||||||
|
scope.launch {
|
||||||
|
val location = if (granted && activity != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
LocationServices.getFusedLocationProviderClient(activity).getLastLocation().await()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
|
||||||
|
val country = if (!granted) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
(context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager)
|
||||||
|
?.networkCountryIso
|
||||||
|
?.takeIf { !it.isNullOrBlank() }
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
|
||||||
|
cityService.initialize(granted, location, country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(cityService) {
|
||||||
|
if (!cityService.isCityInitComplete()) {
|
||||||
|
val permissions = arrayOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
)
|
||||||
|
val allGranted = permissions.all {
|
||||||
|
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (allGranted) {
|
||||||
|
val location = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
LocationServices.getFusedLocationProviderClient(context).getLastLocation().await()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
val country = withContext(Dispatchers.IO) {
|
||||||
|
(context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager)
|
||||||
|
?.networkCountryIso
|
||||||
|
?.takeIf { !it.isNullOrBlank() }
|
||||||
|
}
|
||||||
|
cityService.initialize(true, location, country)
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(permissions)
|
||||||
|
}
|
||||||
|
} else if (cityService.getDetectAutomatically()) {
|
||||||
|
val permissions = arrayOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
)
|
||||||
|
val allGranted = permissions.all {
|
||||||
|
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (allGranted && activity != null) {
|
||||||
|
val location = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
LocationServices.getFusedLocationProviderClient(activity).getLastLocation().await()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
cityService.refreshCityFromLocation(location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content()
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
package org.db3.airmq.features.entry
|
package org.db3.airmq.features.entry
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.db3.airmq.R
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import org.db3.airmq.features.common.MockScreenScaffold
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import org.db3.airmq.features.common.ScreenAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SplashScreen(onContinue: () -> Unit) {
|
fun SplashScreen(onContinue: () -> Unit) {
|
||||||
MockScreenScaffold(
|
MockScreenScaffold(
|
||||||
title = "Splash",
|
title = stringResource(id = R.string.screen_splash_title),
|
||||||
subtitle = "Entry flow starting point.",
|
subtitle = stringResource(id = R.string.coming_soon),
|
||||||
actions = listOf(ScreenAction(label = "Continue to Wizard", onClick = onContinue))
|
actions = listOf(ScreenAction(label = stringResource(id = R.string.screen_continue_to_wizard), onClick = onContinue))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package org.db3.airmq.features.entry
|
package org.db3.airmq.features.entry
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.db3.airmq.R
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import org.db3.airmq.features.common.MockScreenScaffold
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import org.db3.airmq.features.common.ScreenAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WizardScreen(onFinish: () -> Unit) {
|
fun WizardScreen(onFinish: () -> Unit) {
|
||||||
MockScreenScaffold(
|
MockScreenScaffold(
|
||||||
title = "Wizard",
|
title = stringResource(id = R.string.screen_wizard_title),
|
||||||
subtitle = "Mock onboarding/wizard flow.",
|
subtitle = stringResource(id = R.string.coming_soon),
|
||||||
actions = listOf(ScreenAction(label = "Finish Wizard", onClick = onFinish))
|
actions = listOf(ScreenAction(label = stringResource(id = R.string.screen_finish_wizard), onClick = onFinish))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package org.db3.airmq.features.location
|
package org.db3.airmq.features.location
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.db3.airmq.R
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import org.db3.airmq.features.common.MockScreenScaffold
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import org.db3.airmq.features.common.ScreenAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LocationScreen(onBackToManage: () -> Unit) {
|
fun LocationScreen(onBackToManage: () -> Unit) {
|
||||||
MockScreenScaffold(
|
MockScreenScaffold(
|
||||||
title = "Location",
|
title = stringResource(id = R.string.title_location),
|
||||||
subtitle = "Mock location picker/editor screen.",
|
subtitle = stringResource(id = R.string.coming_soon),
|
||||||
actions = listOf(ScreenAction("Back to Manage", onBackToManage))
|
actions = listOf(ScreenAction(stringResource(id = R.string.back_to_manage), onBackToManage))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package org.db3.airmq.features.login
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
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.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.autofill.ContentType
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
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.semantics.contentType
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import org.db3.airmq.features.common.AirMQOutlinedLightButton
|
||||||
|
import org.db3.airmq.features.login.EmailLoginScreenContract.Action
|
||||||
|
import org.db3.airmq.features.login.EmailLoginScreenContract.Event
|
||||||
|
import org.db3.airmq.features.login.EmailLoginScreenContract.State
|
||||||
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
|
|
||||||
|
private val LegacyLoginGradientStart = Color(0xFF449CF5)
|
||||||
|
private val LegacyLoginGradientEnd = Color(0xFF5CE4BB)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EmailLoginScreen(
|
||||||
|
onLogInToManage: () -> Unit,
|
||||||
|
onOpenRegister: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: EmailLoginViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LaunchedEffect(viewModel) {
|
||||||
|
viewModel.actions.collectLatest { action ->
|
||||||
|
when (action) {
|
||||||
|
Action.OpenManage -> onLogInToManage()
|
||||||
|
Action.NavigateToRegister -> onOpenRegister()
|
||||||
|
is Action.ShowMessage -> {
|
||||||
|
Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EmailLoginScreenContent(
|
||||||
|
uiState = uiState,
|
||||||
|
onEvent = viewModel::onEvent,
|
||||||
|
onBack = onBack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmailLoginScreenContent(
|
||||||
|
uiState: State,
|
||||||
|
onEvent: (Event) -> Unit,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
brush = Brush.linearGradient(
|
||||||
|
colorStops = arrayOf(
|
||||||
|
0.0f to LegacyLoginGradientStart,
|
||||||
|
0.35f to LegacyLoginGradientStart,
|
||||||
|
1.0f to LegacyLoginGradientEnd
|
||||||
|
),
|
||||||
|
start = Offset(0f, 0f),
|
||||||
|
end = Offset(1200f, 1200f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(horizontal = 40.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_arrow_back),
|
||||||
|
contentDescription = stringResource(id = R.string.content_back),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.text_sign_in_email),
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 36.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.email,
|
||||||
|
onValueChange = { onEvent(Event.EmailChanged(it)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.semantics {
|
||||||
|
contentType = ContentType.Username + ContentType.EmailAddress
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.hint_email),
|
||||||
|
color = Color.White.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = Color.White,
|
||||||
|
unfocusedTextColor = Color.White,
|
||||||
|
cursorColor = Color.White,
|
||||||
|
focusedBorderColor = Color.White.copy(alpha = 0.8f),
|
||||||
|
unfocusedBorderColor = Color.White.copy(alpha = 0.55f),
|
||||||
|
focusedContainerColor = Color.White.copy(alpha = 0.1f),
|
||||||
|
unfocusedContainerColor = Color.White.copy(alpha = 0.05f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.password,
|
||||||
|
onValueChange = { onEvent(Event.PasswordChanged(it)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.semantics { contentType = ContentType.Password },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.hint_password),
|
||||||
|
color = Color.White.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = Color.White,
|
||||||
|
unfocusedTextColor = Color.White,
|
||||||
|
cursorColor = Color.White,
|
||||||
|
focusedBorderColor = Color.White.copy(alpha = 0.8f),
|
||||||
|
unfocusedBorderColor = Color.White.copy(alpha = 0.55f),
|
||||||
|
focusedContainerColor = Color.White.copy(alpha = 0.1f),
|
||||||
|
unfocusedContainerColor = Color.White.copy(alpha = 0.05f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
} else {
|
||||||
|
AirMQOutlinedLightButton(
|
||||||
|
text = stringResource(id = R.string.button_sign_in),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = { onEvent(Event.SignInClicked) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = { onEvent(Event.RegisterClicked) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.button_register),
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun EmailLoginScreenPreview() {
|
||||||
|
AirMQTheme {
|
||||||
|
EmailLoginScreenContent(
|
||||||
|
uiState = State(),
|
||||||
|
onEvent = {},
|
||||||
|
onBack = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package org.db3.airmq.features.login
|
||||||
|
|
||||||
|
object EmailLoginScreenContract {
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val email: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
val isLoading: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface Action {
|
||||||
|
data object OpenManage : Action
|
||||||
|
data object NavigateToRegister : Action
|
||||||
|
data class ShowMessage(val message: String) : Action
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Event {
|
||||||
|
data class EmailChanged(val value: String) : Event
|
||||||
|
data class PasswordChanged(val value: String) : Event
|
||||||
|
data object SignInClicked : Event
|
||||||
|
data object RegisterClicked : Event
|
||||||
|
data object BackClicked : Event
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package org.db3.airmq.features.login
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Patterns
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.login.EmailLoginScreenContract.Action
|
||||||
|
import org.db3.airmq.features.login.EmailLoginScreenContract.Event
|
||||||
|
import org.db3.airmq.features.login.EmailLoginScreenContract.State
|
||||||
|
import org.db3.airmq.sdk.auth.AuthService
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class EmailLoginViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
|
private val authService: AuthService
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(State())
|
||||||
|
val uiState: StateFlow<State> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
|
||||||
|
val actions: SharedFlow<Action> = _actions.asSharedFlow()
|
||||||
|
|
||||||
|
fun onEvent(event: Event) {
|
||||||
|
when (event) {
|
||||||
|
is Event.EmailChanged -> {
|
||||||
|
_uiState.value = _uiState.value.copy(email = event.value)
|
||||||
|
}
|
||||||
|
is Event.PasswordChanged -> {
|
||||||
|
_uiState.value = _uiState.value.copy(password = event.value)
|
||||||
|
}
|
||||||
|
Event.SignInClicked -> submitEmailAuth()
|
||||||
|
Event.RegisterClicked -> _actions.tryEmit(Action.NavigateToRegister)
|
||||||
|
Event.BackClicked -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun submitEmailAuth() {
|
||||||
|
val email = _uiState.value.email.trim()
|
||||||
|
val password = _uiState.value.password
|
||||||
|
if (email.isEmpty() || password.isEmpty()) {
|
||||||
|
_actions.tryEmit(
|
||||||
|
Action.ShowMessage(appContext.getString(R.string.toast_email_fields_required))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
|
||||||
|
_actions.tryEmit(
|
||||||
|
Action.ShowMessage(appContext.getString(R.string.toast_invalid_email))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
try {
|
||||||
|
val result = authService.loginWithEmailPassword(email, password)
|
||||||
|
if (result.isSuccess) {
|
||||||
|
_actions.tryEmit(Action.OpenManage)
|
||||||
|
} else {
|
||||||
|
val message = result.exceptionOrNull()?.message
|
||||||
|
?: appContext.getString(R.string.toast_email_auth_failed)
|
||||||
|
_actions.tryEmit(Action.ShowMessage(message))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
package org.db3.airmq.features.login
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
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.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.autofill.ContentType
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalAutofillManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
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.semantics.contentType
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.common.AirMQOutlinedLightButton
|
||||||
|
import org.db3.airmq.features.login.EmailRegisterScreenContract.Action
|
||||||
|
import org.db3.airmq.features.login.EmailRegisterScreenContract.Event
|
||||||
|
import org.db3.airmq.features.login.EmailRegisterScreenContract.State
|
||||||
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
|
|
||||||
|
private val LegacyLoginGradientStart = Color(0xFF449CF5)
|
||||||
|
private val LegacyLoginGradientEnd = Color(0xFF5CE4BB)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EmailRegisterScreen(
|
||||||
|
onRegisterSuccess: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: EmailRegisterViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val autofillManager = LocalAutofillManager.current
|
||||||
|
|
||||||
|
LaunchedEffect(viewModel) {
|
||||||
|
viewModel.actions.collectLatest { action ->
|
||||||
|
when (action) {
|
||||||
|
Action.OpenManage -> {
|
||||||
|
autofillManager?.commit()
|
||||||
|
onRegisterSuccess()
|
||||||
|
}
|
||||||
|
is Action.ShowMessage -> {
|
||||||
|
Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EmailRegisterScreenContent(
|
||||||
|
uiState = uiState,
|
||||||
|
onEvent = viewModel::onEvent,
|
||||||
|
onBack = onBack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmailRegisterScreenContent(
|
||||||
|
uiState: State,
|
||||||
|
onEvent: (Event) -> Unit,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val fieldEnabled = !uiState.isLoading
|
||||||
|
val scroll = rememberScrollState()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
brush = Brush.linearGradient(
|
||||||
|
colorStops = arrayOf(
|
||||||
|
0.0f to LegacyLoginGradientStart,
|
||||||
|
0.35f to LegacyLoginGradientStart,
|
||||||
|
1.0f to LegacyLoginGradientEnd
|
||||||
|
),
|
||||||
|
start = Offset(0f, 0f),
|
||||||
|
end = Offset(1200f, 1200f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(horizontal = 40.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack, enabled = fieldEnabled) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_arrow_back),
|
||||||
|
contentDescription = stringResource(id = R.string.content_back),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(scroll),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.text_register_email_title),
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 36.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.name,
|
||||||
|
onValueChange = { onEvent(Event.NameChanged(it)) },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = fieldEnabled,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.semantics { contentType = ContentType.PersonFullName },
|
||||||
|
isError = uiState.nameError != null,
|
||||||
|
supportingText = uiState.nameError?.let { err ->
|
||||||
|
{ Text(err, color = Color.White.copy(alpha = 0.9f)) }
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.hint_register_name),
|
||||||
|
color = Color.White.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
colors = legacyLoginFieldColors()
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.email,
|
||||||
|
onValueChange = { onEvent(Event.EmailChanged(it)) },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = fieldEnabled,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.semantics {
|
||||||
|
contentType = ContentType.NewUsername + ContentType.EmailAddress
|
||||||
|
},
|
||||||
|
isError = uiState.emailError != null,
|
||||||
|
supportingText = uiState.emailError?.let { err ->
|
||||||
|
{ Text(err, color = Color.White.copy(alpha = 0.9f)) }
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.hint_email),
|
||||||
|
color = Color.White.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
colors = legacyLoginFieldColors()
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.password,
|
||||||
|
onValueChange = { onEvent(Event.PasswordChanged(it)) },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = fieldEnabled,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.semantics { contentType = ContentType.NewPassword },
|
||||||
|
isError = uiState.passwordError != null,
|
||||||
|
supportingText = uiState.passwordError?.let { err ->
|
||||||
|
{ Text(err, color = Color.White.copy(alpha = 0.9f)) }
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.hint_password),
|
||||||
|
color = Color.White.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
colors = legacyLoginFieldColors()
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.passwordConfirm,
|
||||||
|
onValueChange = { onEvent(Event.PasswordConfirmChanged(it)) },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = fieldEnabled,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.semantics { contentType = ContentType.NewPassword },
|
||||||
|
isError = uiState.passwordConfirmError != null,
|
||||||
|
supportingText = uiState.passwordConfirmError?.let { err ->
|
||||||
|
{ Text(err, color = Color.White.copy(alpha = 0.9f)) }
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.hint_password_confirm),
|
||||||
|
color = Color.White.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
colors = legacyLoginFieldColors()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
} else {
|
||||||
|
AirMQOutlinedLightButton(
|
||||||
|
text = stringResource(id = R.string.button_register),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = { onEvent(Event.RegisterClicked) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun legacyLoginFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = Color.White,
|
||||||
|
unfocusedTextColor = Color.White,
|
||||||
|
cursorColor = Color.White,
|
||||||
|
focusedBorderColor = Color.White.copy(alpha = 0.8f),
|
||||||
|
unfocusedBorderColor = Color.White.copy(alpha = 0.55f),
|
||||||
|
focusedContainerColor = Color.White.copy(alpha = 0.1f),
|
||||||
|
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||||
|
errorBorderColor = Color.White.copy(alpha = 0.95f),
|
||||||
|
errorCursorColor = Color.White,
|
||||||
|
errorSupportingTextColor = Color.White.copy(alpha = 0.95f)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun EmailRegisterScreenPreviewEmpty() {
|
||||||
|
AirMQTheme {
|
||||||
|
EmailRegisterScreenContent(
|
||||||
|
uiState = EmailRegisterScreenContract.previewState(),
|
||||||
|
onEvent = {},
|
||||||
|
onBack = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun EmailRegisterScreenPreviewFilled() {
|
||||||
|
AirMQTheme {
|
||||||
|
EmailRegisterScreenContent(
|
||||||
|
uiState = EmailRegisterScreenContract.previewState(
|
||||||
|
name = "Alex",
|
||||||
|
email = "alex@example.com",
|
||||||
|
password = "secret1",
|
||||||
|
passwordConfirm = "secret1"
|
||||||
|
),
|
||||||
|
onEvent = {},
|
||||||
|
onBack = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun EmailRegisterScreenPreviewLoading() {
|
||||||
|
AirMQTheme {
|
||||||
|
EmailRegisterScreenContent(
|
||||||
|
uiState = EmailRegisterScreenContract.previewState(
|
||||||
|
name = "Alex",
|
||||||
|
email = "alex@example.com",
|
||||||
|
password = "secret1",
|
||||||
|
passwordConfirm = "secret1",
|
||||||
|
isLoading = true
|
||||||
|
),
|
||||||
|
onEvent = {},
|
||||||
|
onBack = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun EmailRegisterScreenPreviewErrors() {
|
||||||
|
AirMQTheme {
|
||||||
|
EmailRegisterScreenContent(
|
||||||
|
uiState = EmailRegisterScreenContract.previewState(
|
||||||
|
name = "",
|
||||||
|
email = "bad",
|
||||||
|
password = "12",
|
||||||
|
passwordConfirm = "34",
|
||||||
|
nameError = "Enter your name",
|
||||||
|
emailError = "Invalid email",
|
||||||
|
passwordError = "Too short",
|
||||||
|
passwordConfirmError = "Does not match"
|
||||||
|
),
|
||||||
|
onEvent = {},
|
||||||
|
onBack = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package org.db3.airmq.features.login
|
||||||
|
|
||||||
|
object EmailRegisterScreenContract {
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val name: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
val passwordConfirm: String = "",
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val nameError: String? = null,
|
||||||
|
val emailError: String? = null,
|
||||||
|
val passwordError: String? = null,
|
||||||
|
val passwordConfirmError: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
fun previewState(
|
||||||
|
name: String = "",
|
||||||
|
email: String = "",
|
||||||
|
password: String = "",
|
||||||
|
passwordConfirm: String = "",
|
||||||
|
isLoading: Boolean = false,
|
||||||
|
nameError: String? = null,
|
||||||
|
emailError: String? = null,
|
||||||
|
passwordError: String? = null,
|
||||||
|
passwordConfirmError: String? = null
|
||||||
|
): State = State(
|
||||||
|
name = name,
|
||||||
|
email = email,
|
||||||
|
password = password,
|
||||||
|
passwordConfirm = passwordConfirm,
|
||||||
|
isLoading = isLoading,
|
||||||
|
nameError = nameError,
|
||||||
|
emailError = emailError,
|
||||||
|
passwordError = passwordError,
|
||||||
|
passwordConfirmError = passwordConfirmError
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface Action {
|
||||||
|
data object OpenManage : Action
|
||||||
|
data class ShowMessage(val message: String) : Action
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Event {
|
||||||
|
data class NameChanged(val value: String) : Event
|
||||||
|
data class EmailChanged(val value: String) : Event
|
||||||
|
data class PasswordChanged(val value: String) : Event
|
||||||
|
data class PasswordConfirmChanged(val value: String) : Event
|
||||||
|
data object RegisterClicked : Event
|
||||||
|
data object BackClicked : Event
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package org.db3.airmq.features.login
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Patterns
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.login.EmailRegisterScreenContract.Action
|
||||||
|
import org.db3.airmq.features.login.EmailRegisterScreenContract.Event
|
||||||
|
import org.db3.airmq.features.login.EmailRegisterScreenContract.State
|
||||||
|
import org.db3.airmq.sdk.auth.AuthService
|
||||||
|
|
||||||
|
private const val MinPasswordLength = 6
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class EmailRegisterViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
|
private val authService: AuthService
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(State())
|
||||||
|
val uiState: StateFlow<State> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
|
||||||
|
val actions: SharedFlow<Action> = _actions.asSharedFlow()
|
||||||
|
|
||||||
|
fun onEvent(event: Event) {
|
||||||
|
when (event) {
|
||||||
|
is Event.NameChanged -> {
|
||||||
|
_uiState.value = _uiState.value.copy(name = event.value, nameError = null)
|
||||||
|
}
|
||||||
|
is Event.EmailChanged -> {
|
||||||
|
_uiState.value = _uiState.value.copy(email = event.value, emailError = null)
|
||||||
|
}
|
||||||
|
is Event.PasswordChanged -> {
|
||||||
|
_uiState.value = _uiState.value.copy(password = event.value, passwordError = null)
|
||||||
|
}
|
||||||
|
is Event.PasswordConfirmChanged -> {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
passwordConfirm = event.value,
|
||||||
|
passwordConfirmError = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Event.RegisterClicked -> register()
|
||||||
|
Event.BackClicked -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun register() {
|
||||||
|
val s = _uiState.value
|
||||||
|
val nameError = if (s.name.isBlank()) {
|
||||||
|
appContext.getString(R.string.error_register_name_required)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val emailTrimmed = s.email.trim()
|
||||||
|
val emailError = when {
|
||||||
|
emailTrimmed.isBlank() -> appContext.getString(R.string.error_register_email_required)
|
||||||
|
!Patterns.EMAIL_ADDRESS.matcher(emailTrimmed).matches() -> {
|
||||||
|
appContext.getString(R.string.error_register_email_invalid)
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val passwordError = when {
|
||||||
|
s.password.length < MinPasswordLength -> {
|
||||||
|
appContext.getString(R.string.error_register_password_short)
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val passwordConfirmError = when {
|
||||||
|
s.password != s.passwordConfirm -> {
|
||||||
|
appContext.getString(R.string.error_register_password_mismatch)
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (nameError != null || emailError != null || passwordError != null || passwordConfirmError != null) {
|
||||||
|
_uiState.value = s.copy(
|
||||||
|
nameError = nameError,
|
||||||
|
emailError = emailError,
|
||||||
|
passwordError = passwordError,
|
||||||
|
passwordConfirmError = passwordConfirmError
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = true,
|
||||||
|
nameError = null,
|
||||||
|
emailError = null,
|
||||||
|
passwordError = null,
|
||||||
|
passwordConfirmError = null
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
val result = authService.registerWithEmail(
|
||||||
|
email = emailTrimmed,
|
||||||
|
password = s.password,
|
||||||
|
name = s.name.trim()
|
||||||
|
)
|
||||||
|
if (result.isSuccess) {
|
||||||
|
_actions.tryEmit(Action.OpenManage)
|
||||||
|
} else {
|
||||||
|
val message = result.exceptionOrNull()?.message
|
||||||
|
?: appContext.getString(R.string.toast_email_auth_failed)
|
||||||
|
_actions.tryEmit(Action.ShowMessage(message))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package org.db3.airmq.features.login
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ContextWrapper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.credentials.CredentialManager
|
||||||
|
import androidx.credentials.CustomCredential
|
||||||
|
import androidx.credentials.GetCredentialRequest
|
||||||
|
import androidx.credentials.exceptions.GetCredentialCancellationException
|
||||||
|
import androidx.credentials.exceptions.GetCredentialException
|
||||||
|
import androidx.credentials.exceptions.GetCredentialInterruptedException
|
||||||
|
import androidx.credentials.exceptions.GetCredentialProviderConfigurationException
|
||||||
|
import androidx.credentials.exceptions.NoCredentialException
|
||||||
|
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
|
||||||
|
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
||||||
|
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import org.db3.airmq.R
|
||||||
|
|
||||||
|
sealed interface GoogleSignInResult {
|
||||||
|
data class Success(val idToken: String) : GoogleSignInResult
|
||||||
|
data object Cancelled : GoogleSignInResult
|
||||||
|
data class Error(val message: String) : GoogleSignInResult
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val GOOGLE_SIGN_IN_TAG = "GoogleSignIn"
|
||||||
|
|
||||||
|
suspend fun launchGoogleSignIn(context: Context): GoogleSignInResult {
|
||||||
|
return try {
|
||||||
|
val activity = context.findActivity()
|
||||||
|
val request = GetCredentialRequest.Builder()
|
||||||
|
.addCredentialOption(
|
||||||
|
GetGoogleIdOption.Builder()
|
||||||
|
.setServerClientId(context.getString(R.string.default_web_client_id))
|
||||||
|
.setFilterByAuthorizedAccounts(false)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
val response = CredentialManager.create(context).getCredential(
|
||||||
|
context = activity,
|
||||||
|
request = request
|
||||||
|
)
|
||||||
|
val credential = response.credential
|
||||||
|
if (credential is CustomCredential &&
|
||||||
|
credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
|
||||||
|
) {
|
||||||
|
GoogleSignInResult.Success(GoogleIdTokenCredential.createFrom(credential.data).idToken)
|
||||||
|
} else {
|
||||||
|
GoogleSignInResult.Error("Unsupported credential type for Google sign-in.")
|
||||||
|
}
|
||||||
|
} catch (error: GetCredentialCancellationException) {
|
||||||
|
logGoogleSignInError(error)
|
||||||
|
GoogleSignInResult.Cancelled
|
||||||
|
} catch (error: GetCredentialInterruptedException) {
|
||||||
|
logGoogleSignInError(error)
|
||||||
|
GoogleSignInResult.Cancelled
|
||||||
|
} catch (error: GetCredentialException) {
|
||||||
|
logGoogleSignInError(error)
|
||||||
|
GoogleSignInResult.Error(context.getString(R.string.toast_oauth_failed))
|
||||||
|
} catch (error: GoogleIdTokenParsingException) {
|
||||||
|
Log.e(GOOGLE_SIGN_IN_TAG, "Google ID token parsing failed", error)
|
||||||
|
GoogleSignInResult.Error(context.getString(R.string.toast_oauth_failed))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
GoogleSignInResult.Error(error.message ?: context.getString(R.string.toast_oauth_failed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logGoogleSignInError(exception: GetCredentialException) {
|
||||||
|
val message = when (exception) {
|
||||||
|
is GetCredentialCancellationException -> "Google sign-in cancelled by user"
|
||||||
|
is NoCredentialException -> "No Google credential available on device"
|
||||||
|
is GetCredentialProviderConfigurationException -> "Credential provider is not configured correctly"
|
||||||
|
is GetCredentialInterruptedException -> "Credential flow interrupted; try again"
|
||||||
|
else -> "CredentialManager returned an unknown sign-in error"
|
||||||
|
}
|
||||||
|
Log.e(GOOGLE_SIGN_IN_TAG, message, exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
private tailrec fun Context.findActivity(): Activity {
|
||||||
|
return when (this) {
|
||||||
|
is Activity -> this
|
||||||
|
is ContextWrapper -> baseContext.findActivity()
|
||||||
|
else -> error("Unable to find Activity context for Google sign-in.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,329 @@
|
|||||||
package org.db3.airmq.features.login
|
package org.db3.airmq.features.login
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
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.layout.statusBarsPadding
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.common.AirMQOutlinedLightButton
|
||||||
|
import org.db3.airmq.features.common.AirMQSocialButton
|
||||||
|
import org.db3.airmq.features.login.LoginScreenContract.Action
|
||||||
|
import org.db3.airmq.features.login.LoginScreenContract.Event
|
||||||
|
import org.db3.airmq.features.login.LoginScreenContract.State
|
||||||
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
|
|
||||||
|
private val LegacyLoginGradientStart = Color(0xFF449CF5)
|
||||||
|
private val LegacyLoginGradientEnd = Color(0xFF5CE4BB)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(onLogInToManage: () -> Unit) {
|
fun LoginScreen(
|
||||||
MockScreenScaffold(
|
onLogInToManage: () -> Unit,
|
||||||
title = "Login",
|
onOpenEmailLogin: () -> Unit,
|
||||||
subtitle = "Mock account sign-in screen.",
|
viewModel: LoginViewModel = hiltViewModel()
|
||||||
actions = listOf(ScreenAction("Log In to Manage", onLogInToManage))
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showContinueAnonymousDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(viewModel) {
|
||||||
|
viewModel.actions.collectLatest { action ->
|
||||||
|
when (action) {
|
||||||
|
Action.OpenManage -> onLogInToManage()
|
||||||
|
Action.LaunchGoogleSignIn -> {
|
||||||
|
when (val result = launchGoogleSignIn(context)) {
|
||||||
|
is GoogleSignInResult.Success -> viewModel.onEvent(Event.GoogleTokenReceived(result.idToken))
|
||||||
|
GoogleSignInResult.Cancelled -> viewModel.onEvent(Event.GoogleSignInCancelled)
|
||||||
|
is GoogleSignInResult.Error -> viewModel.onEvent(Event.GoogleSignInFailed(result.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action.NavigateToEmailLogin -> onOpenEmailLogin()
|
||||||
|
Action.ShowContinueAnonymousDialog -> showContinueAnonymousDialog = true
|
||||||
|
Action.OpenPrivacyPolicy -> {
|
||||||
|
Toast.makeText(context, context.getString(R.string.coming_soon), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
Action.OpenTermsAndConditions -> {
|
||||||
|
Toast.makeText(context, context.getString(R.string.coming_soon), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
is Action.ShowMessage -> {
|
||||||
|
Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginScreenContent(
|
||||||
|
uiState = uiState,
|
||||||
|
onEvent = viewModel::onEvent
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showContinueAnonymousDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
showContinueAnonymousDialog = false
|
||||||
|
viewModel.onEvent(Event.ContinueAnonymousDismissed)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.dialog_anonym_title),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.dialog_anonym_mesage),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showContinueAnonymousDialog = false
|
||||||
|
viewModel.onEvent(Event.ContinueAnonymousDismissed)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.button_sign_in),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showContinueAnonymousDialog = false
|
||||||
|
viewModel.onEvent(Event.ContinueAnonymousConfirmed)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.button_continue),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoginScreenContent(
|
||||||
|
uiState: State,
|
||||||
|
onEvent: (Event) -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
brush = Brush.linearGradient(
|
||||||
|
colorStops = arrayOf(
|
||||||
|
0.0f to LegacyLoginGradientStart,
|
||||||
|
0.35f to LegacyLoginGradientStart,
|
||||||
|
1.0f to LegacyLoginGradientEnd
|
||||||
|
),
|
||||||
|
start = Offset(0f, 0f),
|
||||||
|
end = Offset(1200f, 1200f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(horizontal = 40.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(56.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.text_sign_in),
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 36.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.airmq_logo),
|
||||||
|
contentDescription = stringResource(id = R.string.content_airmq_logo),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(168.dp)
|
||||||
|
.alpha(0.54f)
|
||||||
|
)
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
AirMQSocialButton(
|
||||||
|
text = stringResource(id = R.string.button_sign_in_google),
|
||||||
|
leadingIconRes = R.drawable.ic_google,
|
||||||
|
iconTint = Color.Unspecified,
|
||||||
|
containerColor = Color.White,
|
||||||
|
contentColor = Color(0xFF202124),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !uiState.isLoading,
|
||||||
|
onClick = { onEvent(Event.GoogleClicked) }
|
||||||
|
)
|
||||||
|
|
||||||
|
AirMQSocialButton(
|
||||||
|
text = stringResource(id = R.string.button_sign_in_email),
|
||||||
|
leadingIconRes = R.drawable.ic_account,
|
||||||
|
iconTint = Color.Unspecified,
|
||||||
|
containerColor = Color.White,
|
||||||
|
contentColor = Color(0xFF202124),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = { onEvent(Event.EmailClicked) }
|
||||||
|
)
|
||||||
|
|
||||||
|
AirMQOutlinedLightButton(
|
||||||
|
text = stringResource(id = R.string.button_continue_anonym),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = { onEvent(Event.ContinueAnonymousClicked) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
PrivacyAndTermsFooter(onEvent = onEvent)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PrivacyAndTermsFooter(onEvent: (Event) -> Unit) {
|
||||||
|
val privacyPolicy = "Privacy Policy"
|
||||||
|
val termsAndConditions = "Terms & conditions"
|
||||||
|
val fullText = stringResource(
|
||||||
|
id = R.string.text_policy_label,
|
||||||
|
privacyPolicy,
|
||||||
|
termsAndConditions
|
||||||
|
)
|
||||||
|
|
||||||
|
val annotatedText = remember(fullText, privacyPolicy, termsAndConditions) {
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(fullText)
|
||||||
|
val privacyStart = fullText.indexOf(privacyPolicy)
|
||||||
|
val termsStart = fullText.indexOf(termsAndConditions)
|
||||||
|
|
||||||
|
if (privacyStart >= 0) {
|
||||||
|
val privacyEnd = privacyStart + privacyPolicy.length
|
||||||
|
addStyle(
|
||||||
|
style = SpanStyle(
|
||||||
|
color = Color.White.copy(alpha = 0.85f),
|
||||||
|
textDecoration = TextDecoration.Underline
|
||||||
|
),
|
||||||
|
start = privacyStart,
|
||||||
|
end = privacyEnd
|
||||||
|
)
|
||||||
|
addStringAnnotation(
|
||||||
|
tag = "privacy",
|
||||||
|
annotation = "privacy",
|
||||||
|
start = privacyStart,
|
||||||
|
end = privacyEnd
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (termsStart >= 0) {
|
||||||
|
val termsEnd = termsStart + termsAndConditions.length
|
||||||
|
addStyle(
|
||||||
|
style = SpanStyle(
|
||||||
|
color = Color.White.copy(alpha = 0.85f),
|
||||||
|
textDecoration = TextDecoration.Underline
|
||||||
|
),
|
||||||
|
start = termsStart,
|
||||||
|
end = termsEnd
|
||||||
|
)
|
||||||
|
addStringAnnotation(
|
||||||
|
tag = "terms",
|
||||||
|
annotation = "terms",
|
||||||
|
start = termsStart,
|
||||||
|
end = termsEnd
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ClickableText(
|
||||||
|
text = annotatedText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = Color.White.copy(alpha = 0.63f),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = { offset ->
|
||||||
|
annotatedText.getStringAnnotations(tag = "privacy", start = offset, end = offset)
|
||||||
|
.firstOrNull()?.let {
|
||||||
|
onEvent(Event.PrivacyPolicyClicked)
|
||||||
|
}
|
||||||
|
annotatedText.getStringAnnotations(tag = "terms", start = offset, end = offset)
|
||||||
|
.firstOrNull()?.let {
|
||||||
|
onEvent(Event.TermsAndConditionsClicked)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun LoginScreenPreview() {
|
||||||
|
AirMQTheme {
|
||||||
|
LoginScreenContent(
|
||||||
|
uiState = State(),
|
||||||
|
onEvent = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package org.db3.airmq.features.login
|
||||||
|
|
||||||
|
object LoginScreenContract {
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val isLoading: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface Action {
|
||||||
|
data object OpenManage : Action
|
||||||
|
data object LaunchGoogleSignIn : Action
|
||||||
|
data object NavigateToEmailLogin : Action
|
||||||
|
data object ShowContinueAnonymousDialog : Action
|
||||||
|
data object OpenPrivacyPolicy : Action
|
||||||
|
data object OpenTermsAndConditions : Action
|
||||||
|
data class ShowMessage(val message: String) : Action
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Event {
|
||||||
|
data object GoogleClicked : Event
|
||||||
|
data class GoogleTokenReceived(val idToken: String) : Event
|
||||||
|
data object GoogleSignInCancelled : Event
|
||||||
|
data class GoogleSignInFailed(val message: String? = null) : Event
|
||||||
|
data object EmailClicked : Event
|
||||||
|
data object ContinueAnonymousClicked : Event
|
||||||
|
data object ContinueAnonymousConfirmed : Event
|
||||||
|
data object ContinueAnonymousDismissed : Event
|
||||||
|
data object PrivacyPolicyClicked : Event
|
||||||
|
data object TermsAndConditionsClicked : Event
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package org.db3.airmq.features.login
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.login.LoginScreenContract.Action
|
||||||
|
import org.db3.airmq.features.login.LoginScreenContract.Event
|
||||||
|
import org.db3.airmq.features.login.LoginScreenContract.State
|
||||||
|
import org.db3.airmq.sdk.auth.AuthService
|
||||||
|
import org.db3.airmq.sdk.auth.model.AuthProvider
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class LoginViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
|
private val authService: AuthService
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(State())
|
||||||
|
val uiState: StateFlow<State> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
|
||||||
|
val actions: SharedFlow<Action> = _actions.asSharedFlow()
|
||||||
|
|
||||||
|
fun onEvent(event: Event) {
|
||||||
|
when (event) {
|
||||||
|
Event.GoogleClicked -> {
|
||||||
|
if (_uiState.value.isLoading) return
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
_actions.tryEmit(Action.LaunchGoogleSignIn)
|
||||||
|
}
|
||||||
|
is Event.GoogleTokenReceived -> {
|
||||||
|
signInWithGoogle(event.idToken)
|
||||||
|
}
|
||||||
|
is Event.GoogleSignInFailed -> {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
_actions.tryEmit(
|
||||||
|
Action.ShowMessage(
|
||||||
|
event.message ?: appContext.getString(R.string.toast_oauth_failed)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Event.GoogleSignInCancelled -> {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
}
|
||||||
|
Event.EmailClicked -> {
|
||||||
|
_actions.tryEmit(Action.NavigateToEmailLogin)
|
||||||
|
}
|
||||||
|
Event.ContinueAnonymousClicked -> {
|
||||||
|
_actions.tryEmit(Action.ShowContinueAnonymousDialog)
|
||||||
|
}
|
||||||
|
Event.ContinueAnonymousConfirmed -> {
|
||||||
|
_actions.tryEmit(Action.OpenManage)
|
||||||
|
}
|
||||||
|
Event.ContinueAnonymousDismissed -> Unit
|
||||||
|
Event.PrivacyPolicyClicked -> {
|
||||||
|
_actions.tryEmit(Action.OpenPrivacyPolicy)
|
||||||
|
}
|
||||||
|
Event.TermsAndConditionsClicked -> {
|
||||||
|
_actions.tryEmit(Action.OpenTermsAndConditions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun signInWithGoogle(idToken: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val signInResult = authService.signIn(provider = AuthProvider.GOOGLE, token = idToken)
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
|
if (signInResult.isSuccess) {
|
||||||
|
_actions.tryEmit(Action.OpenManage)
|
||||||
|
} else {
|
||||||
|
val message = signInResult.exceptionOrNull()?.message
|
||||||
|
_actions.tryEmit(
|
||||||
|
Action.ShowMessage(
|
||||||
|
message ?: appContext.getString(R.string.toast_oauth_failed)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,424 @@
|
|||||||
package org.db3.airmq.features.manage
|
package org.db3.airmq.features.manage
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.common.AirMQButton
|
||||||
|
import org.db3.airmq.features.common.AirMQButtonStyle
|
||||||
|
import org.db3.airmq.features.manage.ManageScreenContract.Action
|
||||||
|
import org.db3.airmq.features.manage.ManageScreenContract.DeviceItem
|
||||||
|
import org.db3.airmq.features.manage.ManageScreenContract.Event
|
||||||
|
import org.db3.airmq.features.manage.ManageScreenContract.State
|
||||||
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
|
import org.db3.airmq.ui.theme.LegacyBackground
|
||||||
|
import org.db3.airmq.ui.theme.LegacyNavGradientEnd
|
||||||
|
import org.db3.airmq.ui.theme.LegacyNavGradientStart
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ManageScreen(
|
fun ManageScreen(
|
||||||
onOpenDevice: () -> Unit,
|
onOpenDevice: (String) -> Unit,
|
||||||
onOpenSetup: () -> Unit,
|
onOpenSetup: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenLogin: () -> Unit,
|
onOpenLogin: () -> Unit,
|
||||||
onOpenLocation: () -> Unit,
|
onOpenLocation: () -> Unit,
|
||||||
onOpenWidgetConstructor: () -> Unit,
|
onOpenAddLocation: () -> Unit,
|
||||||
onBackToDashboard: () -> Unit
|
viewModel: ManageViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
MockScreenScaffold(
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
title = "Manage",
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
subtitle = "Bottom-tab equivalent: manage",
|
DisposableEffect(lifecycleOwner, viewModel) {
|
||||||
actions = listOf(
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
ScreenAction("Open Device", onOpenDevice),
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
ScreenAction("Start Setup", onOpenSetup),
|
viewModel.refreshAuthState()
|
||||||
ScreenAction("Open Settings", onOpenSettings),
|
}
|
||||||
ScreenAction("Open Login", onOpenLogin),
|
}
|
||||||
ScreenAction("Select Location", onOpenLocation),
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
ScreenAction("Open Widget Constructor", onOpenWidgetConstructor),
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
ScreenAction("Back to Dashboard", onBackToDashboard)
|
}
|
||||||
)
|
LaunchedEffect(viewModel) {
|
||||||
|
viewModel.actions.collectLatest { action ->
|
||||||
|
when (action) {
|
||||||
|
Action.OpenLogin -> onOpenLogin()
|
||||||
|
Action.OpenSettings -> onOpenSettings()
|
||||||
|
Action.OpenSetup -> onOpenSetup()
|
||||||
|
is Action.OpenDevice -> onOpenDevice(action.deviceId)
|
||||||
|
is Action.OpenLocation -> onOpenLocation()
|
||||||
|
is Action.OpenAddLocation -> onOpenAddLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ManageScreenContent(
|
||||||
|
uiState = uiState,
|
||||||
|
onEvent = viewModel::onEvent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ManageScreenContent(
|
||||||
|
uiState: State,
|
||||||
|
onEvent: (Event) -> Unit
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentWindowInsets = WindowInsets(0, 0, 0, 0)
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
ProfileHeader(
|
||||||
|
name = uiState.userName,
|
||||||
|
email = uiState.userEmail,
|
||||||
|
isAnonymous = !uiState.isAuthorized,
|
||||||
|
onSettingsClick = { onEvent(Event.SettingsClicked) }
|
||||||
|
)
|
||||||
|
when (uiState.isAuthorized) {
|
||||||
|
false -> AnonymousContent(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
devicesLabel = uiState.devicesLabel
|
||||||
|
)
|
||||||
|
true -> AuthorizedContent(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
devices = uiState.devices,
|
||||||
|
onOpenDevice = { onEvent(Event.DeviceClicked(it)) },
|
||||||
|
onOpenLocation = { onEvent(Event.DeviceLocationClicked(it)) },
|
||||||
|
onAddLocation = { onEvent.invoke(Event.AddDeviceLocationClicked(it))}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
AirMQButton(
|
||||||
|
text = if (uiState.isAuthorized) stringResource(id = R.string.button_setup)
|
||||||
|
else stringResource(id = R.string.button_sign_in),
|
||||||
|
onClick = if (uiState.isAuthorized) { { onEvent(Event.SetupClicked) } }
|
||||||
|
else { { onEvent(Event.SignInClicked) } },
|
||||||
|
style = AirMQButtonStyle.Gradient,
|
||||||
|
leadingIconRes = if (uiState.isAuthorized) null else R.drawable.ic_account,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.46f)
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileHeader(
|
||||||
|
name: String,
|
||||||
|
email: String,
|
||||||
|
isAnonymous: Boolean,
|
||||||
|
onSettingsClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(LegacyNavGradientEnd, LegacyNavGradientStart)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
if (isAnonymous) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.placeholder_avatar_round),
|
||||||
|
contentDescription = stringResource(id = R.string.content_desc_user_pic),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 24.dp)
|
||||||
|
.size(96.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 24.dp)
|
||||||
|
.size(96.dp)
|
||||||
|
.background(color = Color.White.copy(alpha = 0.25f), shape = CircleShape),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = name.firstOrNull()?.uppercase() ?: "A",
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(top = 24.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = email,
|
||||||
|
color = Color.White.copy(alpha = 0.8f),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onSettingsClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 24.dp)
|
||||||
|
.size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_settings),
|
||||||
|
contentDescription = stringResource(id = R.string.content_settings),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AnonymousContent(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
devicesLabel: String
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = devicesLabel,
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Light),
|
||||||
|
color = Color(0xFFBDBDBD),
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AuthorizedContent(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
devices: List<DeviceItem>,
|
||||||
|
onOpenDevice: (String) -> Unit,
|
||||||
|
onOpenLocation: (String) -> Unit,
|
||||||
|
onAddLocation: (String) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(LegacyBackground)
|
||||||
|
) {
|
||||||
|
if (devices.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.text_nothing_to_show),
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Light),
|
||||||
|
color = Color(0xFF757575),
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.Top
|
||||||
|
) {
|
||||||
|
item(key = "header") {
|
||||||
|
DeviceListHeader()
|
||||||
|
}
|
||||||
|
items(devices, key = { it.id }) { device ->
|
||||||
|
DeviceRow(
|
||||||
|
item = device,
|
||||||
|
onOpenDevice = { onOpenDevice(device.id) },
|
||||||
|
onOpenLocation = { onOpenLocation(device.id) },
|
||||||
|
onAddLocation = { onAddLocation(device.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item(key = "footer") {
|
||||||
|
DeviceListFooter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Black38 = Color(0x61000000)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceListHeader() {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.text_your_devices),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 16.dp, top = 22.dp),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = Black38,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceListFooter() {
|
||||||
|
Spacer(modifier = Modifier.height(72.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceRow(
|
||||||
|
item: DeviceItem,
|
||||||
|
onOpenDevice: () -> Unit,
|
||||||
|
onOpenLocation: () -> Unit,
|
||||||
|
onAddLocation: () -> Unit
|
||||||
|
) {
|
||||||
|
val isOnline = item.status.equals(stringResource(id = R.string.map_status_online), ignoreCase = true)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(72.dp)
|
||||||
|
.clickable(onClick = onOpenDevice)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.ic_chip),
|
||||||
|
contentDescription = stringResource(id = R.string.content_device_icon),
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f, fill = true)) {
|
||||||
|
Text(
|
||||||
|
text = item.name,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = item.extra,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = Black38
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Image(
|
||||||
|
painter = painterResource(
|
||||||
|
id = if (isOnline) R.drawable.device_chip_online else R.drawable.device_chip_offline
|
||||||
|
),
|
||||||
|
contentDescription = item.status,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Top)
|
||||||
|
.padding(top = 18.dp)
|
||||||
|
)
|
||||||
|
val trailingIcon = if (item.hasLocation) R.drawable.ic_go_to_location else R.drawable.ic_warning
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (item.hasLocation) {
|
||||||
|
onOpenLocation.invoke()
|
||||||
|
} else {
|
||||||
|
onAddLocation.invoke()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = trailingIcon),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Black38
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun ManageScreenAnonymousPreview() {
|
||||||
|
AirMQTheme {
|
||||||
|
ManageScreenContent(
|
||||||
|
uiState = State(
|
||||||
|
isAuthorized = false,
|
||||||
|
userName = "Anonymous user",
|
||||||
|
userEmail = "Your preferences are not being synced, please sign in",
|
||||||
|
devicesLabel = "Sign in to add devices"
|
||||||
|
),
|
||||||
|
onEvent = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun ManageScreenAuthorizedPreview() {
|
||||||
|
AirMQTheme {
|
||||||
|
ManageScreenContent(
|
||||||
|
uiState = State(
|
||||||
|
isAuthorized = true,
|
||||||
|
userName = "User",
|
||||||
|
userEmail = "user@example.com",
|
||||||
|
devices = listOf(
|
||||||
|
DeviceItem("1", "AirMQ #1", "mobile", "Online", true),
|
||||||
|
DeviceItem("2", "AirMQ #2", "mobile", "Offline", false)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
onEvent = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package org.db3.airmq.features.manage
|
||||||
|
|
||||||
|
object ManageScreenContract {
|
||||||
|
data class DeviceItem(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val extra: String,
|
||||||
|
val status: String,
|
||||||
|
val hasLocation: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val isAuthorized: Boolean = false,
|
||||||
|
val userName: String = "",
|
||||||
|
val userEmail: String = "",
|
||||||
|
val devicesLabel: String = "",
|
||||||
|
val devices: List<DeviceItem> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface Action {
|
||||||
|
data object OpenSettings : Action
|
||||||
|
data object OpenSetup : Action
|
||||||
|
data object OpenLogin : Action
|
||||||
|
data class OpenDevice(val deviceId: String) : Action
|
||||||
|
data class OpenLocation(val deviceId: String) : Action
|
||||||
|
data class OpenAddLocation(val deviceId: String) : Action
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Event {
|
||||||
|
data object SettingsClicked : Event
|
||||||
|
data object SetupClicked : Event
|
||||||
|
data object SignInClicked : Event
|
||||||
|
data class DeviceClicked(val deviceId: String) : Event
|
||||||
|
data class DeviceLocationClicked(val deviceId: String) : Event
|
||||||
|
data class AddDeviceLocationClicked(val deviceId: String) : Event
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package org.db3.airmq.features.manage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.device.usecases.GetMyDevicesUseCase
|
||||||
|
import org.db3.airmq.features.manage.ManageScreenContract.Action
|
||||||
|
import org.db3.airmq.features.manage.ManageScreenContract.DeviceItem
|
||||||
|
import org.db3.airmq.features.manage.ManageScreenContract.Event
|
||||||
|
import org.db3.airmq.features.manage.ManageScreenContract.State
|
||||||
|
import org.db3.airmq.sdk.auth.AuthService
|
||||||
|
import org.db3.airmq.sdk.auth.model.User
|
||||||
|
import org.db3.airmq.sdk.device.data.remote.DeviceSubscriptionManager
|
||||||
|
import org.db3.airmq.sdk.device.domain.OnlineStatus
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ManageViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
|
private val authService: AuthService,
|
||||||
|
private val getMyDevicesUseCase: GetMyDevicesUseCase,
|
||||||
|
private val subscriptionManager: DeviceSubscriptionManager
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(initialState())
|
||||||
|
val uiState: StateFlow<State> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
|
||||||
|
val actions: SharedFlow<Action> = _actions.asSharedFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
refreshAuthState()
|
||||||
|
viewModelScope.launch {
|
||||||
|
getMyDevicesUseCase().collect { devices ->
|
||||||
|
val session = authService.getUser()
|
||||||
|
if (session?.isAuthenticated == true) {
|
||||||
|
val user = session
|
||||||
|
_uiState.update { state ->
|
||||||
|
if (state.isAuthorized) {
|
||||||
|
state.copy(
|
||||||
|
devices = devices.map { device -> device.toDeviceItem(appContext) }
|
||||||
|
)
|
||||||
|
} else state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: Event) {
|
||||||
|
when (event) {
|
||||||
|
Event.SettingsClicked -> _actions.tryEmit(Action.OpenSettings)
|
||||||
|
Event.SetupClicked -> _actions.tryEmit(Action.OpenSetup)
|
||||||
|
Event.SignInClicked -> _actions.tryEmit(Action.OpenLogin)
|
||||||
|
is Event.DeviceClicked -> _actions.tryEmit(Action.OpenDevice(event.deviceId))
|
||||||
|
is Event.DeviceLocationClicked -> _actions.tryEmit(Action.OpenLocation(event.deviceId))
|
||||||
|
is Event.AddDeviceLocationClicked -> _actions.tryEmit(Action.OpenAddLocation(event.deviceId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initialState(): State = anonymousState()
|
||||||
|
|
||||||
|
fun refreshAuthState() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val session = authService.getUser()
|
||||||
|
if (session?.isAuthenticated == true) {
|
||||||
|
subscriptionManager.start()
|
||||||
|
_uiState.value = authorizedState(session)
|
||||||
|
} else {
|
||||||
|
subscriptionManager.stop()
|
||||||
|
_uiState.value = anonymousState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun anonymousState(): State = State(
|
||||||
|
isAuthorized = false,
|
||||||
|
userName = appContext.getString(R.string.text_anonymous_user),
|
||||||
|
userEmail = appContext.getString(R.string.text_please_sign_in),
|
||||||
|
devicesLabel = appContext.getString(R.string.text_sign_in_small)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun authorizedState(user: User): State = State(
|
||||||
|
isAuthorized = true,
|
||||||
|
userName = user.displayName ?: appContext.getString(R.string.text_anonymous_user),
|
||||||
|
userEmail = user.email ?: "",
|
||||||
|
devicesLabel = "",
|
||||||
|
devices = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun org.db3.airmq.sdk.device.domain.Device.toDeviceItem(context: Context): DeviceItem {
|
||||||
|
val statusText = when (toOnlineStatus()) {
|
||||||
|
OnlineStatus.Online -> context.getString(R.string.map_status_online)
|
||||||
|
OnlineStatus.Offline -> context.getString(R.string.map_status_offline)
|
||||||
|
OnlineStatus.Unknown, OnlineStatus.Stale -> context.getString(R.string.map_status_offline)
|
||||||
|
}
|
||||||
|
return DeviceItem(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
extra = model.displayName,
|
||||||
|
status = statusText,
|
||||||
|
hasLocation = hasLocation()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package org.db3.airmq.features.map
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Point
|
||||||
|
import android.graphics.RadialGradient
|
||||||
|
import android.graphics.Shader
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.ColorUtils
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.osmdroid.util.GeoPoint
|
||||||
|
import org.osmdroid.views.MapView
|
||||||
|
import org.osmdroid.views.overlay.Overlay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OSMDroid overlay that draws heat halos under markers, projected on every frame so pan/zoom
|
||||||
|
* stays aligned with the map (unlike a cached screen bitmap that only updates after debounce).
|
||||||
|
* Mirrors legacy Google Maps heatmap: PM2.5-style dust tiers, ~40dp radius, ~0.4 opacity.
|
||||||
|
*/
|
||||||
|
private const val HeatmapRadiusDp = 40f
|
||||||
|
private const val HeatmapOpacity = 0.4f
|
||||||
|
|
||||||
|
private val heatmapTierColorOrder = intArrayOf(
|
||||||
|
R.color.sensorGreen,
|
||||||
|
R.color.sensorYellow,
|
||||||
|
R.color.sensorOrange,
|
||||||
|
R.color.sensorRed,
|
||||||
|
R.color.sensorPink,
|
||||||
|
R.color.sensorPurple
|
||||||
|
)
|
||||||
|
|
||||||
|
private class HeatmapTierBatch(
|
||||||
|
val shader: RadialGradient,
|
||||||
|
val points: List<GeoPoint>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One [RadialGradient] per tier (centered at origin); [Matrix] translates it each draw for low GC during pan.
|
||||||
|
*/
|
||||||
|
class MapHeatmapOverlay private constructor(
|
||||||
|
private val radiusPx: Float,
|
||||||
|
private val batches: List<HeatmapTierBatch>
|
||||||
|
) : Overlay() {
|
||||||
|
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val shaderMatrix = Matrix()
|
||||||
|
private val scratch = Point()
|
||||||
|
|
||||||
|
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
||||||
|
if (shadow) return
|
||||||
|
val projection = mapView.projection
|
||||||
|
for (batch in batches) {
|
||||||
|
paint.shader = batch.shader
|
||||||
|
for (geo in batch.points) {
|
||||||
|
projection.toPixels(geo, scratch)
|
||||||
|
val cx = scratch.x.toFloat()
|
||||||
|
val cy = scratch.y.toFloat()
|
||||||
|
shaderMatrix.reset()
|
||||||
|
shaderMatrix.setTranslate(cx, cy)
|
||||||
|
batch.shader.setLocalMatrix(shaderMatrix)
|
||||||
|
canvas.drawCircle(cx, cy, radiusPx, paint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paint.shader = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kept for [removeHeatmapOverlaysRecycle]; no bitmap to recycle anymore. */
|
||||||
|
fun recycleBitmap() {}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun create(mapView: MapView, items: List<MapMarker>): MapHeatmapOverlay? {
|
||||||
|
val byTier = LinkedHashMap<Int, MutableList<GeoPoint>>()
|
||||||
|
for (res in heatmapTierColorOrder) {
|
||||||
|
byTier[res] = ArrayList()
|
||||||
|
}
|
||||||
|
for (item in items) {
|
||||||
|
val colorRes = MapMarkerStyle.heatmapTierColorRes(item) ?: continue
|
||||||
|
byTier.getOrPut(colorRes) { ArrayList() }
|
||||||
|
.add(GeoPoint(item.latitude, item.longitude))
|
||||||
|
}
|
||||||
|
|
||||||
|
val density = mapView.context.resources.displayMetrics.density
|
||||||
|
val radiusPx = HeatmapRadiusDp * density
|
||||||
|
val centerAlpha = (255 * HeatmapOpacity).toInt().coerceIn(0, 255)
|
||||||
|
val ctx = mapView.context
|
||||||
|
|
||||||
|
val batches = ArrayList<HeatmapTierBatch>()
|
||||||
|
for (colorRes in heatmapTierColorOrder) {
|
||||||
|
val points = byTier[colorRes] ?: continue
|
||||||
|
if (points.isEmpty()) continue
|
||||||
|
val rgb = ContextCompat.getColor(ctx, colorRes)
|
||||||
|
val centerColor = ColorUtils.setAlphaComponent(rgb, centerAlpha)
|
||||||
|
val shader = RadialGradient(
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
radiusPx,
|
||||||
|
centerColor,
|
||||||
|
Color.TRANSPARENT,
|
||||||
|
Shader.TileMode.CLAMP
|
||||||
|
)
|
||||||
|
batches.add(HeatmapTierBatch(shader, points))
|
||||||
|
}
|
||||||
|
if (batches.isEmpty()) return null
|
||||||
|
return MapHeatmapOverlay(radiusPx, batches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun MapView.removeHeatmapOverlaysRecycle() {
|
||||||
|
overlays.filterIsInstance<MapHeatmapOverlay>().forEach { it.recycleBitmap() }
|
||||||
|
overlays.removeAll { it is MapHeatmapOverlay }
|
||||||
|
}
|
||||||
15
app/src/main/kotlin/org/db3/airmq/features/map/MapMarker.kt
Normal file
15
app/src/main/kotlin/org/db3/airmq/features/map/MapMarker.kt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package org.db3.airmq.features.map
|
||||||
|
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.SensorType
|
||||||
|
|
||||||
|
data class MapMarker(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val city: String?,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val isOnline: Boolean,
|
||||||
|
val sensorType: SensorType,
|
||||||
|
val value: Double?,
|
||||||
|
val isOwned: Boolean
|
||||||
|
)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package org.db3.airmq.features.map
|
||||||
|
|
||||||
|
import android.graphics.Point
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.SensorType
|
||||||
|
import org.osmdroid.util.GeoPoint
|
||||||
|
import org.osmdroid.views.Projection
|
||||||
|
import kotlin.math.hypot
|
||||||
|
|
||||||
|
sealed class MapMarkerDisplay {
|
||||||
|
data class Single(val marker: MapMarker) : MapMarkerDisplay()
|
||||||
|
data class Cluster(
|
||||||
|
val members: List<MapMarker>,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double
|
||||||
|
) : MapMarkerDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
object MapMarkerClustering {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param clusterDistancePx screen distance below which markers merge (similar scale to maps-utils defaults).
|
||||||
|
*/
|
||||||
|
fun buildDisplayItems(
|
||||||
|
items: List<MapMarker>,
|
||||||
|
projection: Projection?,
|
||||||
|
clusterDistancePx: Float,
|
||||||
|
clusterEnabled: Boolean
|
||||||
|
): List<MapMarkerDisplay> {
|
||||||
|
if (items.isEmpty()) return emptyList()
|
||||||
|
if (!clusterEnabled || items.size == 1 || projection == null) {
|
||||||
|
return items.map { MapMarkerDisplay.Single(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val threshold = clusterDistancePx.coerceAtLeast(1f)
|
||||||
|
val cellSize = threshold.toInt().coerceAtLeast(1)
|
||||||
|
val pixels = ArrayList<Point>(items.size)
|
||||||
|
val cellMap = mutableMapOf<Pair<Int, Int>, MutableList<Int>>()
|
||||||
|
|
||||||
|
for (i in items.indices) {
|
||||||
|
val p = Point()
|
||||||
|
projection.toPixels(GeoPoint(items[i].latitude, items[i].longitude), p)
|
||||||
|
pixels.add(p)
|
||||||
|
val cx = p.x.floorDiv(cellSize)
|
||||||
|
val cy = p.y.floorDiv(cellSize)
|
||||||
|
cellMap.getOrPut(cx to cy) { mutableListOf() }.add(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
val uf = UnionFind(items.size)
|
||||||
|
for (i in items.indices) {
|
||||||
|
val pi = pixels[i]
|
||||||
|
val cx = pi.x.floorDiv(cellSize)
|
||||||
|
val cy = pi.y.floorDiv(cellSize)
|
||||||
|
for (dx in -1..1) {
|
||||||
|
for (dy in -1..1) {
|
||||||
|
val neighbors = cellMap[cx + dx to cy + dy] ?: continue
|
||||||
|
for (j in neighbors) {
|
||||||
|
if (j <= i) continue
|
||||||
|
val pj = pixels[j]
|
||||||
|
val dist = hypot(
|
||||||
|
(pi.x - pj.x).toDouble(),
|
||||||
|
(pi.y - pj.y).toDouble()
|
||||||
|
)
|
||||||
|
if (dist <= threshold) {
|
||||||
|
uf.union(i, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val groups = mutableMapOf<Int, MutableList<Int>>()
|
||||||
|
for (i in items.indices) {
|
||||||
|
val root = uf.find(i)
|
||||||
|
groups.getOrPut(root) { mutableListOf() }.add(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.values.map { indices ->
|
||||||
|
val members = indices.map { items[it] }
|
||||||
|
if (members.size == 1) {
|
||||||
|
MapMarkerDisplay.Single(members.first())
|
||||||
|
} else {
|
||||||
|
val lat = members.map { it.latitude }.average()
|
||||||
|
val lon = members.map { it.longitude }.average()
|
||||||
|
MapMarkerDisplay.Cluster(members, lat, lon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mode of defined values and last member's sensor type (matches old MarkerClusterRenderer loop). */
|
||||||
|
fun clusterStyleInputs(members: List<MapMarker>): Pair<Double?, SensorType> {
|
||||||
|
val lastSensor = members.last().sensorType
|
||||||
|
val values = members.mapNotNull { it.value }
|
||||||
|
if (values.isEmpty()) return null to lastSensor
|
||||||
|
val mode = values.groupingBy { it }.eachCount().maxBy { it.value }.key
|
||||||
|
return mode to lastSensor
|
||||||
|
}
|
||||||
|
|
||||||
|
private class UnionFind(n: Int) {
|
||||||
|
private val parent = IntArray(n) { it }
|
||||||
|
private val rank = IntArray(n)
|
||||||
|
|
||||||
|
fun find(i: Int): Int {
|
||||||
|
var x = i
|
||||||
|
while (parent[x] != x) {
|
||||||
|
parent[x] = parent[parent[x]]
|
||||||
|
x = parent[x]
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
fun union(a: Int, b: Int) {
|
||||||
|
var ra = find(a)
|
||||||
|
var rb = find(b)
|
||||||
|
if (ra == rb) return
|
||||||
|
if (rank[ra] < rank[rb]) {
|
||||||
|
parent[ra] = rb
|
||||||
|
} else {
|
||||||
|
parent[rb] = ra
|
||||||
|
if (rank[ra] == rank[rb]) rank[ra]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package org.db3.airmq.features.map
|
||||||
|
|
||||||
|
import androidx.annotation.ColorRes
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.SensorType
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
object MapMarkerStyle {
|
||||||
|
|
||||||
|
fun formatValue(value: Double?, sensorType: SensorType): String {
|
||||||
|
if (value == null) return "--"
|
||||||
|
return when (sensorType) {
|
||||||
|
SensorType.DUST -> value.roundToInt().toString()
|
||||||
|
SensorType.RADIOACTIVITY -> formatAdaptive(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color tier for heatmap halos; null when there is no reading (excluded from heatmap).
|
||||||
|
*/
|
||||||
|
@ColorRes
|
||||||
|
fun heatmapTierColorRes(marker: MapMarker): Int? {
|
||||||
|
if (marker.value == null) return null
|
||||||
|
return valueColorRes(marker.value, marker.sensorType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ColorRes
|
||||||
|
fun valueColorRes(value: Double?, sensorType: SensorType): Int {
|
||||||
|
if (value == null) return R.color.colorGrey
|
||||||
|
return when (sensorType) {
|
||||||
|
SensorType.DUST -> when {
|
||||||
|
value <= 12.0 -> R.color.sensorGreen
|
||||||
|
value <= 35.4 -> R.color.sensorYellow
|
||||||
|
value <= 55.4 -> R.color.sensorOrange
|
||||||
|
value <= 150.4 -> R.color.sensorRed
|
||||||
|
value <= 250.4 -> R.color.sensorPink
|
||||||
|
else -> R.color.sensorPurple
|
||||||
|
}
|
||||||
|
SensorType.RADIOACTIVITY -> R.color.sensorGreen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatAdaptive(value: Double): String {
|
||||||
|
val decimals = when {
|
||||||
|
value > 10.0 -> 0
|
||||||
|
value > 1.0 -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
return if (decimals == 0) {
|
||||||
|
value.roundToInt().toString()
|
||||||
|
} else {
|
||||||
|
String.format(Locale.US, "%.${decimals}f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,602 @@
|
|||||||
package org.db3.airmq.features.map
|
package org.db3.airmq.features.map
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.ViewTreeObserver
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.widget.Toast
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.common.metric.SensorType as MetricSensorType
|
||||||
|
import org.db3.airmq.features.dashboard.DashboardChartMapper
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.Action
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.DevicePanelState
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.DeviceSensorType
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.Event
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.SearchPanelState
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.SearchResult
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.SensorType
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.State
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.TimeRange
|
||||||
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
|
import org.osmdroid.config.Configuration
|
||||||
|
import org.osmdroid.events.MapListener
|
||||||
|
import org.osmdroid.events.ScrollEvent
|
||||||
|
import org.osmdroid.events.ZoomEvent
|
||||||
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
|
import org.osmdroid.util.BoundingBox
|
||||||
|
import org.osmdroid.util.GeoPoint
|
||||||
|
import org.osmdroid.views.CustomZoomButtonsController
|
||||||
|
import org.osmdroid.views.MapView
|
||||||
|
import org.osmdroid.views.overlay.Marker
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MapScreen(
|
fun MapScreen(
|
||||||
onOpenDevice: () -> Unit,
|
viewModel: MapViewModel = hiltViewModel()
|
||||||
onBackToDashboard: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
MockScreenScaffold(
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
title = "Map",
|
val context = LocalContext.current
|
||||||
subtitle = "Bottom-tab equivalent: map",
|
LaunchedEffect(viewModel) {
|
||||||
actions = listOf(
|
viewModel.actions.collectLatest { action ->
|
||||||
ScreenAction("Open Device", onOpenDevice),
|
when (action) {
|
||||||
ScreenAction("Back to Dashboard", onBackToDashboard)
|
is Action.ShowToast -> {
|
||||||
)
|
Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
is Action.OpenDeviceRequested -> {
|
||||||
|
// Stub for future navigation integration.
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.map_open_device_not_wired, action.deviceId),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MapScreenContent(
|
||||||
|
uiState = uiState,
|
||||||
|
onEvent = viewModel::onEvent,
|
||||||
|
showMap = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun MapScreenContent(
|
||||||
|
uiState: State,
|
||||||
|
onEvent: (Event) -> Unit,
|
||||||
|
showMap: Boolean
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val centerOnMarker = uiState.selectedMarkerId?.let { id ->
|
||||||
|
uiState.items.find { it.id == id }
|
||||||
|
}
|
||||||
|
val sheetHeightFraction = if (uiState.devicePanelState != null) 0.5f else 0f
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
if (showMap) {
|
||||||
|
AirMQMap(
|
||||||
|
items = uiState.items,
|
||||||
|
onMarkerClick = { onEvent(Event.MarkerClicked(it)) },
|
||||||
|
centerOnMarker = centerOnMarker,
|
||||||
|
sheetHeightFraction = sheetHeightFraction,
|
||||||
|
clusterEnabled = true,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color(0xFFE8F1E5))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MapTopControls(
|
||||||
|
selectedSensor = uiState.selectedTopSensor,
|
||||||
|
onSensorSelected = { onEvent(Event.TopSensorSelected(it)) },
|
||||||
|
onHelpClick = { onEvent(Event.HelpClicked) },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(top = 20.dp, end = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (uiState.showHelpDialog) {
|
||||||
|
WhatDoesThisMeanDialog(onDismiss = { onEvent(Event.HelpDialogDismissed) })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.searchPanelState == null && uiState.devicePanelState == null) {
|
||||||
|
MapFloatingActions(
|
||||||
|
onSearchClick = { onEvent(Event.SearchButtonClicked) },
|
||||||
|
onMyLocationClick = { onEvent(Event.MyLocationClicked) },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(bottom = 20.dp, end = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.searchPanelState?.let { searchPanelState ->
|
||||||
|
MapSearchOverlay(
|
||||||
|
query = searchPanelState.query,
|
||||||
|
results = searchPanelState.results,
|
||||||
|
onQueryChanged = { onEvent(Event.SearchQueryChanged(it)) },
|
||||||
|
onClose = { onEvent(Event.SearchClosed) },
|
||||||
|
onResultClick = { onEvent(Event.SearchResultClicked(it.id)) },
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.devicePanelState?.let { panelData ->
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = { onEvent(Event.DevicePanelClosed) },
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
MapDevicePanelContent(
|
||||||
|
data = panelData,
|
||||||
|
onOpenDevice = { onEvent(Event.DeviceOpenClicked) },
|
||||||
|
onRangeSelected = { onEvent(Event.TimeRangeSelected(it)) },
|
||||||
|
onDateBack = { onEvent(Event.DateBackClicked) },
|
||||||
|
onDateForward = { onEvent(Event.DateForwardClicked) },
|
||||||
|
onSensorSelected = { onEvent(Event.DeviceSensorSelected(it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.2f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(40.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MapClusterDebounceMs = 150L
|
||||||
|
/** Max on-screen gap (in dp) for two markers to merge; lower = less aggressive clustering. */
|
||||||
|
private const val MapClusterDistanceDp = 48f
|
||||||
|
private const val MapClusterZoomPaddingPx = 64
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AirMQMap(
|
||||||
|
items: List<MapMarker>,
|
||||||
|
onMarkerClick: (String) -> Unit,
|
||||||
|
centerOnMarker: MapMarker? = null,
|
||||||
|
sheetHeightFraction: Float = 0f,
|
||||||
|
clusterEnabled: Boolean = true,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
|
val latestItems = rememberUpdatedState(items)
|
||||||
|
val latestOnMarkerClick = rememberUpdatedState(onMarkerClick)
|
||||||
|
val latestClusterEnabled = rememberUpdatedState(clusterEnabled)
|
||||||
|
val latestCenterOnMarker = rememberUpdatedState(centerOnMarker)
|
||||||
|
|
||||||
|
val initialCameraDone = remember { AtomicBoolean(false) }
|
||||||
|
|
||||||
|
val mapView = remember {
|
||||||
|
Configuration.getInstance().userAgentValue = context.packageName
|
||||||
|
MapView(context).apply {
|
||||||
|
setTileSource(TileSourceFactory.MAPNIK)
|
||||||
|
setMultiTouchControls(true)
|
||||||
|
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||||
|
controller.setZoom(7.0)
|
||||||
|
controller.setCenter(GeoPoint(53.7098, 27.9534))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val handler = remember { Handler(Looper.getMainLooper()) }
|
||||||
|
val debouncedRebuild = remember(mapView) {
|
||||||
|
Runnable {
|
||||||
|
rebuildAirMqMapOverlays(
|
||||||
|
map = mapView,
|
||||||
|
items = latestItems.value,
|
||||||
|
onMarkerClick = latestOnMarkerClick.value,
|
||||||
|
clusterEnabled = latestClusterEnabled.value,
|
||||||
|
centerOnMarker = latestCenterOnMarker.value,
|
||||||
|
initialCameraDone = initialCameraDone
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(lifecycleOwner, mapView) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_RESUME -> mapView.onResume()
|
||||||
|
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
mapView.onDetach()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(mapView, debouncedRebuild) {
|
||||||
|
val listener = object : MapListener {
|
||||||
|
override fun onScroll(event: ScrollEvent?): Boolean {
|
||||||
|
scheduleMapClusterRebuildDebounced(handler, debouncedRebuild)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onZoom(event: ZoomEvent?): Boolean {
|
||||||
|
scheduleMapClusterRebuildDebounced(handler, debouncedRebuild)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mapView.addMapListener(listener)
|
||||||
|
onDispose {
|
||||||
|
mapView.removeMapListener(listener)
|
||||||
|
handler.removeCallbacks(debouncedRebuild)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val mapAnimationSpeedMs = 200L
|
||||||
|
LaunchedEffect(centerOnMarker, sheetHeightFraction) {
|
||||||
|
centerOnMarker?.let { marker ->
|
||||||
|
val markerGeo = GeoPoint(marker.latitude, marker.longitude)
|
||||||
|
val zoomLevel = 15.5
|
||||||
|
if (sheetHeightFraction > 0f) {
|
||||||
|
mapView.post {
|
||||||
|
val height = mapView.height
|
||||||
|
val width = mapView.width
|
||||||
|
if (height > 0 && width > 0) {
|
||||||
|
val sheetHeightPx = (height * sheetHeightFraction).toInt()
|
||||||
|
val offsetCenterY = height / 2 + sheetHeightPx / 2
|
||||||
|
val projection = mapView.projection
|
||||||
|
val offsetGeo = projection.fromPixels(width / 2, offsetCenterY)
|
||||||
|
mapView.controller.animateTo(offsetGeo, zoomLevel, mapAnimationSpeedMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mapView.controller.animateTo(markerGeo, zoomLevel, mapAnimationSpeedMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
factory = { mapView },
|
||||||
|
update = { map ->
|
||||||
|
map.post {
|
||||||
|
rebuildAirMqMapOverlays(
|
||||||
|
map = map,
|
||||||
|
items = latestItems.value,
|
||||||
|
onMarkerClick = latestOnMarkerClick.value,
|
||||||
|
clusterEnabled = latestClusterEnabled.value,
|
||||||
|
centerOnMarker = latestCenterOnMarker.value,
|
||||||
|
initialCameraDone = initialCameraDone
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleMapClusterRebuildDebounced(
|
||||||
|
handler: Handler,
|
||||||
|
runnable: Runnable,
|
||||||
|
debounceMs: Long = MapClusterDebounceMs
|
||||||
|
) {
|
||||||
|
handler.removeCallbacks(runnable)
|
||||||
|
handler.postDelayed(runnable, debounceMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rebuildAirMqMapOverlays(
|
||||||
|
map: MapView,
|
||||||
|
items: List<MapMarker>,
|
||||||
|
onMarkerClick: (String) -> Unit,
|
||||||
|
clusterEnabled: Boolean,
|
||||||
|
centerOnMarker: MapMarker?,
|
||||||
|
initialCameraDone: AtomicBoolean
|
||||||
|
) {
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
map.removeHeatmapOverlaysRecycle()
|
||||||
|
map.overlays.removeAll { it is Marker }
|
||||||
|
initialCameraDone.set(false)
|
||||||
|
map.invalidate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val density = map.context.resources.displayMetrics.density
|
||||||
|
val baseClusterPx = MapClusterDistanceDp * density
|
||||||
|
val zoom = map.zoomLevelDouble
|
||||||
|
// Zoomed in: require markers to overlap more on screen before merging (less aggressive).
|
||||||
|
val zoomScale = ((18.5 - zoom) / 11.0).coerceIn(0.38, 1.0).toFloat()
|
||||||
|
val clusterDistancePx = (baseClusterPx * zoomScale).coerceAtLeast(18f * density)
|
||||||
|
if (map.width <= 0 || map.height <= 0) {
|
||||||
|
// Compose AndroidView often invokes update before the MapView is measured; one-shot post
|
||||||
|
// can still see 0×0 and then nothing retriggers rebuild until the user pans the map.
|
||||||
|
val vto = map.viewTreeObserver
|
||||||
|
if (!vto.isAlive) {
|
||||||
|
map.post {
|
||||||
|
rebuildAirMqMapOverlays(
|
||||||
|
map,
|
||||||
|
items,
|
||||||
|
onMarkerClick,
|
||||||
|
clusterEnabled,
|
||||||
|
centerOnMarker,
|
||||||
|
initialCameraDone
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val listener = object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||||
|
override fun onGlobalLayout() {
|
||||||
|
if (map.width <= 0 || map.height <= 0) return
|
||||||
|
map.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||||
|
rebuildAirMqMapOverlays(
|
||||||
|
map,
|
||||||
|
items,
|
||||||
|
onMarkerClick,
|
||||||
|
clusterEnabled,
|
||||||
|
centerOnMarker,
|
||||||
|
initialCameraDone
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vto.addOnGlobalLayoutListener(listener)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val ctx = map.context
|
||||||
|
map.removeHeatmapOverlaysRecycle()
|
||||||
|
map.overlays.removeAll { it is Marker }
|
||||||
|
|
||||||
|
MapHeatmapOverlay.create(map, items)?.let { map.overlays.add(it) }
|
||||||
|
|
||||||
|
val displayItems = MapMarkerClustering.buildDisplayItems(
|
||||||
|
items = items,
|
||||||
|
projection = map.projection,
|
||||||
|
clusterDistancePx = clusterDistancePx,
|
||||||
|
clusterEnabled = clusterEnabled
|
||||||
|
)
|
||||||
|
|
||||||
|
for (entry in displayItems) {
|
||||||
|
when (entry) {
|
||||||
|
is MapMarkerDisplay.Single -> {
|
||||||
|
val item = entry.marker
|
||||||
|
val marker = Marker(map).apply {
|
||||||
|
position = GeoPoint(item.latitude, item.longitude)
|
||||||
|
title = listOfNotNull(item.title, item.city).joinToString(" - ")
|
||||||
|
subDescription = if (item.isOnline) {
|
||||||
|
ctx.getString(R.string.map_status_online)
|
||||||
|
} else {
|
||||||
|
ctx.getString(R.string.map_status_offline)
|
||||||
|
}
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
|
icon = createMarkerIcon(map, item)
|
||||||
|
setOnMarkerClickListener { _, _ ->
|
||||||
|
onMarkerClick(item.id)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.overlays.add(marker)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MapMarkerDisplay.Cluster -> {
|
||||||
|
val cluster = entry
|
||||||
|
val marker = Marker(map).apply {
|
||||||
|
position = GeoPoint(cluster.latitude, cluster.longitude)
|
||||||
|
title = ctx.getString(R.string.map_cluster_marker_title, cluster.members.size)
|
||||||
|
subDescription = ""
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
|
icon = createClusterMarkerIcon(map, cluster.members.size, cluster.members)
|
||||||
|
setOnMarkerClickListener { _, _ ->
|
||||||
|
val geoPoints = cluster.members.map { GeoPoint(it.latitude, it.longitude) }
|
||||||
|
val box = BoundingBox.fromGeoPoints(geoPoints)
|
||||||
|
map.zoomToBoundingBox(box, true, MapClusterZoomPaddingPx)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.overlays.add(marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initialCameraDone.get() && centerOnMarker == null && items.isNotEmpty()) {
|
||||||
|
initialCameraDone.set(true)
|
||||||
|
map.controller.animateTo(
|
||||||
|
GeoPoint(items.first().latitude, items.first().longitude),
|
||||||
|
map.zoomLevelDouble,
|
||||||
|
200L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
map.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMarkerIcon(mapView: MapView, item: MapMarker): BitmapDrawable {
|
||||||
|
val context = mapView.context
|
||||||
|
val iconView = LayoutInflater.from(context).inflate(R.layout.view_map_marker, null, false)
|
||||||
|
val markerBorder = iconView.findViewById<android.view.View>(R.id.marker_border)
|
||||||
|
val markerCenter = iconView.findViewById<android.view.View>(R.id.marker_center)
|
||||||
|
val markerText = iconView.findViewById<TextView>(R.id.marker_text)
|
||||||
|
val markerShade = iconView.findViewById<android.view.View>(R.id.marker_image_shade)
|
||||||
|
val markerImage = iconView.findViewById<ImageView>(R.id.marker_image)
|
||||||
|
|
||||||
|
val hasValue = item.value != null
|
||||||
|
val isNoValueStyled = !hasValue
|
||||||
|
val colorRes = if (isNoValueStyled) {
|
||||||
|
R.color.colorGrey
|
||||||
|
} else {
|
||||||
|
MapMarkerStyle.valueColorRes(item.value, item.sensorType)
|
||||||
|
}
|
||||||
|
val markerColor = ContextCompat.getColor(context, colorRes)
|
||||||
|
val centerColor = if (isNoValueStyled) R.color.colorGrey else R.color.white
|
||||||
|
markerBorder.backgroundTintList = android.content.res.ColorStateList.valueOf(markerColor)
|
||||||
|
markerCenter.backgroundTintList =
|
||||||
|
android.content.res.ColorStateList.valueOf(ContextCompat.getColor(context, centerColor))
|
||||||
|
|
||||||
|
markerText.text = MapMarkerStyle.formatValue(item.value, item.sensorType)
|
||||||
|
if (item.isOwned && !isNoValueStyled) {
|
||||||
|
markerImage.setImageResource(R.drawable.ic_marker_user)
|
||||||
|
markerImage.visibility = android.view.View.VISIBLE
|
||||||
|
markerShade.visibility = android.view.View.VISIBLE
|
||||||
|
markerText.setTextColor(ContextCompat.getColor(context, R.color.white))
|
||||||
|
} else {
|
||||||
|
markerImage.visibility = android.view.View.GONE
|
||||||
|
markerShade.visibility = android.view.View.GONE
|
||||||
|
markerText.setTextColor(
|
||||||
|
ContextCompat.getColor(
|
||||||
|
context,
|
||||||
|
if (isNoValueStyled) R.color.white else colorRes
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
iconView.measure(
|
||||||
|
android.view.View.MeasureSpec.makeMeasureSpec(0, android.view.View.MeasureSpec.UNSPECIFIED),
|
||||||
|
android.view.View.MeasureSpec.makeMeasureSpec(0, android.view.View.MeasureSpec.UNSPECIFIED)
|
||||||
|
)
|
||||||
|
iconView.layout(0, 0, iconView.measuredWidth, iconView.measuredHeight)
|
||||||
|
val bitmap = createBitmap(iconView.measuredWidth, iconView.measuredHeight)
|
||||||
|
iconView.draw(Canvas(bitmap))
|
||||||
|
return bitmap.toDrawable(context.resources)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createClusterMarkerIcon(mapView: MapView, count: Int, members: List<MapMarker>): BitmapDrawable {
|
||||||
|
val context = mapView.context
|
||||||
|
val (modeValue, sensorForStyle) = MapMarkerClustering.clusterStyleInputs(members)
|
||||||
|
val iconView = LayoutInflater.from(context).inflate(R.layout.view_map_marker_cluster, null, false)
|
||||||
|
val background = iconView.findViewById<android.view.View>(R.id.cluster_background)
|
||||||
|
val text = iconView.findViewById<TextView>(R.id.cluster_text)
|
||||||
|
text.text = context.getString(R.string.map_cluster_count_label, count)
|
||||||
|
val colorRes = if (modeValue == null) {
|
||||||
|
R.color.colorGrey
|
||||||
|
} else {
|
||||||
|
MapMarkerStyle.valueColorRes(modeValue, sensorForStyle)
|
||||||
|
}
|
||||||
|
background.backgroundTintList =
|
||||||
|
android.content.res.ColorStateList.valueOf(ContextCompat.getColor(context, colorRes))
|
||||||
|
iconView.measure(
|
||||||
|
android.view.View.MeasureSpec.makeMeasureSpec(0, android.view.View.MeasureSpec.UNSPECIFIED),
|
||||||
|
android.view.View.MeasureSpec.makeMeasureSpec(0, android.view.View.MeasureSpec.UNSPECIFIED)
|
||||||
|
)
|
||||||
|
iconView.layout(0, 0, iconView.measuredWidth, iconView.measuredHeight)
|
||||||
|
val bitmap = createBitmap(iconView.measuredWidth, iconView.measuredHeight)
|
||||||
|
iconView.draw(Canvas(bitmap))
|
||||||
|
return bitmap.toDrawable(context.resources)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, showSystemUi = true)
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMapScreenDefault() {
|
||||||
|
AirMQTheme {
|
||||||
|
MapScreenContent(
|
||||||
|
uiState = State(
|
||||||
|
selectedTopSensor = SensorType.DUST,
|
||||||
|
items = listOf(
|
||||||
|
MapMarker(
|
||||||
|
id = "1",
|
||||||
|
title = "AirMQ #1",
|
||||||
|
city = "Minsk",
|
||||||
|
latitude = 53.9,
|
||||||
|
longitude = 27.56,
|
||||||
|
isOnline = true,
|
||||||
|
sensorType = SensorType.DUST,
|
||||||
|
value = 12.0,
|
||||||
|
isOwned = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
onEvent = {},
|
||||||
|
showMap = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, showSystemUi = true)
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMapScreenSearch() {
|
||||||
|
AirMQTheme {
|
||||||
|
MapScreenContent(
|
||||||
|
uiState = State(
|
||||||
|
selectedTopSensor = SensorType.RADIOACTIVITY,
|
||||||
|
searchPanelState = SearchPanelState(
|
||||||
|
query = "Minsk",
|
||||||
|
results = listOf(
|
||||||
|
SearchResult(id = "1", title = "AirMQ #42", subtitle = "Minsk"),
|
||||||
|
SearchResult(id = "2", title = "AirMQ #91", subtitle = "Salihorsk")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
onEvent = {},
|
||||||
|
showMap = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, showSystemUi = true)
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMapScreenDevicePanel() {
|
||||||
|
AirMQTheme {
|
||||||
|
MapScreenContent(
|
||||||
|
uiState = State(
|
||||||
|
selectedTopSensor = SensorType.DUST,
|
||||||
|
devicePanelState = DevicePanelState(
|
||||||
|
id = "42",
|
||||||
|
name = "AirMQ #42",
|
||||||
|
status = "Online",
|
||||||
|
selectedRange = TimeRange.DAY,
|
||||||
|
displayedDateRange = "Today",
|
||||||
|
selectedSensor = DeviceSensorType.DUST,
|
||||||
|
chartDataset = DashboardChartMapper.chartDataset(
|
||||||
|
DashboardChartMapper.previewStaticRows(),
|
||||||
|
MetricSensorType.DUST
|
||||||
|
),
|
||||||
|
isChartLoading = false
|
||||||
|
)
|
||||||
|
),
|
||||||
|
onEvent = {},
|
||||||
|
showMap = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package org.db3.airmq.features.map
|
||||||
|
|
||||||
|
import org.db3.airmq.features.common.chart.ChartDataset
|
||||||
|
|
||||||
|
object MapScreenContract {
|
||||||
|
|
||||||
|
enum class SensorType {
|
||||||
|
DUST,
|
||||||
|
RADIOACTIVITY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class DeviceSensorType {
|
||||||
|
TEMPERATURE,
|
||||||
|
DUST,
|
||||||
|
RADIOACTIVITY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class TimeRange {
|
||||||
|
HOUR,
|
||||||
|
DAY,
|
||||||
|
WEEK,
|
||||||
|
MONTH
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SearchResult(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DevicePanelState(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val status: String,
|
||||||
|
val selectedRange: TimeRange = TimeRange.DAY,
|
||||||
|
val displayedDateRange: String = "",
|
||||||
|
val selectedSensor: DeviceSensorType = DeviceSensorType.TEMPERATURE,
|
||||||
|
val supportedSensors: List<DeviceSensorType> = listOf(
|
||||||
|
DeviceSensorType.TEMPERATURE,
|
||||||
|
DeviceSensorType.DUST,
|
||||||
|
DeviceSensorType.RADIOACTIVITY
|
||||||
|
),
|
||||||
|
val chartDataset: ChartDataset = ChartDataset.Single(emptyList()),
|
||||||
|
val isChartLoading: Boolean = false,
|
||||||
|
val chartErrorMessage: String? = null,
|
||||||
|
/** Non-positive: shift the window into the past; `0` ends at now. */
|
||||||
|
val chartWindowOffset: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchPanelState(
|
||||||
|
val query: String = "",
|
||||||
|
val results: List<SearchResult> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val items: List<MapMarker> = emptyList(),
|
||||||
|
val selectedTopSensor: SensorType = SensorType.DUST,
|
||||||
|
val searchPanelState: SearchPanelState? = null,
|
||||||
|
val devicePanelState: DevicePanelState? = null,
|
||||||
|
val selectedMarkerId: String? = null,
|
||||||
|
val showHelpDialog: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface Action {
|
||||||
|
data class ShowToast(val message: String) : Action
|
||||||
|
data class OpenDeviceRequested(val deviceId: String) : Action
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Event {
|
||||||
|
data object RetryClicked : Event
|
||||||
|
data object SearchButtonClicked : Event
|
||||||
|
data object SearchClosed : Event
|
||||||
|
data class SearchQueryChanged(val value: String) : Event
|
||||||
|
data class SearchResultClicked(val resultId: String) : Event
|
||||||
|
data object MyLocationClicked : Event
|
||||||
|
data class TopSensorSelected(val sensor: SensorType) : Event
|
||||||
|
data object HelpClicked : Event
|
||||||
|
data object HelpDialogDismissed : Event
|
||||||
|
data class MarkerClicked(val itemId: String) : Event
|
||||||
|
data object DevicePanelClosed : Event
|
||||||
|
data object DeviceOpenClicked : Event
|
||||||
|
data class TimeRangeSelected(val range: TimeRange) : Event
|
||||||
|
data object DateBackClicked : Event
|
||||||
|
data object DateForwardClicked : Event
|
||||||
|
data class DeviceSensorSelected(val sensor: DeviceSensorType) : Event
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,921 @@
|
|||||||
|
package org.db3.airmq.features.map
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.layout.ContentScale
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.common.AirMQButton
|
||||||
|
import org.db3.airmq.features.common.AirMQButtonStyle
|
||||||
|
import org.db3.airmq.features.common.chart.AirMQChart
|
||||||
|
import org.db3.airmq.features.common.chart.ChartConfig
|
||||||
|
import org.db3.airmq.features.common.metric.SensorType as MetricSensorType
|
||||||
|
import org.db3.airmq.features.dashboard.DashboardChartMapper
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.DevicePanelState
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.DeviceSensorType
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.SearchResult
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.SensorType
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.TimeRange
|
||||||
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
|
|
||||||
|
private fun chartConfigForSensor(
|
||||||
|
sensor: DeviceSensorType,
|
||||||
|
leftTimeLabel: String = "",
|
||||||
|
rightTimeLabel: String = ""
|
||||||
|
): ChartConfig {
|
||||||
|
val (lineColor, unit) = when (sensor) {
|
||||||
|
DeviceSensorType.TEMPERATURE -> Color(0xFFFFB357) to "°C"
|
||||||
|
DeviceSensorType.DUST -> Color(0xFFEAB839) to "µg/m³"
|
||||||
|
DeviceSensorType.RADIOACTIVITY -> Color(0xFF8F3BB8) to "μSv/h"
|
||||||
|
}
|
||||||
|
val fillColor = lineColor.copy(alpha = 0.5f)
|
||||||
|
return ChartConfig(
|
||||||
|
lineColor = lineColor,
|
||||||
|
fillColor = fillColor,
|
||||||
|
backgroundColor = Color(0x0F000000),
|
||||||
|
labelColor = Color(0x8A000000),
|
||||||
|
roundCorners = false,
|
||||||
|
leftTimeLabel = leftTimeLabel,
|
||||||
|
rightTimeLabel = rightTimeLabel,
|
||||||
|
unit = unit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sensorTypeString(sensor: DeviceSensorType): String = when (sensor) {
|
||||||
|
DeviceSensorType.TEMPERATURE -> "sensor_temperature"
|
||||||
|
DeviceSensorType.DUST -> "sensor_dust"
|
||||||
|
DeviceSensorType.RADIOACTIVITY -> "sensor_radioactivity"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MapTopControls(
|
||||||
|
selectedSensor: SensorType,
|
||||||
|
onSensorSelected: (SensorType) -> Unit,
|
||||||
|
onHelpClick: () -> Unit,
|
||||||
|
initiallyExpanded: Boolean = false,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var isExpanded by remember(initiallyExpanded) { mutableStateOf(initiallyExpanded) }
|
||||||
|
val arrowRotation = if (isExpanded) 180f else 0f
|
||||||
|
val selectedLabel = when (selectedSensor) {
|
||||||
|
SensorType.DUST -> stringResource(id = R.string.text_air_quality)
|
||||||
|
SensorType.RADIOACTIVITY -> stringResource(id = R.string.sensor_radioactivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.width(234.dp)
|
||||||
|
.wrapContentHeight()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.padding(start = 10.dp, top = 10.dp, end = 10.dp)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(214.dp)
|
||||||
|
.height(36.dp)
|
||||||
|
.clickable { isExpanded = !isExpanded },
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
shadowElevation = 10.dp,
|
||||||
|
color = Color.White
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(start = 12.dp, end = 48.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_arrow_down_dark),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0x8A000000),
|
||||||
|
modifier = Modifier.rotate(arrowRotation)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = selectedLabel.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = Color(0x61000000)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(214.dp)
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
shadowElevation = 10.dp,
|
||||||
|
color = Color.White
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
|
||||||
|
SensorTypeItem(
|
||||||
|
iconRes = R.drawable.ic_dust,
|
||||||
|
titleRes = R.string.text_air_quality,
|
||||||
|
selected = selectedSensor == SensorType.DUST,
|
||||||
|
onClick = {
|
||||||
|
onSensorSelected(SensorType.DUST)
|
||||||
|
isExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
HorizontalDivider(color = Color(0x1F000000))
|
||||||
|
SensorTypeItem(
|
||||||
|
iconRes = R.drawable.ic_radiation,
|
||||||
|
titleRes = R.string.sensor_radioactivity,
|
||||||
|
selected = selectedSensor == SensorType.RADIOACTIVITY,
|
||||||
|
onClick = {
|
||||||
|
onSensorSelected(SensorType.RADIOACTIVITY)
|
||||||
|
isExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.text_what_does_it_mean),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
.padding(top = 12.dp)
|
||||||
|
.clickable {
|
||||||
|
onHelpClick()
|
||||||
|
isExpanded = false
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color(0x8A000000),
|
||||||
|
textDecoration = TextDecoration.Underline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.size(56.dp)
|
||||||
|
.background(
|
||||||
|
brush = Brush.linearGradient(
|
||||||
|
colors = listOf(Color(0xFF00FF6D), Color(0xFF03AEF9))
|
||||||
|
),
|
||||||
|
shape = CircleShape
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
id = if (selectedSensor == SensorType.RADIOACTIVITY) {
|
||||||
|
R.drawable.ic_radiation
|
||||||
|
} else {
|
||||||
|
R.drawable.ic_dust
|
||||||
|
}
|
||||||
|
),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WhatDoesThisMeanDialog(
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 32.dp, vertical = 128.dp),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.text_what_does_it_mean_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.padding(bottom = 22.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(max = 400.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 0.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.text_aqi_title),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.text_aqi),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.aqi_table),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(300.dp)
|
||||||
|
.height(150.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.text_rad_title),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.text_rad),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
) {
|
||||||
|
Text(stringResource(id = R.string.button_ok))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SensorTypeItem(
|
||||||
|
iconRes: Int,
|
||||||
|
titleRes: Int,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val textColor = if (selected) Color(0xFF1C1C1C) else Color(0xFF616161)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = iconRes),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = textColor,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = titleRes),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MapFloatingActions(
|
||||||
|
onSearchClick: () -> Unit,
|
||||||
|
onMyLocationClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
horizontalAlignment = Alignment.End
|
||||||
|
) {
|
||||||
|
RoundIconButton(
|
||||||
|
iconRes = R.drawable.ic_map_search,
|
||||||
|
contentDescription = stringResource(id = R.string.content_search),
|
||||||
|
onClick = onSearchClick
|
||||||
|
)
|
||||||
|
RoundIconButton(
|
||||||
|
iconRes = R.drawable.ic_map_my_location,
|
||||||
|
contentDescription = stringResource(id = R.string.content_my_location),
|
||||||
|
onClick = onMyLocationClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoundIconButton(
|
||||||
|
iconRes: Int,
|
||||||
|
contentDescription: String,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
shape = CircleShape,
|
||||||
|
shadowElevation = 8.dp,
|
||||||
|
color = Color.White
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
painter = androidx.compose.ui.res.painterResource(id = iconRes),
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
tint = Color(0xFF1C1C1C)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MapSearchOverlay(
|
||||||
|
query: String,
|
||||||
|
results: List<SearchResult>,
|
||||||
|
onQueryChanged: (String) -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onResultClick: (SearchResult) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.White)
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
AirMQButton(
|
||||||
|
text = stringResource(id = R.string.content_back),
|
||||||
|
onClick = onClose,
|
||||||
|
style = AirMQButtonStyle.Outlined
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChanged,
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text(stringResource(id = R.string.map_search_hint)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.map_search_empty),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.Gray,
|
||||||
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
results.forEach { result ->
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onResultClick(result) },
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color(0xFFF7F7F7))
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = result.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = result.subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MapDevicePanelContent(
|
||||||
|
data: DevicePanelState,
|
||||||
|
onOpenDevice: () -> Unit,
|
||||||
|
onRangeSelected: (TimeRange) -> Unit,
|
||||||
|
onDateBack: () -> Unit,
|
||||||
|
onDateForward: () -> Unit,
|
||||||
|
onSensorSelected: (DeviceSensorType) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.Start
|
||||||
|
) {
|
||||||
|
Text(text = data.name, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = data.status,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AirMQButton(
|
||||||
|
text = stringResource(id = R.string.button_open),
|
||||||
|
onClick = onOpenDevice,
|
||||||
|
style = AirMQButtonStyle.Outlined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeRangeRow(selected = data.selectedRange, onSelected = onRangeSelected)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
AirMQButton(
|
||||||
|
text = stringResource(id = R.string.map_arrow_left),
|
||||||
|
onClick = onDateBack,
|
||||||
|
style = AirMQButtonStyle.Text
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = data.displayedDateRange,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
AirMQButton(
|
||||||
|
text = stringResource(id = R.string.map_arrow_right),
|
||||||
|
onClick = onDateForward,
|
||||||
|
style = AirMQButtonStyle.Text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(256.dp)
|
||||||
|
) {
|
||||||
|
AirMQChart(
|
||||||
|
data = data.chartDataset,
|
||||||
|
config = chartConfigForSensor(
|
||||||
|
data.selectedSensor,
|
||||||
|
leftTimeLabel = stringResource(R.string.filter_hour),
|
||||||
|
rightTimeLabel = stringResource(R.string.text_now)
|
||||||
|
),
|
||||||
|
sensorType = sensorTypeString(data.selectedSensor),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(220.dp)
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (data.isChartLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data.chartErrorMessage?.let { err ->
|
||||||
|
Text(
|
||||||
|
text = err,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color(0xFFB00020),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceSensorDropdown(
|
||||||
|
selectedSensor = data.selectedSensor,
|
||||||
|
supportedSensors = data.supportedSensors,
|
||||||
|
onSelected = onSensorSelected,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MapDevicePanel(
|
||||||
|
data: DevicePanelState,
|
||||||
|
onOpenDevice: () -> Unit,
|
||||||
|
onRangeSelected: (TimeRange) -> Unit,
|
||||||
|
onDateBack: () -> Unit,
|
||||||
|
onDateForward: () -> Unit,
|
||||||
|
onSensorSelected: (DeviceSensorType) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color.White)
|
||||||
|
) {
|
||||||
|
MapDevicePanelContent(
|
||||||
|
data = data,
|
||||||
|
onOpenDevice = onOpenDevice,
|
||||||
|
onRangeSelected = onRangeSelected,
|
||||||
|
onDateBack = onDateBack,
|
||||||
|
onDateForward = onDateForward,
|
||||||
|
onSensorSelected = onSensorSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TimeRangeRow(
|
||||||
|
selected: TimeRange,
|
||||||
|
onSelected: (TimeRange) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
TimeRangeChip(
|
||||||
|
label = stringResource(id = R.string.filter_hour),
|
||||||
|
selected = selected == TimeRange.HOUR,
|
||||||
|
onClick = { onSelected(TimeRange.HOUR) }
|
||||||
|
)
|
||||||
|
TimeRangeChip(
|
||||||
|
label = stringResource(id = R.string.filter_day),
|
||||||
|
selected = selected == TimeRange.DAY,
|
||||||
|
onClick = { onSelected(TimeRange.DAY) }
|
||||||
|
)
|
||||||
|
TimeRangeChip(
|
||||||
|
label = stringResource(id = R.string.filter_week),
|
||||||
|
selected = selected == TimeRange.WEEK,
|
||||||
|
onClick = { onSelected(TimeRange.WEEK) }
|
||||||
|
)
|
||||||
|
TimeRangeChip(
|
||||||
|
label = stringResource(id = R.string.filter_month),
|
||||||
|
selected = selected == TimeRange.MONTH,
|
||||||
|
onClick = { onSelected(TimeRange.MONTH) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TimeRangeChip(
|
||||||
|
label: String,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(32.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.then(
|
||||||
|
if (selected) {
|
||||||
|
Modifier.background(
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(Color(0xFF53AFA1), Color(0xFF247C84))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier.background(Color.Transparent)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = if (selected) Color.White else Color(0x8A000000),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceSensorDropdown(
|
||||||
|
selectedSensor: DeviceSensorType,
|
||||||
|
supportedSensors: List<DeviceSensorType>,
|
||||||
|
onSelected: (DeviceSensorType) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var isExpanded by remember { mutableStateOf(false) }
|
||||||
|
val sensors = supportedSensors.ifEmpty {
|
||||||
|
listOf(DeviceSensorType.TEMPERATURE, DeviceSensorType.DUST, DeviceSensorType.RADIOACTIVITY)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.width(204.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isExpanded,
|
||||||
|
enter = expandVertically(animationSpec = tween(durationMillis = 150)),
|
||||||
|
exit = shrinkVertically(animationSpec = tween(durationMillis = 150))
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
|
||||||
|
shadowElevation = 10.dp,
|
||||||
|
color = Color.White
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
sensors.forEachIndexed { index, sensorType ->
|
||||||
|
if (index > 0) {
|
||||||
|
HorizontalDivider(color = Color(0x1F000000))
|
||||||
|
}
|
||||||
|
DeviceSensorDropdownOption(
|
||||||
|
sensorType = sensorType,
|
||||||
|
selected = selectedSensor == sensorType,
|
||||||
|
showSelectedBorder = isExpanded && selectedSensor == sensorType,
|
||||||
|
onClick = {
|
||||||
|
onSelected(sensorType)
|
||||||
|
isExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val triggerShape = if (isExpanded) {
|
||||||
|
RoundedCornerShape(bottomStart = 18.dp, bottomEnd = 18.dp)
|
||||||
|
} else {
|
||||||
|
RoundedCornerShape(18.dp)
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(36.dp)
|
||||||
|
.clip(triggerShape)
|
||||||
|
.clickable { isExpanded = !isExpanded },
|
||||||
|
shape = triggerShape,
|
||||||
|
shadowElevation = 10.dp,
|
||||||
|
color = Color.White
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = sensorIconRes(selectedSensor)),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0x8A000000),
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = sensorLabelRes(selectedSensor)),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color(0x8A000000)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_arrow_right_24),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0x8A000000),
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sensorIconRes(sensorType: DeviceSensorType): Int = when (sensorType) {
|
||||||
|
DeviceSensorType.TEMPERATURE -> R.drawable.ic_temperature
|
||||||
|
DeviceSensorType.DUST -> R.drawable.ic_dust
|
||||||
|
DeviceSensorType.RADIOACTIVITY -> R.drawable.ic_radiation
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sensorLabelRes(sensorType: DeviceSensorType): Int = when (sensorType) {
|
||||||
|
DeviceSensorType.TEMPERATURE -> R.string.sensor_temperature
|
||||||
|
DeviceSensorType.DUST -> R.string.sensor_dust
|
||||||
|
DeviceSensorType.RADIOACTIVITY -> R.string.sensor_radioactivity
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceSensorDropdownOption(
|
||||||
|
sensorType: DeviceSensorType,
|
||||||
|
selected: Boolean,
|
||||||
|
showSelectedBorder: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val textColor = if (selected) Color(0xFF1C1C1C) else Color(0xFF616161)
|
||||||
|
val borderModifier = if (showSelectedBorder) {
|
||||||
|
Modifier.border(1.dp, Color(0x61000000), RoundedCornerShape(8.dp))
|
||||||
|
} else Modifier
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(borderModifier)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = sensorIconRes(sensorType)),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = textColor,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = sensorLabelRes(sensorType)),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMapTopControlsCollapsedDust() {
|
||||||
|
AirMQTheme {
|
||||||
|
MapTopControls(
|
||||||
|
selectedSensor = SensorType.DUST,
|
||||||
|
onSensorSelected = {},
|
||||||
|
onHelpClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMapTopControlsExpandedDust() {
|
||||||
|
AirMQTheme {
|
||||||
|
MapTopControls(
|
||||||
|
selectedSensor = SensorType.DUST,
|
||||||
|
onSensorSelected = {},
|
||||||
|
onHelpClick = {},
|
||||||
|
initiallyExpanded = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMapTopControlsExpandedRadioactivity() {
|
||||||
|
AirMQTheme {
|
||||||
|
MapTopControls(
|
||||||
|
selectedSensor = SensorType.RADIOACTIVITY,
|
||||||
|
onSensorSelected = {},
|
||||||
|
onHelpClick = {},
|
||||||
|
initiallyExpanded = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMapSearchOverlayEmpty() {
|
||||||
|
AirMQTheme {
|
||||||
|
MapSearchOverlay(
|
||||||
|
query = "Minsk",
|
||||||
|
results = emptyList(),
|
||||||
|
onQueryChanged = {},
|
||||||
|
onClose = {},
|
||||||
|
onResultClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMapSearchOverlayWithResults() {
|
||||||
|
AirMQTheme {
|
||||||
|
MapSearchOverlay(
|
||||||
|
query = "Minsk",
|
||||||
|
results = listOf(
|
||||||
|
SearchResult(id = "1", title = "AirMQ #42", subtitle = "Minsk"),
|
||||||
|
SearchResult(id = "2", title = "AirMQ #91", subtitle = "Salihorsk")
|
||||||
|
),
|
||||||
|
onQueryChanged = {},
|
||||||
|
onClose = {},
|
||||||
|
onResultClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMapDevicePanelDust() {
|
||||||
|
AirMQTheme {
|
||||||
|
MapDevicePanel(
|
||||||
|
data = DevicePanelState(
|
||||||
|
id = "42",
|
||||||
|
name = "AirMQ #42",
|
||||||
|
status = "Online",
|
||||||
|
selectedRange = TimeRange.DAY,
|
||||||
|
displayedDateRange = "Today",
|
||||||
|
selectedSensor = DeviceSensorType.DUST,
|
||||||
|
chartDataset = DashboardChartMapper.chartDataset(
|
||||||
|
DashboardChartMapper.previewStaticRows(),
|
||||||
|
MetricSensorType.DUST
|
||||||
|
),
|
||||||
|
isChartLoading = false
|
||||||
|
),
|
||||||
|
onOpenDevice = {},
|
||||||
|
onRangeSelected = {},
|
||||||
|
onDateBack = {},
|
||||||
|
onDateForward = {},
|
||||||
|
onSensorSelected = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun PreviewMapDevicePanelRadioactivity() {
|
||||||
|
AirMQTheme {
|
||||||
|
MapDevicePanel(
|
||||||
|
data = DevicePanelState(
|
||||||
|
id = "43",
|
||||||
|
name = "AirMQ #43",
|
||||||
|
status = "Offline",
|
||||||
|
selectedRange = TimeRange.WEEK,
|
||||||
|
displayedDateRange = "Last 7 days",
|
||||||
|
selectedSensor = DeviceSensorType.RADIOACTIVITY,
|
||||||
|
chartDataset = DashboardChartMapper.chartDataset(
|
||||||
|
DashboardChartMapper.previewStaticRows(),
|
||||||
|
MetricSensorType.RADIOACTIVITY
|
||||||
|
),
|
||||||
|
isChartLoading = false
|
||||||
|
),
|
||||||
|
onOpenDevice = {},
|
||||||
|
onRangeSelected = {},
|
||||||
|
onDateBack = {},
|
||||||
|
onDateForward = {},
|
||||||
|
onSensorSelected = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
497
app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt
Normal file
497
app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
package org.db3.airmq.features.map
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.common.chart.ChartDataset
|
||||||
|
import org.db3.airmq.features.common.metric.SensorType
|
||||||
|
import org.db3.airmq.features.dashboard.DashboardChartMapper
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.Action
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.DevicePanelState
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.DeviceSensorType
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.Event
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.SearchResult
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.SearchPanelState
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.SensorType as MapSensorType
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.State
|
||||||
|
import org.db3.airmq.features.map.MapScreenContract.TimeRange
|
||||||
|
import org.db3.airmq.sdk.auth.ApiTokenStore
|
||||||
|
import org.db3.airmq.sdk.dashboard.DeviceTimeSeriesRepository
|
||||||
|
import org.db3.airmq.sdk.dashboard.SensorSampleRow
|
||||||
|
import org.db3.airmq.sdk.map.MapService
|
||||||
|
import org.db3.airmq.sdk.map.domain.MapItem
|
||||||
|
import org.db3.airmq.sdk.settings.SettingsService
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class MapViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
|
private val mapService: MapService,
|
||||||
|
private val apiTokenStore: ApiTokenStore,
|
||||||
|
private val settingsService: SettingsService,
|
||||||
|
private val deviceTimeSeriesRepository: DeviceTimeSeriesRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(State(isLoading = true))
|
||||||
|
val uiState: StateFlow<State> = _uiState.asStateFlow()
|
||||||
|
private var domainItems: List<MapItem> = emptyList()
|
||||||
|
|
||||||
|
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
|
||||||
|
val actions: SharedFlow<Action> = _actions.asSharedFlow()
|
||||||
|
|
||||||
|
private var showOfflineDevices = false
|
||||||
|
private var deviceChartRowsCache: List<SensorSampleRow> = emptyList()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
apiTokenStore.observeToken().collectLatest {
|
||||||
|
refreshMapItems()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: Event) {
|
||||||
|
when (event) {
|
||||||
|
is Event.RetryClicked -> {
|
||||||
|
refreshMapItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.SearchButtonClicked -> {
|
||||||
|
deviceChartRowsCache = emptyList()
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
searchPanelState = SearchPanelState(),
|
||||||
|
devicePanelState = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.SearchClosed -> {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
searchPanelState = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.SearchQueryChanged -> {
|
||||||
|
val searchPanelState = _uiState.value.searchPanelState ?: return
|
||||||
|
val results = resolveSearchResults(event.value)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
searchPanelState = searchPanelState.copy(
|
||||||
|
query = event.value,
|
||||||
|
results = results
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.SearchResultClicked -> {
|
||||||
|
val selectedItem = _uiState.value.items.firstOrNull { it.id == event.resultId } ?: return
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
searchPanelState = null,
|
||||||
|
devicePanelState = selectedItem.toDevicePanelState(),
|
||||||
|
selectedMarkerId = event.resultId
|
||||||
|
)
|
||||||
|
loadDeviceChart(forceFetch = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.MyLocationClicked -> {
|
||||||
|
_actions.tryEmit(Action.ShowToast(appContext.getString(R.string.map_my_location_coming_soon)))
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.TopSensorSelected -> {
|
||||||
|
_uiState.value = _uiState.value.copy(selectedTopSensor = event.sensor)
|
||||||
|
remapMarkers()
|
||||||
|
}
|
||||||
|
|
||||||
|
Event.HelpClicked -> {
|
||||||
|
_uiState.value = _uiState.value.copy(showHelpDialog = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Event.HelpDialogDismissed -> {
|
||||||
|
_uiState.value = _uiState.value.copy(showHelpDialog = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.MarkerClicked -> {
|
||||||
|
val selectedItem = _uiState.value.items.firstOrNull { it.id == event.itemId } ?: return
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
searchPanelState = null,
|
||||||
|
devicePanelState = selectedItem.toDevicePanelState(),
|
||||||
|
selectedMarkerId = event.itemId
|
||||||
|
)
|
||||||
|
loadDeviceChart(forceFetch = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.DevicePanelClosed -> {
|
||||||
|
deviceChartRowsCache = emptyList()
|
||||||
|
val previousMarkerId = _uiState.value.devicePanelState?.id
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
devicePanelState = null,
|
||||||
|
selectedMarkerId = previousMarkerId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.DeviceOpenClicked -> {
|
||||||
|
val deviceId = _uiState.value.devicePanelState?.id ?: return
|
||||||
|
_actions.tryEmit(Action.OpenDeviceRequested(deviceId))
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.TimeRangeSelected -> {
|
||||||
|
val panelData = _uiState.value.devicePanelState ?: return
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
devicePanelState = panelData.copy(
|
||||||
|
selectedRange = event.range,
|
||||||
|
displayedDateRange = rangeLabel(event.range),
|
||||||
|
chartWindowOffset = 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
loadDeviceChart(forceFetch = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.DateBackClicked -> {
|
||||||
|
val panelData = _uiState.value.devicePanelState ?: return
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
devicePanelState = panelData.copy(
|
||||||
|
chartWindowOffset = panelData.chartWindowOffset - 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
loadDeviceChart(forceFetch = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Event.DateForwardClicked -> {
|
||||||
|
val panelData = _uiState.value.devicePanelState ?: return
|
||||||
|
if (panelData.chartWindowOffset >= 0) return
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
devicePanelState = panelData.copy(
|
||||||
|
chartWindowOffset = (panelData.chartWindowOffset + 1).coerceAtMost(0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
loadDeviceChart(forceFetch = true)
|
||||||
|
}
|
||||||
|
is Event.DeviceSensorSelected -> {
|
||||||
|
val panelData = _uiState.value.devicePanelState ?: return
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
devicePanelState = panelData.copy(selectedSensor = event.sensor)
|
||||||
|
)
|
||||||
|
loadDeviceChart(forceFetch = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadDeviceChart(forceFetch: Boolean = true) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val panel = _uiState.value.devicePanelState ?: return@launch
|
||||||
|
val locationId = panel.id
|
||||||
|
|
||||||
|
if (!forceFetch && deviceChartRowsCache.isNotEmpty()) {
|
||||||
|
val current = _uiState.value.devicePanelState ?: return@launch
|
||||||
|
if (current.id != locationId) return@launch
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
devicePanelState = current.copy(
|
||||||
|
chartDataset = DashboardChartMapper.chartDataset(
|
||||||
|
deviceChartRowsCache,
|
||||||
|
current.selectedSensor.toMetricSensorType()
|
||||||
|
),
|
||||||
|
isChartLoading = false,
|
||||||
|
chartErrorMessage = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locationId.startsWith(DUMMY_PREFIX) || apiTokenStore.getToken().isNullOrBlank()) {
|
||||||
|
deviceChartRowsCache = emptyList()
|
||||||
|
val current = _uiState.value.devicePanelState ?: return@launch
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
devicePanelState = current.copy(
|
||||||
|
chartDataset = ChartDataset.Single(emptyList()),
|
||||||
|
isChartLoading = false,
|
||||||
|
chartErrorMessage = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val loadingPanel = panel.copy(isChartLoading = true, chartErrorMessage = null)
|
||||||
|
_uiState.value = _uiState.value.copy(devicePanelState = loadingPanel)
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val span = spanMillis(panel.selectedRange)
|
||||||
|
val tTo = now + panel.chartWindowOffset * span
|
||||||
|
val tFrom = tTo - span
|
||||||
|
val (intervalH, intervalD, intervalM) = intervalsForRange(panel.selectedRange)
|
||||||
|
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
deviceTimeSeriesRepository.fetchTimeSeries(
|
||||||
|
locationId = locationId,
|
||||||
|
tFromEpochMillis = tFrom,
|
||||||
|
tToEpochMillis = tTo,
|
||||||
|
intervalHours = intervalH,
|
||||||
|
intervalDays = intervalD,
|
||||||
|
intervalMinutes = intervalM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentPanel = _uiState.value.devicePanelState
|
||||||
|
if (currentPanel?.id != locationId) return@launch
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { rows ->
|
||||||
|
deviceChartRowsCache = rows
|
||||||
|
val label = formatWindowRange(tFrom, tTo)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
devicePanelState = currentPanel.copy(
|
||||||
|
chartDataset = DashboardChartMapper.chartDataset(
|
||||||
|
rows,
|
||||||
|
currentPanel.selectedSensor.toMetricSensorType()
|
||||||
|
),
|
||||||
|
isChartLoading = false,
|
||||||
|
chartErrorMessage = null,
|
||||||
|
displayedDateRange = label
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { throwable ->
|
||||||
|
deviceChartRowsCache = emptyList()
|
||||||
|
_actions.tryEmit(
|
||||||
|
Action.ShowToast(
|
||||||
|
throwable.message ?: appContext.getString(R.string.map_failed_to_load_items)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
devicePanelState = currentPanel.copy(
|
||||||
|
chartDataset = ChartDataset.Single(emptyList()),
|
||||||
|
isChartLoading = false,
|
||||||
|
chartErrorMessage = throwable.message
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshMapItems() {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
if (apiTokenStore.getToken().isNullOrBlank()) {
|
||||||
|
domainItems = emptyList()
|
||||||
|
val searchPanelState = _uiState.value.searchPanelState
|
||||||
|
val selectedSensorType = _uiState.value.selectedTopSensor
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
items = dummyMarkers(selectedSensorType),
|
||||||
|
searchPanelState = searchPanelState?.copy(results = emptyList())
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val result = runCatching {
|
||||||
|
showOfflineDevices = settingsService.getOfflineDevicesVisible()
|
||||||
|
mapService.fetchMapItems(showOfflineDevices = showOfflineDevices)
|
||||||
|
}
|
||||||
|
_uiState.value = result.fold(
|
||||||
|
onSuccess = { items ->
|
||||||
|
domainItems = items
|
||||||
|
val searchPanelState = _uiState.value.searchPanelState
|
||||||
|
val selectedSensorType = _uiState.value.selectedTopSensor
|
||||||
|
val markers = items.toMarkers(selectedSensorType).ifEmpty {
|
||||||
|
dummyMarkers(selectedSensorType)
|
||||||
|
}
|
||||||
|
_uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
items = markers,
|
||||||
|
searchPanelState = searchPanelState?.copy(
|
||||||
|
results = resolveSearchResults(searchPanelState.query)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { throwable ->
|
||||||
|
domainItems = emptyList()
|
||||||
|
_actions.tryEmit(
|
||||||
|
Action.ShowToast(
|
||||||
|
throwable.message ?: appContext.getString(R.string.map_failed_to_load_items)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val searchPanelState = _uiState.value.searchPanelState
|
||||||
|
val selectedSensorType = _uiState.value.selectedTopSensor
|
||||||
|
_uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
items = dummyMarkers(selectedSensorType),
|
||||||
|
searchPanelState = searchPanelState?.copy(results = emptyList())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveSearchResults(query: String): List<SearchResult> {
|
||||||
|
if (query.isBlank()) return emptyList()
|
||||||
|
return _uiState.value.items
|
||||||
|
.filter { item ->
|
||||||
|
item.title.contains(query, ignoreCase = true) ||
|
||||||
|
(item.city?.contains(query, ignoreCase = true) == true)
|
||||||
|
}
|
||||||
|
.take(20)
|
||||||
|
.map { item ->
|
||||||
|
SearchResult(
|
||||||
|
id = item.id,
|
||||||
|
title = item.title,
|
||||||
|
subtitle = item.city ?: appContext.getString(R.string.map_no_city)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rangeLabel(range: TimeRange): String = when (range) {
|
||||||
|
TimeRange.HOUR -> appContext.getString(R.string.map_time_last_hour)
|
||||||
|
TimeRange.DAY -> appContext.getString(R.string.map_time_today)
|
||||||
|
TimeRange.WEEK -> appContext.getString(R.string.map_time_this_week)
|
||||||
|
TimeRange.MONTH -> appContext.getString(R.string.map_time_this_month)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatWindowRange(tFrom: Long, tTo: Long): String {
|
||||||
|
val df = SimpleDateFormat("MMM d HH:mm", Locale.getDefault())
|
||||||
|
return "${df.format(Date(tFrom))} – ${df.format(Date(tTo))}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun spanMillis(range: TimeRange): Long = when (range) {
|
||||||
|
TimeRange.HOUR -> HOUR_MS
|
||||||
|
TimeRange.DAY -> DAY_MS
|
||||||
|
TimeRange.WEEK -> WEEK_MS
|
||||||
|
TimeRange.MONTH -> MONTH_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun intervalsForRange(range: TimeRange): Triple<Int, Int, Int> = when (range) {
|
||||||
|
TimeRange.HOUR -> Triple(0, 0, 15)
|
||||||
|
TimeRange.DAY -> Triple(1, 0, 0)
|
||||||
|
TimeRange.WEEK -> Triple(0, 1, 0)
|
||||||
|
TimeRange.MONTH -> Triple(0, 1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun remapMarkers() {
|
||||||
|
val markers = domainItems.toMarkers(_uiState.value.selectedTopSensor)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
items = markers.ifEmpty { dummyMarkers(_uiState.value.selectedTopSensor) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dummyMarkers(sensorType: MapSensorType): List<MapMarker> = listOf(
|
||||||
|
MapMarker(
|
||||||
|
id = "dummy-1",
|
||||||
|
title = "AirMQ Demo #1",
|
||||||
|
city = "Minsk",
|
||||||
|
latitude = 53.9,
|
||||||
|
longitude = 27.56,
|
||||||
|
isOnline = true,
|
||||||
|
sensorType = sensorType,
|
||||||
|
value = 12.0,
|
||||||
|
isOwned = false
|
||||||
|
),
|
||||||
|
MapMarker(
|
||||||
|
id = "dummy-2",
|
||||||
|
title = "AirMQ Demo #2",
|
||||||
|
city = "Minsk",
|
||||||
|
latitude = 53.85,
|
||||||
|
longitude = 27.65,
|
||||||
|
isOnline = true,
|
||||||
|
sensorType = sensorType,
|
||||||
|
value = 45.0,
|
||||||
|
isOwned = true
|
||||||
|
),
|
||||||
|
MapMarker(
|
||||||
|
id = "dummy-3",
|
||||||
|
title = "AirMQ Demo #3",
|
||||||
|
city = "Minsk",
|
||||||
|
latitude = 53.75,
|
||||||
|
longitude = 27.45,
|
||||||
|
isOnline = false,
|
||||||
|
sensorType = sensorType,
|
||||||
|
value = 8.5,
|
||||||
|
isOwned = false
|
||||||
|
),
|
||||||
|
MapMarker(
|
||||||
|
id = "dummy-4",
|
||||||
|
title = "AirMQ Demo #4",
|
||||||
|
city = null,
|
||||||
|
latitude = 53.82,
|
||||||
|
longitude = 27.92,
|
||||||
|
isOnline = true,
|
||||||
|
sensorType = sensorType,
|
||||||
|
value = null,
|
||||||
|
isOwned = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun List<MapItem>.toMarkers(sensorType: MapSensorType): List<MapMarker> {
|
||||||
|
return mapNotNull { item ->
|
||||||
|
val value = when (sensorType) {
|
||||||
|
MapSensorType.DUST -> item.dustValue
|
||||||
|
MapSensorType.RADIOACTIVITY -> item.radioactivityValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return null if device is offline or value is missing
|
||||||
|
if (!showOfflineDevices && (!item.isOnline || value == null)) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
|
||||||
|
MapMarker(
|
||||||
|
id = item.id,
|
||||||
|
title = item.title,
|
||||||
|
city = item.city,
|
||||||
|
latitude = item.latitude,
|
||||||
|
longitude = item.longitude,
|
||||||
|
isOnline = item.isOnline,
|
||||||
|
sensorType = sensorType,
|
||||||
|
value = value,
|
||||||
|
isOwned = false // TODO: derive from authenticated user when ownership/auth is implemented.
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MapMarker.toDevicePanelState(): DevicePanelState {
|
||||||
|
val defaultSensor = when (sensorType) {
|
||||||
|
MapSensorType.DUST -> DeviceSensorType.DUST
|
||||||
|
MapSensorType.RADIOACTIVITY -> DeviceSensorType.RADIOACTIVITY
|
||||||
|
}
|
||||||
|
return DevicePanelState(
|
||||||
|
id = id,
|
||||||
|
name = title,
|
||||||
|
status = if (isOnline) {
|
||||||
|
appContext.getString(R.string.map_status_online)
|
||||||
|
} else {
|
||||||
|
appContext.getString(R.string.map_status_offline)
|
||||||
|
},
|
||||||
|
selectedRange = TimeRange.DAY,
|
||||||
|
displayedDateRange = rangeLabel(TimeRange.DAY),
|
||||||
|
selectedSensor = defaultSensor,
|
||||||
|
chartDataset = ChartDataset.Single(emptyList()),
|
||||||
|
isChartLoading = true,
|
||||||
|
chartErrorMessage = null,
|
||||||
|
chartWindowOffset = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DeviceSensorType.toMetricSensorType(): SensorType = when (this) {
|
||||||
|
DeviceSensorType.TEMPERATURE -> SensorType.TEMPERATURE
|
||||||
|
DeviceSensorType.DUST -> SensorType.DUST
|
||||||
|
DeviceSensorType.RADIOACTIVITY -> SensorType.RADIOACTIVITY
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val DUMMY_PREFIX = "dummy-"
|
||||||
|
private const val HOUR_MS = 60L * 60L * 1000L
|
||||||
|
private const val DAY_MS = 24L * HOUR_MS
|
||||||
|
private const val WEEK_MS = 7L * DAY_MS
|
||||||
|
private const val MONTH_MS = 30L * DAY_MS
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package org.db3.airmq.features.navigation
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.city.CityScreen
|
||||||
|
import org.db3.airmq.features.constructor.ChartConstructorScreen
|
||||||
|
import org.db3.airmq.features.constructor.MapConstructorScreen
|
||||||
|
import org.db3.airmq.features.constructor.NewsConstructorScreen
|
||||||
|
import org.db3.airmq.features.constructor.SelectMapWidgetLocationScreen
|
||||||
|
import org.db3.airmq.features.constructor.WidgetConstructorScreen
|
||||||
|
import org.db3.airmq.features.dashboard.DashboardScreen
|
||||||
|
import org.db3.airmq.features.debug.DebugScreen
|
||||||
|
import org.db3.airmq.features.device.DeviceSettingsScreen
|
||||||
|
import org.db3.airmq.features.entry.SplashScreen
|
||||||
|
import org.db3.airmq.features.entry.WizardScreen
|
||||||
|
import org.db3.airmq.features.location.LocationScreen
|
||||||
|
import org.db3.airmq.features.login.EmailLoginScreen
|
||||||
|
import org.db3.airmq.features.login.EmailRegisterScreen
|
||||||
|
import org.db3.airmq.features.login.LoginScreen
|
||||||
|
import org.db3.airmq.features.manage.ManageScreen
|
||||||
|
import org.db3.airmq.features.map.MapScreen
|
||||||
|
import org.db3.airmq.features.news.NewsDetailScreen
|
||||||
|
import org.db3.airmq.features.news.NewsScreen
|
||||||
|
import org.db3.airmq.features.settings.SettingsScreen
|
||||||
|
import org.db3.airmq.features.setup.SetupScreen
|
||||||
|
import org.db3.airmq.ui.theme.LegacyNavGradientEnd
|
||||||
|
import org.db3.airmq.ui.theme.LegacyNavGradientStart
|
||||||
|
import org.db3.airmq.ui.theme.LegacyNavSelected
|
||||||
|
import org.db3.airmq.ui.theme.LegacyNavUnselected
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AirMQNavGraph(modifier: Modifier = Modifier) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val tabItems = listOf(
|
||||||
|
BottomTabItem(route = AirMqRoutes.MAP, label = stringResource(id = R.string.title_map), iconRes = R.drawable.ic_map),
|
||||||
|
BottomTabItem(route = AirMqRoutes.DASHBOARD, label = stringResource(id = R.string.title_dashboard), iconRes = R.drawable.ic_dashboard),
|
||||||
|
BottomTabItem(route = AirMqRoutes.MANAGE, label = stringResource(id = R.string.title_manage), iconRes = R.drawable.ic_manage_active)
|
||||||
|
)
|
||||||
|
val tabRoutes = tabItems.map { it.route }.toSet()
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
|
val showBottomBar = currentRoute in tabRoutes
|
||||||
|
|
||||||
|
val view = LocalView.current
|
||||||
|
SideEffect {
|
||||||
|
val window = (view.context as? Activity)?.window ?: return@SideEffect
|
||||||
|
// Map: dark icons (light map background); Dashboard/Manage: white icons (dark nav bar)
|
||||||
|
val lightStatusBars = currentRoute == AirMqRoutes.MAP || !showBottomBar
|
||||||
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = lightStatusBars
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||||
|
bottomBar = {
|
||||||
|
if (showBottomBar) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.background(
|
||||||
|
brush = Brush.linearGradient(
|
||||||
|
colors = listOf(LegacyNavGradientEnd, LegacyNavGradientStart)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
NavigationBar(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
tonalElevation = 0.dp
|
||||||
|
) {
|
||||||
|
tabItems.forEach { tabItem ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = currentRoute == tabItem.route,
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(tabItem.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = LegacyNavSelected,
|
||||||
|
unselectedIconColor = LegacyNavUnselected,
|
||||||
|
selectedTextColor = LegacyNavSelected,
|
||||||
|
unselectedTextColor = LegacyNavUnselected,
|
||||||
|
indicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = tabItem.iconRes),
|
||||||
|
contentDescription = tabItem.label
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = { Text(tabItem.label) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = AirMqRoutes.DASHBOARD,
|
||||||
|
modifier = Modifier.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
composable(AirMqRoutes.SPLASH) {
|
||||||
|
SplashScreen(
|
||||||
|
onContinue = { navController.navigate(AirMqRoutes.WIZARD) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.WIZARD) {
|
||||||
|
WizardScreen(
|
||||||
|
onFinish = {
|
||||||
|
navController.navigate(AirMqRoutes.DASHBOARD) {
|
||||||
|
popUpTo(AirMqRoutes.SPLASH) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.DASHBOARD) {
|
||||||
|
DashboardScreen(
|
||||||
|
onOpenCity = { navController.navigate(AirMqRoutes.CITY) },
|
||||||
|
onOpenDevice = { navController.navigate(AirMqRoutes.device()) },
|
||||||
|
onOpenNews = { navController.navigate(AirMqRoutes.NEWS) },
|
||||||
|
onOpenWidgetConstructor = { navController.navigate(AirMqRoutes.WIDGET_CONSTRUCTOR) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.MAP) {
|
||||||
|
MapScreen()
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.MANAGE) {
|
||||||
|
ManageScreen(
|
||||||
|
onOpenDevice = { deviceId ->
|
||||||
|
navController.navigate(AirMqRoutes.device(deviceId))
|
||||||
|
},
|
||||||
|
onOpenSetup = { navController.navigate(AirMqRoutes.SETUP) },
|
||||||
|
onOpenSettings = { navController.navigate(AirMqRoutes.SETTINGS) },
|
||||||
|
onOpenLogin = { navController.navigate(AirMqRoutes.LOGIN) },
|
||||||
|
onOpenLocation = { navController.navigate(AirMqRoutes.LOCATION) },
|
||||||
|
onOpenAddLocation = { /* TODO */ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = AirMqRoutes.DEVICE,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("deviceId") {
|
||||||
|
type = NavType.StringType
|
||||||
|
defaultValue = "mock-device-id"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) { backStackEntry ->
|
||||||
|
DeviceSettingsScreen(
|
||||||
|
deviceId = backStackEntry.arguments?.getString("deviceId") ?: "mock-device-id",
|
||||||
|
onOpenLocation = { navController.navigate(AirMqRoutes.LOCATION) },
|
||||||
|
onShowOnMap = {
|
||||||
|
navController.navigate(AirMqRoutes.MAP) {
|
||||||
|
popUpTo(AirMqRoutes.MANAGE) {
|
||||||
|
inclusive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.CITY) {
|
||||||
|
CityScreen(
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.SETTINGS) {
|
||||||
|
SettingsScreen(
|
||||||
|
onOpenDebug = { navController.navigate(AirMqRoutes.DEBUG) },
|
||||||
|
onOpenCity = { navController.navigate(AirMqRoutes.CITY) },
|
||||||
|
onLogOutToManage = {
|
||||||
|
navController.navigate(AirMqRoutes.MANAGE) {
|
||||||
|
popUpTo(AirMqRoutes.MANAGE) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.DEBUG) {
|
||||||
|
DebugScreen(
|
||||||
|
onBackToSettings = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.LOCATION) {
|
||||||
|
LocationScreen(
|
||||||
|
onBackToManage = { navController.navigate(AirMqRoutes.MANAGE) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.SETUP) {
|
||||||
|
SetupScreen(
|
||||||
|
onFinishSetup = { navController.navigate(AirMqRoutes.MANAGE) },
|
||||||
|
onCancelSetup = { navController.navigate(AirMqRoutes.MANAGE) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.LOGIN) {
|
||||||
|
LoginScreen(
|
||||||
|
onLogInToManage = { navController.navigate(AirMqRoutes.MANAGE) },
|
||||||
|
onOpenEmailLogin = { navController.navigate(AirMqRoutes.EMAIL_LOGIN) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.EMAIL_LOGIN) {
|
||||||
|
EmailLoginScreen(
|
||||||
|
onLogInToManage = {
|
||||||
|
navController.navigate(AirMqRoutes.MANAGE) {
|
||||||
|
popUpTo(AirMqRoutes.LOGIN) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOpenRegister = { navController.navigate(AirMqRoutes.EMAIL_REGISTER) },
|
||||||
|
onBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.EMAIL_REGISTER) {
|
||||||
|
EmailRegisterScreen(
|
||||||
|
onRegisterSuccess = {
|
||||||
|
navController.navigate(AirMqRoutes.MANAGE) {
|
||||||
|
popUpTo(AirMqRoutes.LOGIN) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.NEWS) {
|
||||||
|
NewsScreen(
|
||||||
|
onOpenNewsDetail = { navController.navigate(AirMqRoutes.newsDetail()) },
|
||||||
|
onBackToDashboard = { navController.navigate(AirMqRoutes.DASHBOARD) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = AirMqRoutes.NEWS_DETAIL,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("newsId") {
|
||||||
|
type = NavType.StringType
|
||||||
|
defaultValue = "mock-news-id"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) { backStackEntry ->
|
||||||
|
NewsDetailScreen(
|
||||||
|
newsId = backStackEntry.arguments?.getString("newsId") ?: "mock-news-id",
|
||||||
|
onBackToNews = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.WIDGET_CONSTRUCTOR) {
|
||||||
|
WidgetConstructorScreen(
|
||||||
|
onOpenSelectMapWidgetLocation = {
|
||||||
|
navController.navigate(AirMqRoutes.SELECT_MAP_WIDGET_LOCATION)
|
||||||
|
},
|
||||||
|
onOpenMapConstructor = { navController.navigate(AirMqRoutes.MAP_CONSTRUCTOR) },
|
||||||
|
onOpenChartConstructor = { navController.navigate(AirMqRoutes.CHART_CONSTRUCTOR) },
|
||||||
|
onOpenNewsConstructor = { navController.navigate(AirMqRoutes.NEWS_CONSTRUCTOR) },
|
||||||
|
onBackToManage = { navController.navigate(AirMqRoutes.MANAGE) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.SELECT_MAP_WIDGET_LOCATION) {
|
||||||
|
SelectMapWidgetLocationScreen(
|
||||||
|
onDone = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.MAP_CONSTRUCTOR) {
|
||||||
|
MapConstructorScreen(
|
||||||
|
onBackToWidgetConstructor = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.CHART_CONSTRUCTOR) {
|
||||||
|
ChartConstructorScreen(
|
||||||
|
onBackToWidgetConstructor = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(AirMqRoutes.NEWS_CONSTRUCTOR) {
|
||||||
|
NewsConstructorScreen(
|
||||||
|
onBackToWidgetConstructor = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class BottomTabItem(
|
||||||
|
val route: String,
|
||||||
|
val label: String,
|
||||||
|
val iconRes: Int
|
||||||
|
)
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
package org.db3.airmq.features.navigation
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.navigation.NavType
|
|
||||||
import androidx.navigation.compose.NavHost
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import androidx.navigation.navArgument
|
|
||||||
import org.db3.airmq.features.city.CityScreen
|
|
||||||
import org.db3.airmq.features.constructor.ChartConstructorScreen
|
|
||||||
import org.db3.airmq.features.constructor.MapConstructorScreen
|
|
||||||
import org.db3.airmq.features.constructor.NewsConstructorScreen
|
|
||||||
import org.db3.airmq.features.constructor.SelectMapWidgetLocationScreen
|
|
||||||
import org.db3.airmq.features.constructor.WidgetConstructorScreen
|
|
||||||
import org.db3.airmq.features.dashboard.DashboardScreen
|
|
||||||
import org.db3.airmq.features.debug.DebugScreen
|
|
||||||
import org.db3.airmq.features.device.DeviceScreen
|
|
||||||
import org.db3.airmq.features.entry.SplashScreen
|
|
||||||
import org.db3.airmq.features.entry.WizardScreen
|
|
||||||
import org.db3.airmq.features.location.LocationScreen
|
|
||||||
import org.db3.airmq.features.login.LoginScreen
|
|
||||||
import org.db3.airmq.features.manage.ManageScreen
|
|
||||||
import org.db3.airmq.features.map.MapScreen
|
|
||||||
import org.db3.airmq.features.news.NewsDetailScreen
|
|
||||||
import org.db3.airmq.features.news.NewsScreen
|
|
||||||
import org.db3.airmq.features.settings.SettingsScreen
|
|
||||||
import org.db3.airmq.features.setup.SetupScreen
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AirMqNavGraph(modifier: Modifier = Modifier) {
|
|
||||||
val navController = rememberNavController()
|
|
||||||
|
|
||||||
NavHost(
|
|
||||||
navController = navController,
|
|
||||||
startDestination = AirMqRoutes.SPLASH,
|
|
||||||
modifier = modifier
|
|
||||||
) {
|
|
||||||
composable(AirMqRoutes.SPLASH) {
|
|
||||||
SplashScreen(
|
|
||||||
onContinue = { navController.navigate(AirMqRoutes.WIZARD) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.WIZARD) {
|
|
||||||
WizardScreen(
|
|
||||||
onFinish = {
|
|
||||||
navController.navigate(AirMqRoutes.DASHBOARD) {
|
|
||||||
popUpTo(AirMqRoutes.SPLASH) { inclusive = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.DASHBOARD) {
|
|
||||||
DashboardScreen(
|
|
||||||
onOpenMap = { navController.navigate(AirMqRoutes.MAP) },
|
|
||||||
onOpenManage = { navController.navigate(AirMqRoutes.MANAGE) },
|
|
||||||
onOpenCity = { navController.navigate(AirMqRoutes.CITY) },
|
|
||||||
onOpenDevice = { navController.navigate(AirMqRoutes.device()) },
|
|
||||||
onOpenNews = { navController.navigate(AirMqRoutes.NEWS) },
|
|
||||||
onOpenWidgetConstructor = { navController.navigate(AirMqRoutes.WIDGET_CONSTRUCTOR) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.MAP) {
|
|
||||||
MapScreen(
|
|
||||||
onOpenDevice = { navController.navigate(AirMqRoutes.device()) },
|
|
||||||
onBackToDashboard = { navController.navigate(AirMqRoutes.DASHBOARD) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.MANAGE) {
|
|
||||||
ManageScreen(
|
|
||||||
onOpenDevice = { navController.navigate(AirMqRoutes.device()) },
|
|
||||||
onOpenSetup = { navController.navigate(AirMqRoutes.SETUP) },
|
|
||||||
onOpenSettings = { navController.navigate(AirMqRoutes.SETTINGS) },
|
|
||||||
onOpenLogin = { navController.navigate(AirMqRoutes.LOGIN) },
|
|
||||||
onOpenLocation = { navController.navigate(AirMqRoutes.LOCATION) },
|
|
||||||
onOpenWidgetConstructor = { navController.navigate(AirMqRoutes.WIDGET_CONSTRUCTOR) },
|
|
||||||
onBackToDashboard = { navController.navigate(AirMqRoutes.DASHBOARD) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = AirMqRoutes.DEVICE,
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("deviceId") {
|
|
||||||
type = NavType.StringType
|
|
||||||
defaultValue = "mock-device-id"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { backStackEntry ->
|
|
||||||
DeviceScreen(
|
|
||||||
deviceId = backStackEntry.arguments?.getString("deviceId") ?: "mock-device-id",
|
|
||||||
onOpenLocation = { navController.navigate(AirMqRoutes.LOCATION) },
|
|
||||||
onShowOnMap = { navController.navigate(AirMqRoutes.MAP) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.CITY) {
|
|
||||||
CityScreen(
|
|
||||||
onBackToDashboard = { navController.navigate(AirMqRoutes.DASHBOARD) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.SETTINGS) {
|
|
||||||
SettingsScreen(
|
|
||||||
onOpenDebug = { navController.navigate(AirMqRoutes.DEBUG) },
|
|
||||||
onOpenCity = { navController.navigate(AirMqRoutes.CITY) },
|
|
||||||
onLogOutToManage = {
|
|
||||||
navController.navigate(AirMqRoutes.MANAGE) {
|
|
||||||
popUpTo(AirMqRoutes.MANAGE) { inclusive = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.DEBUG) {
|
|
||||||
DebugScreen(
|
|
||||||
onBackToSettings = { navController.popBackStack() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.LOCATION) {
|
|
||||||
LocationScreen(
|
|
||||||
onBackToManage = { navController.navigate(AirMqRoutes.MANAGE) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.SETUP) {
|
|
||||||
SetupScreen(
|
|
||||||
onFinishSetup = { navController.navigate(AirMqRoutes.MANAGE) },
|
|
||||||
onCancelSetup = { navController.navigate(AirMqRoutes.MANAGE) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.LOGIN) {
|
|
||||||
LoginScreen(
|
|
||||||
onLogInToManage = { navController.navigate(AirMqRoutes.MANAGE) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.NEWS) {
|
|
||||||
NewsScreen(
|
|
||||||
onOpenNewsDetail = { navController.navigate(AirMqRoutes.newsDetail()) },
|
|
||||||
onBackToDashboard = { navController.navigate(AirMqRoutes.DASHBOARD) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = AirMqRoutes.NEWS_DETAIL,
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("newsId") {
|
|
||||||
type = NavType.StringType
|
|
||||||
defaultValue = "mock-news-id"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { backStackEntry ->
|
|
||||||
NewsDetailScreen(
|
|
||||||
newsId = backStackEntry.arguments?.getString("newsId") ?: "mock-news-id",
|
|
||||||
onBackToNews = { navController.popBackStack() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.WIDGET_CONSTRUCTOR) {
|
|
||||||
WidgetConstructorScreen(
|
|
||||||
onOpenSelectMapWidgetLocation = {
|
|
||||||
navController.navigate(AirMqRoutes.SELECT_MAP_WIDGET_LOCATION)
|
|
||||||
},
|
|
||||||
onOpenMapConstructor = { navController.navigate(AirMqRoutes.MAP_CONSTRUCTOR) },
|
|
||||||
onOpenChartConstructor = { navController.navigate(AirMqRoutes.CHART_CONSTRUCTOR) },
|
|
||||||
onOpenNewsConstructor = { navController.navigate(AirMqRoutes.NEWS_CONSTRUCTOR) },
|
|
||||||
onBackToManage = { navController.navigate(AirMqRoutes.MANAGE) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.SELECT_MAP_WIDGET_LOCATION) {
|
|
||||||
SelectMapWidgetLocationScreen(
|
|
||||||
onDone = { navController.popBackStack() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.MAP_CONSTRUCTOR) {
|
|
||||||
MapConstructorScreen(
|
|
||||||
onBackToWidgetConstructor = { navController.popBackStack() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.CHART_CONSTRUCTOR) {
|
|
||||||
ChartConstructorScreen(
|
|
||||||
onBackToWidgetConstructor = { navController.popBackStack() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(AirMqRoutes.NEWS_CONSTRUCTOR) {
|
|
||||||
NewsConstructorScreen(
|
|
||||||
onBackToWidgetConstructor = { navController.popBackStack() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,8 @@ object AirMqRoutes {
|
|||||||
const val LOCATION = "detail/location"
|
const val LOCATION = "detail/location"
|
||||||
const val SETUP = "detail/setup"
|
const val SETUP = "detail/setup"
|
||||||
const val LOGIN = "detail/login"
|
const val LOGIN = "detail/login"
|
||||||
|
const val EMAIL_LOGIN = "detail/email-login"
|
||||||
|
const val EMAIL_REGISTER = "detail/email-register"
|
||||||
const val NEWS = "detail/news"
|
const val NEWS = "detail/news"
|
||||||
const val NEWS_DETAIL = "detail/news/{newsId}"
|
const val NEWS_DETAIL = "detail/news/{newsId}"
|
||||||
const val DEVICE = "detail/device/{deviceId}"
|
const val DEVICE = "detail/device/{deviceId}"
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package org.db3.airmq.features.news
|
package org.db3.airmq.features.news
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.db3.airmq.R
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import org.db3.airmq.features.common.MockScreenScaffold
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import org.db3.airmq.features.common.ScreenAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NewsDetailScreen(newsId: String, onBackToNews: () -> Unit) {
|
fun NewsDetailScreen(newsId: String, onBackToNews: () -> Unit) {
|
||||||
MockScreenScaffold(
|
MockScreenScaffold(
|
||||||
title = "News Detail",
|
title = stringResource(id = R.string.news_detail_title),
|
||||||
subtitle = "Mock newsId: $newsId",
|
subtitle = stringResource(id = R.string.coming_soon),
|
||||||
actions = listOf(ScreenAction("Back to News", onBackToNews))
|
actions = listOf(ScreenAction(stringResource(id = R.string.back_to_news), onBackToNews))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package org.db3.airmq.features.news
|
package org.db3.airmq.features.news
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.db3.airmq.R
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import org.db3.airmq.features.common.MockScreenScaffold
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import org.db3.airmq.features.common.ScreenAction
|
||||||
|
|
||||||
@@ -10,11 +12,11 @@ fun NewsScreen(
|
|||||||
onBackToDashboard: () -> Unit
|
onBackToDashboard: () -> Unit
|
||||||
) {
|
) {
|
||||||
MockScreenScaffold(
|
MockScreenScaffold(
|
||||||
title = "News",
|
title = stringResource(id = R.string.text_widget_news),
|
||||||
subtitle = "Mock news list screen.",
|
subtitle = stringResource(id = R.string.coming_soon),
|
||||||
actions = listOf(
|
actions = listOf(
|
||||||
ScreenAction("Open News Detail", onOpenNewsDetail),
|
ScreenAction(stringResource(id = R.string.news_open_detail), onOpenNewsDetail),
|
||||||
ScreenAction("Back to Dashboard", onBackToDashboard)
|
ScreenAction(stringResource(id = R.string.back_to_dashboard), onBackToDashboard)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,307 @@
|
|||||||
package org.db3.airmq.features.settings
|
package org.db3.airmq.features.settings
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
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.heightIn
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CheckboxDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.settings.SettingsScreenContract.Action
|
||||||
|
import org.db3.airmq.features.settings.SettingsScreenContract.Event
|
||||||
|
import org.db3.airmq.features.settings.SettingsScreenContract.State
|
||||||
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
onOpenDebug: () -> Unit,
|
onOpenDebug: () -> Unit,
|
||||||
onOpenCity: () -> Unit,
|
onOpenCity: () -> Unit,
|
||||||
onLogOutToManage: () -> Unit
|
onLogOutToManage: () -> Unit,
|
||||||
|
viewModel: SettingsViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
MockScreenScaffold(
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
title = "Settings",
|
val context = LocalContext.current
|
||||||
subtitle = "Settings and account actions.",
|
|
||||||
actions = listOf(
|
LaunchedEffect(viewModel) {
|
||||||
ScreenAction("Open Debug", onOpenDebug),
|
viewModel.actions.collectLatest { action ->
|
||||||
ScreenAction("Open City", onOpenCity),
|
when (action) {
|
||||||
ScreenAction("Log Out to Manage", onLogOutToManage)
|
Action.OpenDebug -> onOpenDebug()
|
||||||
)
|
Action.OpenCity -> onOpenCity()
|
||||||
|
Action.LogOutToManage -> onLogOutToManage()
|
||||||
|
is Action.ShowMessage -> Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsScreenContent(
|
||||||
|
uiState = uiState,
|
||||||
|
onEvent = viewModel::onEvent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsScreenContent(
|
||||||
|
uiState: State,
|
||||||
|
onEvent: (Event) -> Unit
|
||||||
|
) {
|
||||||
|
val screenHorizontalPadding = 16.dp
|
||||||
|
val iconSize = 20.dp
|
||||||
|
val iconTextGap = 12.dp
|
||||||
|
val headerTextStart = iconSize + iconTextGap
|
||||||
|
|
||||||
|
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
return@Scaffold
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(horizontal = screenHorizontalPadding, vertical = 12.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
if (uiState.isAuthorized) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.pref_account_header),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = Color(0xFF607D8B),
|
||||||
|
modifier = Modifier.padding(start = headerTextStart, top = 4.dp, bottom = 4.dp)
|
||||||
|
)
|
||||||
|
PreferenceRow(
|
||||||
|
iconName = "ic_pref_logout",
|
||||||
|
iconFallbackRes = android.R.drawable.ic_lock_power_off,
|
||||||
|
title = stringResource(id = R.string.pref_logout_title),
|
||||||
|
iconSize = iconSize,
|
||||||
|
iconTextGap = iconTextGap,
|
||||||
|
onClick = { onEvent(Event.LogOutClicked) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.pref_application_header),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = Color(0xFF607D8B),
|
||||||
|
modifier = Modifier.padding(start = headerTextStart, top = 4.dp, bottom = 4.dp)
|
||||||
|
)
|
||||||
|
PreferenceRow(
|
||||||
|
iconName = "ic_pref_city",
|
||||||
|
iconFallbackRes = android.R.drawable.ic_menu_myplaces,
|
||||||
|
title = stringResource(id = R.string.pref_city_title),
|
||||||
|
summary = uiState.city,
|
||||||
|
iconSize = iconSize,
|
||||||
|
iconTextGap = iconTextGap,
|
||||||
|
onClick = { onEvent(Event.CityClicked) }
|
||||||
|
)
|
||||||
|
PreferenceCheckRow(
|
||||||
|
iconName = "ic_pref_notifications",
|
||||||
|
iconFallbackRes = android.R.drawable.ic_dialog_info,
|
||||||
|
title = stringResource(id = R.string.pref_notifications_title),
|
||||||
|
checked = uiState.deviceStatusNotificationsEnabled,
|
||||||
|
iconSize = iconSize,
|
||||||
|
iconTextGap = iconTextGap,
|
||||||
|
onToggle = { onEvent(Event.DeviceStatusNotificationsChanged(it)) }
|
||||||
|
)
|
||||||
|
PreferenceCheckRow(
|
||||||
|
iconName = "ic_pref_offline_devices",
|
||||||
|
iconFallbackRes = android.R.drawable.ic_menu_mylocation,
|
||||||
|
title = stringResource(id = R.string.pref_offline_devices_title),
|
||||||
|
checked = uiState.offlineDevicesVisible,
|
||||||
|
iconSize = iconSize,
|
||||||
|
iconTextGap = iconTextGap,
|
||||||
|
onToggle = { onEvent(Event.OfflineDevicesVisibilityChanged(it)) }
|
||||||
|
)
|
||||||
|
PreferenceRow(
|
||||||
|
iconName = "ic_pref_info",
|
||||||
|
iconFallbackRes = android.R.drawable.ic_dialog_info,
|
||||||
|
title = stringResource(id = R.string.pref_about),
|
||||||
|
iconSize = iconSize,
|
||||||
|
iconTextGap = iconTextGap,
|
||||||
|
onClick = { onEvent(Event.AboutClicked) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PreferenceRow(
|
||||||
|
iconName: String,
|
||||||
|
iconFallbackRes: Int,
|
||||||
|
title: String,
|
||||||
|
summary: String? = null,
|
||||||
|
iconSize: androidx.compose.ui.unit.Dp,
|
||||||
|
iconTextGap: androidx.compose.ui.unit.Dp,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val iconRes = remember(iconName, iconFallbackRes) {
|
||||||
|
context.resources.getIdentifier(iconName, "drawable", context.packageName)
|
||||||
|
.takeIf { it != 0 } ?: iconFallbackRes
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 64.dp)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(iconTextGap)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = iconRes),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(iconSize),
|
||||||
|
tint = Color(0x99333333)
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.bodyLarge, color = Color(0xFF222222))
|
||||||
|
if (summary != null) {
|
||||||
|
Text(
|
||||||
|
text = summary,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color(0xFF6B6B6B)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PreferenceCheckRow(
|
||||||
|
iconName: String,
|
||||||
|
iconFallbackRes: Int,
|
||||||
|
title: String,
|
||||||
|
checked: Boolean,
|
||||||
|
iconSize: androidx.compose.ui.unit.Dp,
|
||||||
|
iconTextGap: androidx.compose.ui.unit.Dp,
|
||||||
|
onToggle: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val iconRes = remember(iconName, iconFallbackRes) {
|
||||||
|
context.resources.getIdentifier(iconName, "drawable", context.packageName)
|
||||||
|
.takeIf { it != 0 } ?: iconFallbackRes
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 64.dp)
|
||||||
|
.clickable { onToggle(!checked) }
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = iconRes),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(iconSize),
|
||||||
|
tint = Color(0x99333333)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(iconTextGap))
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color(0xFF222222),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Checkbox(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = onToggle,
|
||||||
|
colors = CheckboxDefaults.colors(
|
||||||
|
checkedColor = Color(0xFF2F6FA8),
|
||||||
|
uncheckedColor = Color(0xFF8C8C8C),
|
||||||
|
checkmarkColor = Color.White
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Settings Anonymous", showBackground = true, showSystemUi = true)
|
||||||
|
@Composable
|
||||||
|
private fun SettingsScreenAnonymousPreview() {
|
||||||
|
AirMQTheme {
|
||||||
|
SettingsScreenContent(
|
||||||
|
uiState = State(
|
||||||
|
isAuthorized = false,
|
||||||
|
city = "Minsk",
|
||||||
|
deviceStatusNotificationsEnabled = true,
|
||||||
|
offlineDevicesVisible = false,
|
||||||
|
advancedEnabled = false,
|
||||||
|
isLoading = false
|
||||||
|
),
|
||||||
|
onEvent = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Settings Advanced", showBackground = true, showSystemUi = true)
|
||||||
|
@Composable
|
||||||
|
private fun SettingsScreenAdvancedPreview() {
|
||||||
|
AirMQTheme {
|
||||||
|
SettingsScreenContent(
|
||||||
|
uiState = State(
|
||||||
|
isAuthorized = false,
|
||||||
|
city = "Minsk",
|
||||||
|
deviceStatusNotificationsEnabled = true,
|
||||||
|
offlineDevicesVisible = true,
|
||||||
|
advancedEnabled = true,
|
||||||
|
isLoading = false
|
||||||
|
),
|
||||||
|
onEvent = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Settings Advanced", showBackground = true, showSystemUi = true)
|
||||||
|
@Composable
|
||||||
|
private fun SettingsScreenAuthorizedPreview() {
|
||||||
|
AirMQTheme {
|
||||||
|
SettingsScreenContent(
|
||||||
|
uiState = State(
|
||||||
|
isAuthorized = true,
|
||||||
|
city = "Minsk",
|
||||||
|
deviceStatusNotificationsEnabled = true,
|
||||||
|
offlineDevicesVisible = true,
|
||||||
|
advancedEnabled = true,
|
||||||
|
isLoading = false
|
||||||
|
),
|
||||||
|
onEvent = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.db3.airmq.features.settings
|
||||||
|
|
||||||
|
object SettingsScreenContract {
|
||||||
|
data class State(
|
||||||
|
val isAuthorized: Boolean = false,
|
||||||
|
val city: String = "",
|
||||||
|
val deviceStatusNotificationsEnabled: Boolean = true,
|
||||||
|
val offlineDevicesVisible: Boolean = false,
|
||||||
|
val advancedEnabled: Boolean = false,
|
||||||
|
val isLoading: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface Action {
|
||||||
|
data object OpenDebug : Action
|
||||||
|
data object OpenCity : Action
|
||||||
|
data object LogOutToManage : Action
|
||||||
|
data class ShowMessage(val message: String) : Action
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Event {
|
||||||
|
data object CityClicked : Event
|
||||||
|
data object AboutClicked : Event
|
||||||
|
data object DebugClicked : Event
|
||||||
|
data object LogOutClicked : Event
|
||||||
|
data class DeviceStatusNotificationsChanged(val enabled: Boolean) : Event
|
||||||
|
data class OfflineDevicesVisibilityChanged(val enabled: Boolean) : Event
|
||||||
|
data class AdvancedChanged(val enabled: Boolean) : Event
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package org.db3.airmq.features.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.db3.airmq.R
|
||||||
|
import org.db3.airmq.features.settings.SettingsScreenContract.Action
|
||||||
|
import org.db3.airmq.features.settings.SettingsScreenContract.Event
|
||||||
|
import org.db3.airmq.features.settings.SettingsScreenContract.State
|
||||||
|
import org.db3.airmq.sdk.auth.AuthService
|
||||||
|
import org.db3.airmq.sdk.city.CityService
|
||||||
|
import org.db3.airmq.sdk.settings.SettingsService
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SettingsViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
|
private val settingsService: SettingsService,
|
||||||
|
private val authService: AuthService,
|
||||||
|
private val cityService: CityService
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(State())
|
||||||
|
val uiState: StateFlow<State> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
|
||||||
|
val actions: SharedFlow<Action> = _actions.asSharedFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: Event) {
|
||||||
|
when (event) {
|
||||||
|
Event.CityClicked -> _actions.tryEmit(Action.OpenCity)
|
||||||
|
Event.AboutClicked -> _actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.coming_soon)))
|
||||||
|
Event.DebugClicked -> _actions.tryEmit(Action.OpenDebug)
|
||||||
|
Event.LogOutClicked -> logOut()
|
||||||
|
is Event.DeviceStatusNotificationsChanged -> {
|
||||||
|
updateToggle(
|
||||||
|
toggle = { settingsService.setDeviceStatusNotificationsEnabled(event.enabled) },
|
||||||
|
updateState = { copy(deviceStatusNotificationsEnabled = event.enabled) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Event.OfflineDevicesVisibilityChanged -> {
|
||||||
|
updateToggle(
|
||||||
|
toggle = { settingsService.setOfflineDevicesVisible(event.enabled) },
|
||||||
|
updateState = { copy(offlineDevicesVisible = event.enabled) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Event.AdvancedChanged -> {
|
||||||
|
updateToggle(
|
||||||
|
toggle = { settingsService.setAdvancedEnabled(event.enabled) },
|
||||||
|
updateState = { copy(advancedEnabled = event.enabled) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSettings() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val session = authService.getUser()
|
||||||
|
val city = cityService.getSelectedCity()
|
||||||
|
val deviceStatus = settingsService.getDeviceStatusNotificationsEnabled()
|
||||||
|
val offlineVisible = settingsService.getOfflineDevicesVisible()
|
||||||
|
val advanced = settingsService.getAdvancedEnabled()
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isAuthorized = session?.isAuthenticated == true,
|
||||||
|
city = city,
|
||||||
|
deviceStatusNotificationsEnabled = deviceStatus,
|
||||||
|
offlineDevicesVisible = offlineVisible,
|
||||||
|
advancedEnabled = advanced,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateToggle(
|
||||||
|
toggle: suspend () -> Result<Unit>,
|
||||||
|
updateState: State.() -> State
|
||||||
|
) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val previous = _uiState.value
|
||||||
|
_uiState.value = previous.updateState()
|
||||||
|
val result = toggle()
|
||||||
|
if (result.isFailure) {
|
||||||
|
_uiState.value = previous
|
||||||
|
_actions.tryEmit(
|
||||||
|
Action.ShowMessage(
|
||||||
|
result.exceptionOrNull()?.message
|
||||||
|
?: appContext.getString(R.string.settings_save_failed)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logOut() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val result = authService.signOut()
|
||||||
|
if (result.isSuccess) {
|
||||||
|
_actions.tryEmit(Action.LogOutToManage)
|
||||||
|
} else {
|
||||||
|
_actions.tryEmit(
|
||||||
|
Action.ShowMessage(
|
||||||
|
result.exceptionOrNull()?.message ?: appContext.getString(R.string.toast_error)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package org.db3.airmq.features.setup
|
package org.db3.airmq.features.setup
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.db3.airmq.R
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import org.db3.airmq.features.common.MockScreenScaffold
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
import org.db3.airmq.features.common.ScreenAction
|
||||||
|
|
||||||
@@ -10,11 +12,11 @@ fun SetupScreen(
|
|||||||
onCancelSetup: () -> Unit
|
onCancelSetup: () -> Unit
|
||||||
) {
|
) {
|
||||||
MockScreenScaffold(
|
MockScreenScaffold(
|
||||||
title = "Setup",
|
title = stringResource(id = R.string.button_setup),
|
||||||
subtitle = "Mock setup flow for device onboarding.",
|
subtitle = stringResource(id = R.string.coming_soon),
|
||||||
actions = listOf(
|
actions = listOf(
|
||||||
ScreenAction("Finish Setup", onFinishSetup),
|
ScreenAction(stringResource(id = R.string.button_finish), onFinishSetup),
|
||||||
ScreenAction("Cancel Setup", onCancelSetup)
|
ScreenAction(stringResource(id = R.string.button_cancel), onCancelSetup)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,60 @@ package org.db3.airmq.ui.theme
|
|||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
val Purple80 = Color(0xFFD0BCFF)
|
val LegacyPrimary = Color(0xFF295989)
|
||||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
val LegacyPrimaryDark = Color(0xFF0069C0)
|
||||||
val Pink80 = Color(0xFFEFB8C8)
|
val LegacyAccent = Color(0xFF295989)
|
||||||
|
val LegacyBackground = Color(0xFFFAFAFA)
|
||||||
|
val LegacySurface = Color(0xFFFFFFFF)
|
||||||
|
val LegacyOnPrimary = Color(0xFFFFFFFF)
|
||||||
|
val LegacyOnSecondary = Color(0xFFFFFFFF)
|
||||||
|
val LegacyOnBackground = Color(0xFF000000)
|
||||||
|
val LegacyOnSurface = Color(0xFF000000)
|
||||||
|
val LegacyOutline = Color(0x61000000)
|
||||||
|
val LegacyOutlineLight = Color(0x3B000000)
|
||||||
|
val LegacyBlack12 = Color(0x1F000000)
|
||||||
|
val LegacyBlack38 = Color(0x61000000)
|
||||||
|
val LegacyNavSelected = Color(0xFFFFFFFF)
|
||||||
|
val LegacyNavUnselected = Color(0x8AFFFFFF)
|
||||||
|
val LegacyNavContainer = Color(0xFF295989)
|
||||||
|
val LegacyNavGradientStart = Color(0xFF005BAB)
|
||||||
|
val LegacyNavGradientEnd = Color(0xFF4997D1)
|
||||||
|
|
||||||
val Purple40 = Color(0xFF6650a4)
|
/** Dashboard city selector pill (legacy vectors city_left / city_right / city_middle) */
|
||||||
val PurpleGrey40 = Color(0xFF625b71)
|
val DashboardCityChipGradientStart = Color(0xFF3EDEA5)
|
||||||
val Pink40 = Color(0xFF7D5260)
|
val DashboardCityChipGradientEnd = Color(0xFF33B6EE)
|
||||||
|
|
||||||
|
val LegacyButtonContained = Color(0xFF135CA5)
|
||||||
|
val LegacyButtonContainedDisabledOverlay = Color(0x1F000000)
|
||||||
|
val LegacyButtonPressedOverlayDark = Color(0x33000000)
|
||||||
|
val LegacyButtonPressedOverlayLight = Color(0x33FFFFFF)
|
||||||
|
val LegacyButtonOnContained = Color(0xFFFFFFFF)
|
||||||
|
val LegacyButtonOnOutlined = Color(0xFF135CA5)
|
||||||
|
val LegacyButtonOnText = Color(0xFF295989)
|
||||||
|
val LegacyButtonGradientStart = Color(0xFF03B6EC)
|
||||||
|
val LegacyButtonGradientEnd = Color(0xFF01DEA7)
|
||||||
|
|
||||||
|
// Chart colors
|
||||||
|
val ChartFill = Color(0xFFBBD3E9)
|
||||||
|
val ChartBackground = Color(0x66FFFFFF) // More opaque for visibility on light/dark
|
||||||
|
val ChartFillWidget = Color(0xFF84B0B3)
|
||||||
|
val ChartBackgroundWidget = Color(0x33FFFFFF) // More visible on light
|
||||||
|
val ChartLineWidget = Color(0xFF2C7575)
|
||||||
|
|
||||||
|
// Sensor colors
|
||||||
|
val SensorTemperature = Color(0xFFFFB357)
|
||||||
|
val SensorHumidity = Color(0xFF96D98D)
|
||||||
|
val SensorPressure = Color(0xFF4FC3F7)
|
||||||
|
val SensorDust25 = Color(0xFF4A90D9)
|
||||||
|
val SensorDust10 = Color(0xFF81C784)
|
||||||
|
val SensorDust1 = Color(0xFF90A4AE)
|
||||||
|
val SensorRadioactivity = Color(0xFFE57373)
|
||||||
|
|
||||||
|
// AQI / metric gauge colors (EPA scale for dust)
|
||||||
|
val SensorGreen = Color(0xFF00FF1E)
|
||||||
|
val SensorYellow = Color(0xFFFFBF00)
|
||||||
|
val SensorOrange = Color(0xFFFF6F00)
|
||||||
|
val SensorRed = Color(0xFFFF0000)
|
||||||
|
val SensorPink = Color(0xFFFF006A)
|
||||||
|
val SensorPurple = Color(0xFF6200FF)
|
||||||
|
val SensorGrey = Color(0xFF858585)
|
||||||
@@ -1,51 +1,46 @@
|
|||||||
package org.db3.airmq.ui.theme
|
package org.db3.airmq.ui.theme
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val DarkColorScheme = darkColorScheme(
|
||||||
primary = Purple80,
|
primary = LegacyPrimary,
|
||||||
secondary = PurpleGrey80,
|
onPrimary = LegacyOnPrimary,
|
||||||
tertiary = Pink80
|
secondary = LegacyAccent,
|
||||||
|
onSecondary = LegacyOnSecondary,
|
||||||
|
tertiary = LegacyPrimaryDark,
|
||||||
|
background = LegacyBackground,
|
||||||
|
onBackground = LegacyOnBackground,
|
||||||
|
surface = LegacySurface,
|
||||||
|
onSurface = LegacyOnSurface,
|
||||||
|
outline = LegacyOutline,
|
||||||
|
onSurfaceVariant = LegacyNavUnselected
|
||||||
)
|
)
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
private val LightColorScheme = lightColorScheme(
|
||||||
primary = Purple40,
|
primary = LegacyPrimary,
|
||||||
secondary = PurpleGrey40,
|
onPrimary = LegacyOnPrimary,
|
||||||
tertiary = Pink40
|
secondary = LegacyAccent,
|
||||||
|
onSecondary = LegacyOnSecondary,
|
||||||
/* Other default colors to override
|
tertiary = LegacyPrimaryDark,
|
||||||
background = Color(0xFFFFFBFE),
|
background = LegacyBackground,
|
||||||
surface = Color(0xFFFFFBFE),
|
onBackground = LegacyOnBackground,
|
||||||
onPrimary = Color.White,
|
surface = LegacySurface,
|
||||||
onSecondary = Color.White,
|
onSurface = LegacyOnSurface,
|
||||||
onTertiary = Color.White,
|
outline = LegacyOutline,
|
||||||
onBackground = Color(0xFF1C1B1F),
|
onSurfaceVariant = LegacyNavUnselected
|
||||||
onSurface = Color(0xFF1C1B1F),
|
|
||||||
*/
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AirMQTheme(
|
fun AirMQTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = false,
|
||||||
// Dynamic color is available on Android 12+
|
dynamicColor: Boolean = false,
|
||||||
dynamicColor: Boolean = true,
|
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
dynamicColor -> LightColorScheme
|
||||||
val context = LocalContext.current
|
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
darkTheme -> DarkColorScheme
|
darkTheme -> DarkColorScheme
|
||||||
else -> LightColorScheme
|
else -> LightColorScheme
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
app/src/main/res/drawable-hdpi/ic_facebook.png
Normal file
BIN
app/src/main/res/drawable-hdpi/ic_facebook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 595 B |
BIN
app/src/main/res/drawable-mdpi/ic_facebook.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_facebook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 411 B |
BIN
app/src/main/res/drawable-xhdpi/ic_facebook.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/ic_facebook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 703 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_facebook.png
Normal file
BIN
app/src/main/res/drawable-xxhdpi/ic_facebook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_facebook.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/ic_facebook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
39
app/src/main/res/drawable/airmq_logo.xml
Normal file
39
app/src/main/res/drawable/airmq_logo.xml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="168dp"
|
||||||
|
android:height="172.04dp"
|
||||||
|
android:viewportWidth="168"
|
||||||
|
android:viewportHeight="172.04">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M168,87.43l-0.01,0.18c-0.15,2.88 -0.45,5.72 -0.87,8.51c-10.82,5.07 -21.99,7.26 -33.8,6.64c-1.11,-0.06 -2.23,-0.15 -3.35,-0.25c-16.88,-1.62 -33.82,-8.8 -48.38,-15.4C51.18,73.32 27.31,64.9 0,82.7c0.01,-1.29 0.04,-2.6 0.11,-3.91c0.09,-1.68 0.22,-3.34 0.42,-4.99c28.44,-16 54.39,-6.85 84.09,6.62C114.02,93.74 139.82,102.87 168,87.43z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M167.23,76.41c-10.52,4.77 -21.39,6.82 -32.86,6.21c-1.11,-0.06 -2.23,-0.15 -3.35,-0.25c-16.88,-1.62 -33.82,-8.8 -48.38,-15.4c-29.3,-13.28 -52.54,-21.58 -78.63,-6.24c1.17,-3.57 2.58,-7.04 4.2,-10.37l0,-0.01c25.89,-11.23 50.08,-2.49 77.46,9.93c28.31,12.83 53.28,21.78 80.25,8.64C166.48,71.38 166.92,73.87 167.23,76.41z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M163.29,109.44c-1.19,3.32 -2.58,6.54 -4.17,9.65c-8.65,3.04 -17.56,4.3 -26.87,3.81c-1.11,-0.06 -2.23,-0.15 -3.35,-0.25c-16.88,-1.62 -33.82,-8.8 -48.38,-15.4c-29.1,-13.2 -52.22,-21.48 -78.11,-6.55c-0.6,-2.49 -1.08,-5.01 -1.44,-7.58c27.85,-14.85 53.41,-5.79 82.58,7.44C111.69,113.3 136.52,122.21 163.29,109.44z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M164.15,58.63c-2.24,1.15 -4.47,2.14 -6.69,2.96c-9.18,-29.98 -36.29,-52.47 -69.41,-54.21C62.7,6.05 39.6,17.15 24.65,35.35c-3.52,0.42 -7.07,1.13 -10.64,2.16C29.92,13.62 57.67,-1.5 88.42,0.12C124.37,2.01 153.86,26.22 164.15,58.63z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M145.98,64.48c-2.5,0.34 -4.99,0.51 -7.49,0.51c-7.51,-21.43 -27.37,-37.29 -51.44,-38.56c-13.41,-0.71 -25.99,3.23 -36.19,10.4c-3.24,-0.7 -6.49,-1.23 -9.75,-1.57c12.3,-10.82 28.67,-17.02 46.32,-16.09C115.24,20.64 138.05,39.4 145.98,64.48z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M125.11,63.61c-2.99,-0.59 -6.01,-1.37 -9.06,-2.3l-0.01,0c-6.71,-9.45 -17.51,-15.84 -29.97,-16.49c-3.1,-0.16 -6.12,0.03 -9.05,0.56c-3.38,-1.4 -6.77,-2.72 -10.15,-3.91c6.01,-2.43 12.64,-3.61 19.54,-3.25C103.53,39.12 117.97,49.29 125.11,63.61z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M53.01,128.35c0.17,0.25 0.37,0.47 0.6,0.66l-0.19,-0.19C53.28,128.68 53.14,128.51 53.01,128.35z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M47.77,145.01c-0.31,-0.29 -0.66,-0.54 -1.05,-0.7C47.09,144.46 47.46,144.71 47.77,145.01z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M55.74,149.93c-0.91,0 -1.77,0.15 -2.58,0.42l-7.05,-7.08c-0.24,-0.21 -0.49,-0.41 -0.76,-0.56c-0.1,-0.05 -0.19,-0.1 -0.29,-0.14c-0.19,-0.09 -0.4,-0.15 -0.6,-0.19c-0.24,-0.05 -0.44,-0.06 -0.66,-0.06h-8.68l-17.33,-17.31v-12.38h14.07v4.75c-2.75,1.26 -4.64,3.99 -4.64,7.19c0,4.35 3.55,7.9 7.9,7.9c4.34,0 7.9,-3.55 7.9,-7.9c0,-3.2 -1.9,-5.93 -4.64,-7.19v-8.01c0,-1.79 -1.44,-3.25 -3.26,-3.25H14.53c-1.82,0 -3.27,1.46 -3.27,3.25v16.98c0,0.1 0.01,0.19 0.01,0.29c0,0.02 0.02,0.04 0.02,0.04c0,0.11 0.02,0.21 0.04,0.29c0.05,0.21 0.11,0.42 0.17,0.62c0.16,0.4 0.39,0.77 0.72,1.07l19.22,19.22c0.57,0.58 1.39,0.96 2.3,0.96h8.68l5.88,5.87c-0.5,1.03 -0.77,2.19 -0.77,3.41c0,4.52 3.67,8.19 8.19,8.19s8.2,-3.67 8.2,-8.19S60.26,149.93 55.74,149.93zM35.12,121.33c1.57,0 2.86,1.12 3.2,2.58c0.04,0.24 0.06,0.46 0.06,0.68c0,1.82 -1.47,3.28 -3.26,3.28c-1.8,0 -3.26,-1.47 -3.26,-3.28c0,-0.22 0.01,-0.45 0.06,-0.68C32.25,122.45 33.56,121.33 35.12,121.33zM55.74,161.39c-1.79,0 -3.25,-1.47 -3.25,-3.26c0,-0.44 0.07,-0.83 0.22,-1.22c0.39,-0.97 1.23,-1.72 2.26,-1.97c0.25,-0.06 0.5,-0.1 0.76,-0.1c1.8,0 3.26,1.48 3.26,3.28C59,159.92 57.55,161.39 55.74,161.39z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M145.14,136.23c-0.16,-0.4 -0.4,-0.76 -0.72,-1.07c-0.65,-0.65 -1.49,-0.96 -2.31,-0.96H93.77l-6.76,-6.74c0.44,-0.97 0.68,-2.05 0.68,-3.2c0,-4.34 -3.55,-7.9 -7.91,-7.9c-4.35,0 -7.9,3.56 -7.9,7.9c0,4.38 3.55,7.93 7.9,7.93c0.82,0 1.6,-0.12 2.35,-0.35c0.01,0 0.01,0 0.01,0l7.94,7.94c0.65,0.63 1.49,0.95 2.31,0.95h41.83l-11.66,11.67h-8.82c-1.26,-2.89 -4.14,-4.9 -7.49,-4.9c-4.52,0 -8.19,3.67 -8.19,8.19s3.67,8.19 8.19,8.19c3.35,0 6.23,-2.04 7.5,-4.93h10.15c0.85,0 1.67,-0.34 2.31,-0.97l18.19,-18.19c0.32,-0.31 0.56,-0.68 0.72,-1.07C145.45,137.9 145.45,137.04 145.14,136.23zM79.78,127.55c-0.31,0 -0.62,-0.04 -0.9,-0.12c-1.36,-0.39 -2.36,-1.65 -2.36,-3.14c0,-1.82 1.46,-3.28 3.26,-3.28c1.73,0 3.14,1.37 3.23,3.07c0.02,0.06 0.02,0.15 0.02,0.21C83.04,126.08 81.57,127.55 79.78,127.55zM108.96,157.49c-0.6,0.87 -1.58,1.44 -2.7,1.44c-1.82,0 -3.27,-1.47 -3.27,-3.26c0,-1.82 1.46,-3.28 3.27,-3.28c1.12,0 2.1,0.58 2.7,1.46c0.35,0.51 0.56,1.14 0.56,1.83C109.52,156.36 109.31,156.98 108.96,157.49z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M85.9,156.92v-3.47c0,-0.44 -0.07,-0.85 -0.25,-1.22c-0.06,-0.14 -0.11,-0.29 -0.2,-0.41c-0.06,-0.12 -0.12,-0.22 -0.21,-0.32c-0.07,-0.12 -0.19,-0.25 -0.29,-0.36l-25.97,-25.97v-3.83c2.89,-1.26 4.91,-4.14 4.91,-7.5c0,-4.52 -3.67,-8.2 -8.17,-8.2c-4.52,0 -8.19,3.68 -8.19,8.2c0,3.33 2.02,6.22 4.91,7.5v5.18c0,0.44 0.07,0.85 0.24,1.24c0.09,0.2 0.19,0.39 0.31,0.57c0,0 0,0 0,0.02c0.12,0.16 0.27,0.32 0.41,0.47l25.98,25.98v2.12c-2.75,1.26 -4.64,4.02 -4.64,7.22c0,4.35 3.55,7.9 7.89,7.9c4.35,0 7.9,-3.55 7.9,-7.9C90.55,160.94 88.65,158.18 85.9,156.92zM56.67,116.97c-0.29,0.1 -0.6,0.14 -0.93,0.14c-0.32,0 -0.63,-0.04 -0.92,-0.14c-1.34,-0.4 -2.35,-1.65 -2.35,-3.14c0,-1.8 1.48,-3.26 3.27,-3.26c1.79,0 3.26,1.46 3.26,3.26C58.99,115.32 58,116.57 56.67,116.97zM82.64,167.4c-1.79,0 -3.25,-1.47 -3.25,-3.26c0,-0.83 0.3,-1.57 0.8,-2.15c0.6,-0.7 1.47,-1.13 2.45,-1.13c1,0 1.87,0.44 2.46,1.13c0.49,0.58 0.8,1.32 0.8,2.15C85.9,165.93 84.45,167.4 82.64,167.4z" />
|
||||||
|
</vector>
|
||||||
BIN
app/src/main/res/drawable/aqi_table.png
Normal file
BIN
app/src/main/res/drawable/aqi_table.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
10
app/src/main/res/drawable/circle_indicator.xml
Normal file
10
app/src/main/res/drawable/circle_indicator.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="10dp"
|
||||||
|
android:height="10dp"
|
||||||
|
android:viewportWidth="10"
|
||||||
|
android:viewportHeight="10">
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/sensorOrange"
|
||||||
|
android:pathData="M5,5m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"/>
|
||||||
|
</vector>
|
||||||
4
app/src/main/res/drawable/circle_marker.xml
Normal file
4
app/src/main/res/drawable/circle_marker.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||||
|
<solid android:color="@color/white" />
|
||||||
|
</shape>
|
||||||
4
app/src/main/res/drawable/circle_marker_shade.xml
Normal file
4
app/src/main/res/drawable/circle_marker_shade.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||||
|
<solid android:color="#66000000" />
|
||||||
|
</shape>
|
||||||
30
app/src/main/res/drawable/device_chip_offline.xml
Normal file
30
app/src/main/res/drawable/device_chip_offline.xml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="52dp"
|
||||||
|
android:height="18dp"
|
||||||
|
android:viewportWidth="52"
|
||||||
|
android:viewportHeight="18">
|
||||||
|
<path
|
||||||
|
android:pathData="M43,18H9c-4.97,0 -9,-4.03 -9,-9v0c0,-4.97 4.03,-9 9,-9l34,0c4.95,0 9,4.05 9,9v0C52,13.97 47.97,18 43,18z"
|
||||||
|
android:fillColor="#F1AF00"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M10.4,9.51c0,-0.62 0.12,-1.18 0.37,-1.68s0.58,-0.88 1.02,-1.15s0.93,-0.4 1.49,-0.4c0.86,0 1.56,0.3 2.09,0.9s0.8,1.39 0.8,2.38v0.08c0,0.62 -0.12,1.17 -0.35,1.66s-0.57,0.87 -1.01,1.15s-0.94,0.41 -1.51,0.41c-0.86,0 -1.56,-0.3 -2.09,-0.9s-0.8,-1.39 -0.8,-2.37V9.51zM11.49,9.64c0,0.7 0.16,1.27 0.49,1.69s0.76,0.64 1.31,0.64c0.55,0 0.99,-0.22 1.31,-0.65s0.49,-1.04 0.49,-1.81c0,-0.7 -0.17,-1.26 -0.5,-1.69s-0.77,-0.65 -1.32,-0.65c-0.54,0 -0.97,0.21 -1.29,0.64S11.49,8.85 11.49,9.64z"
|
||||||
|
android:fillColor="#FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M18.06,12.74v-5.5h-1V6.4h1V5.75c0,-0.68 0.18,-1.21 0.54,-1.58s0.88,-0.56 1.54,-0.56c0.25,0 0.5,0.03 0.74,0.1l-0.06,0.87c-0.18,-0.04 -0.38,-0.05 -0.59,-0.05c-0.35,0 -0.62,0.1 -0.81,0.31s-0.29,0.5 -0.29,0.88V6.4h1.35v0.84h-1.35v5.5H18.06z"
|
||||||
|
android:fillColor="#FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M22.23,12.74v-5.5h-1V6.4h1V5.75c0,-0.68 0.18,-1.21 0.54,-1.58s0.88,-0.56 1.54,-0.56c0.25,0 0.5,0.03 0.74,0.1L25,4.59c-0.18,-0.04 -0.38,-0.05 -0.59,-0.05c-0.35,0 -0.62,0.1 -0.81,0.31s-0.29,0.5 -0.29,0.88V6.4h1.35v0.84h-1.35v5.5H22.23z"
|
||||||
|
android:fillColor="#FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M27.04,12.74h-1.08v-9h1.08V12.74z"
|
||||||
|
android:fillColor="#FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M28.78,4.72c0,-0.18 0.05,-0.32 0.16,-0.45s0.27,-0.18 0.48,-0.18s0.37,0.06 0.48,0.18s0.16,0.27 0.16,0.45s-0.05,0.32 -0.16,0.44s-0.27,0.18 -0.48,0.18s-0.37,-0.06 -0.48,-0.18S28.78,4.89 28.78,4.72zM29.95,12.74h-1.08V6.4h1.08V12.74z"
|
||||||
|
android:fillColor="#FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M32.71,6.4l0.04,0.8c0.48,-0.61 1.12,-0.91 1.9,-0.91c1.34,0 2.02,0.76 2.03,2.27v4.19h-1.08v-4.2c0,-0.46 -0.11,-0.79 -0.31,-1.01S34.75,7.2 34.32,7.2c-0.35,0 -0.66,0.09 -0.93,0.28s-0.47,0.43 -0.62,0.74v4.52h-1.08V6.4H32.71z"
|
||||||
|
android:fillColor="#FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M40.94,12.85c-0.86,0 -1.56,-0.28 -2.1,-0.85s-0.81,-1.32 -0.81,-2.26v-0.2c0,-0.63 0.12,-1.19 0.36,-1.68s0.58,-0.88 1.01,-1.16s0.9,-0.42 1.4,-0.42c0.82,0 1.46,0.27 1.92,0.81s0.69,1.32 0.69,2.33v0.45h-4.29c0.02,0.63 0.2,1.13 0.55,1.51s0.79,0.58 1.33,0.58c0.38,0 0.71,-0.08 0.97,-0.23s0.5,-0.36 0.7,-0.62l0.66,0.52C42.8,12.45 42,12.85 40.94,12.85zM40.8,7.17c-0.44,0 -0.8,0.16 -1.1,0.48s-0.48,0.76 -0.55,1.34h3.18V8.91c-0.03,-0.55 -0.18,-0.98 -0.45,-1.28S41.26,7.17 40.8,7.17z"
|
||||||
|
android:fillColor="#FFFFFF"/>
|
||||||
|
</vector>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user