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
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
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.
|
||||
- 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
|
||||
|
||||
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.
|
||||
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).
|
||||
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.
|
||||
|
||||
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/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/deviceManager.xml
|
||||
/.idea/gradle.xml
|
||||
/.idea/misc.xml
|
||||
.idea/inspectionProfiles/
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
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 {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.hilt.android)
|
||||
alias(libs.plugins.ksp)
|
||||
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"
|
||||
compileSdk {
|
||||
version = release(36) {
|
||||
@@ -16,12 +35,16 @@ android {
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionName = "3.0.0-pre"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
versionNameSuffix = "_debug_$debugBuildId"
|
||||
}
|
||||
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
@@ -40,22 +63,36 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// AirMQ SDK
|
||||
implementation(project(":sdk"))
|
||||
|
||||
// OSM
|
||||
implementation(libs.osmdroid.android)
|
||||
|
||||
// Android
|
||||
implementation(libs.androidx.core.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(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.analytics)
|
||||
implementation(libs.firebase.crashlytics)
|
||||
implementation(libs.firebase.messaging)
|
||||
implementation(libs.google.maps.compose)
|
||||
implementation(libs.play.services.maps)
|
||||
implementation(libs.apollo.runtime)
|
||||
implementation(libs.hilt.android)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.androidx.credentials)
|
||||
implementation(libs.play.services.location)
|
||||
implementation(libs.kotlinx.coroutines.play.services)
|
||||
implementation(libs.androidx.credentials.play.services.auth)
|
||||
implementation(libs.googleid)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
// Tests
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
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"
|
||||
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
|
||||
android:name=".AirMQApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_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.compose.foundation.layout.fillMaxSize
|
||||
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 javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var cityService: CityService
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
AirMQTheme {
|
||||
AirMqNavGraph(modifier = Modifier.fillMaxSize())
|
||||
CityInitializer(cityService = cityService) {
|
||||
AirMQNavGraph(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,409 @@
|
||||
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 org.db3.airmq.features.common.MockScreenScaffold
|
||||
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.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
|
||||
fun CityScreen(onBackToDashboard: () -> Unit) {
|
||||
MockScreenScaffold(
|
||||
title = "City",
|
||||
subtitle = "Mock city management screen.",
|
||||
actions = listOf(ScreenAction("Back to Dashboard", onBackToDashboard))
|
||||
fun CityScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: CityViewModel = hiltViewModel()
|
||||
) {
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -18,7 +17,8 @@ import androidx.compose.ui.unit.dp
|
||||
|
||||
data class ScreenAction(
|
||||
val label: String,
|
||||
val onClick: () -> Unit
|
||||
val onClick: () -> Unit,
|
||||
val style: AirMQButtonStyle = AirMQButtonStyle.Contained
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -61,9 +61,12 @@ private fun ScreenContent(
|
||||
}
|
||||
content?.invoke()
|
||||
actions.forEach { action ->
|
||||
Button(onClick = action.onClick, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = action.label)
|
||||
}
|
||||
AirMQButton(
|
||||
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
|
||||
|
||||
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.ScreenAction
|
||||
|
||||
@Composable
|
||||
fun ChartConstructorScreen(onBackToWidgetConstructor: () -> Unit) {
|
||||
MockScreenScaffold(
|
||||
title = "Chart Constructor",
|
||||
subtitle = "Mock chart widget constructor variant.",
|
||||
actions = listOf(ScreenAction("Back to Widget Constructor", onBackToWidgetConstructor))
|
||||
title = stringResource(id = R.string.widget_chart_constructor_title),
|
||||
subtitle = stringResource(id = R.string.coming_soon),
|
||||
actions = listOf(ScreenAction(stringResource(id = R.string.back_to_widget_constructor), onBackToWidgetConstructor))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package org.db3.airmq.features.constructor
|
||||
|
||||
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.ScreenAction
|
||||
|
||||
@Composable
|
||||
fun MapConstructorScreen(onBackToWidgetConstructor: () -> Unit) {
|
||||
MockScreenScaffold(
|
||||
title = "Map Constructor",
|
||||
subtitle = "Mock map widget constructor variant.",
|
||||
actions = listOf(ScreenAction("Back to Widget Constructor", onBackToWidgetConstructor))
|
||||
title = stringResource(id = R.string.widget_map_constructor_title),
|
||||
subtitle = stringResource(id = R.string.coming_soon),
|
||||
actions = listOf(ScreenAction(stringResource(id = R.string.back_to_widget_constructor), onBackToWidgetConstructor))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package org.db3.airmq.features.constructor
|
||||
|
||||
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.ScreenAction
|
||||
|
||||
@Composable
|
||||
fun NewsConstructorScreen(onBackToWidgetConstructor: () -> Unit) {
|
||||
MockScreenScaffold(
|
||||
title = "News Constructor",
|
||||
subtitle = "Mock news widget constructor variant.",
|
||||
actions = listOf(ScreenAction("Back to Widget Constructor", onBackToWidgetConstructor))
|
||||
title = stringResource(id = R.string.widget_news_constructor_title),
|
||||
subtitle = stringResource(id = R.string.coming_soon),
|
||||
actions = listOf(ScreenAction(stringResource(id = R.string.back_to_widget_constructor), onBackToWidgetConstructor))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package org.db3.airmq.features.constructor
|
||||
|
||||
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.ScreenAction
|
||||
|
||||
@Composable
|
||||
fun SelectMapWidgetLocationScreen(onDone: () -> Unit) {
|
||||
MockScreenScaffold(
|
||||
title = "Select Map Widget Location",
|
||||
subtitle = "Mock map location picker for widget.",
|
||||
actions = listOf(ScreenAction("Done", onDone))
|
||||
title = stringResource(id = R.string.widget_select_map_location),
|
||||
subtitle = stringResource(id = R.string.coming_soon),
|
||||
actions = listOf(ScreenAction(stringResource(id = R.string.toast_done), onDone))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.db3.airmq.features.constructor
|
||||
|
||||
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.ScreenAction
|
||||
|
||||
@@ -13,14 +15,14 @@ fun WidgetConstructorScreen(
|
||||
onBackToManage: () -> Unit
|
||||
) {
|
||||
MockScreenScaffold(
|
||||
title = "Widget Constructor",
|
||||
subtitle = "Select constructor variant.",
|
||||
title = stringResource(id = R.string.title_widget_constructor),
|
||||
subtitle = stringResource(id = R.string.coming_soon),
|
||||
actions = listOf(
|
||||
ScreenAction("Select Map Widget Location", onOpenSelectMapWidgetLocation),
|
||||
ScreenAction("Open Map Constructor", onOpenMapConstructor),
|
||||
ScreenAction("Open Chart Constructor", onOpenChartConstructor),
|
||||
ScreenAction("Open News Constructor", onOpenNewsConstructor),
|
||||
ScreenAction("Back to Manage", onBackToManage)
|
||||
ScreenAction(stringResource(id = R.string.widget_select_map_location), onOpenSelectMapWidgetLocation),
|
||||
ScreenAction(stringResource(id = R.string.widget_open_map_constructor), onOpenMapConstructor),
|
||||
ScreenAction(stringResource(id = R.string.widget_open_chart_constructor), onOpenChartConstructor),
|
||||
ScreenAction(stringResource(id = R.string.widget_open_news_constructor), onOpenNewsConstructor),
|
||||
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
|
||||
|
||||
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 org.db3.airmq.features.common.MockScreenScaffold
|
||||
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.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
|
||||
fun DashboardScreen(
|
||||
onOpenMap: () -> Unit,
|
||||
onOpenManage: () -> Unit,
|
||||
onOpenCity: () -> Unit,
|
||||
onOpenDevice: () -> Unit,
|
||||
onOpenNews: () -> Unit,
|
||||
onOpenWidgetConstructor: () -> Unit
|
||||
onOpenWidgetConstructor: () -> Unit,
|
||||
viewModel: DashboardViewModel = hiltViewModel()
|
||||
) {
|
||||
MockScreenScaffold(
|
||||
title = "Dashboard",
|
||||
subtitle = "Bottom-tab equivalent: dashboard",
|
||||
actions = listOf(
|
||||
ScreenAction("Open Map", onOpenMap),
|
||||
ScreenAction("Open Manage", onOpenManage),
|
||||
ScreenAction("Open City", onOpenCity),
|
||||
ScreenAction("Open Device", onOpenDevice),
|
||||
ScreenAction("Open News", onOpenNews),
|
||||
ScreenAction("Open Widget Constructor", onOpenWidgetConstructor)
|
||||
)
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.actions.collect { action ->
|
||||
when (action) {
|
||||
DashboardScreenContract.Action.OpenCity -> onOpenCity()
|
||||
DashboardScreenContract.Action.OpenNews -> { /* handled elsewhere */ }
|
||||
DashboardScreenContract.Action.OpenWidgetConstructor -> { /* handled elsewhere */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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.ScreenAction
|
||||
|
||||
@Composable
|
||||
fun DebugScreen(onBackToSettings: () -> Unit) {
|
||||
MockScreenScaffold(
|
||||
title = "Debug",
|
||||
subtitle = "Debug-only tools placeholder.",
|
||||
actions = listOf(ScreenAction("Back to Settings", onBackToSettings))
|
||||
title = stringResource(id = R.string.title_debug),
|
||||
subtitle = stringResource(id = R.string.coming_soon),
|
||||
actions = listOf(ScreenAction(stringResource(id = R.string.back_to_settings), onBackToSettings))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.db3.airmq.features.device
|
||||
|
||||
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.ScreenAction
|
||||
|
||||
@@ -11,11 +13,11 @@ fun DeviceScreen(
|
||||
onShowOnMap: () -> Unit
|
||||
) {
|
||||
MockScreenScaffold(
|
||||
title = "Device",
|
||||
subtitle = "Mock deviceId: $deviceId",
|
||||
title = stringResource(id = R.string.title_device),
|
||||
subtitle = stringResource(id = R.string.coming_soon),
|
||||
actions = listOf(
|
||||
ScreenAction("Select Location", onOpenLocation),
|
||||
ScreenAction("Show on Map", onShowOnMap)
|
||||
ScreenAction(stringResource(id = R.string.title_location), onOpenLocation),
|
||||
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
|
||||
|
||||
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.ScreenAction
|
||||
|
||||
@Composable
|
||||
fun SplashScreen(onContinue: () -> Unit) {
|
||||
MockScreenScaffold(
|
||||
title = "Splash",
|
||||
subtitle = "Entry flow starting point.",
|
||||
actions = listOf(ScreenAction(label = "Continue to Wizard", onClick = onContinue))
|
||||
title = stringResource(id = R.string.screen_splash_title),
|
||||
subtitle = stringResource(id = R.string.coming_soon),
|
||||
actions = listOf(ScreenAction(label = stringResource(id = R.string.screen_continue_to_wizard), onClick = onContinue))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package org.db3.airmq.features.entry
|
||||
|
||||
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.ScreenAction
|
||||
|
||||
@Composable
|
||||
fun WizardScreen(onFinish: () -> Unit) {
|
||||
MockScreenScaffold(
|
||||
title = "Wizard",
|
||||
subtitle = "Mock onboarding/wizard flow.",
|
||||
actions = listOf(ScreenAction(label = "Finish Wizard", onClick = onFinish))
|
||||
title = stringResource(id = R.string.screen_wizard_title),
|
||||
subtitle = stringResource(id = R.string.coming_soon),
|
||||
actions = listOf(ScreenAction(label = stringResource(id = R.string.screen_finish_wizard), onClick = onFinish))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package org.db3.airmq.features.location
|
||||
|
||||
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.ScreenAction
|
||||
|
||||
@Composable
|
||||
fun LocationScreen(onBackToManage: () -> Unit) {
|
||||
MockScreenScaffold(
|
||||
title = "Location",
|
||||
subtitle = "Mock location picker/editor screen.",
|
||||
actions = listOf(ScreenAction("Back to Manage", onBackToManage))
|
||||
title = stringResource(id = R.string.title_location),
|
||||
subtitle = stringResource(id = R.string.coming_soon),
|
||||
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
|
||||
|
||||
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 org.db3.airmq.features.common.MockScreenScaffold
|
||||
import org.db3.airmq.features.common.ScreenAction
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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
|
||||
fun LoginScreen(onLogInToManage: () -> Unit) {
|
||||
MockScreenScaffold(
|
||||
title = "Login",
|
||||
subtitle = "Mock account sign-in screen.",
|
||||
actions = listOf(ScreenAction("Log In to Manage", onLogInToManage))
|
||||
fun LoginScreen(
|
||||
onLogInToManage: () -> Unit,
|
||||
onOpenEmailLogin: () -> Unit,
|
||||
viewModel: LoginViewModel = hiltViewModel()
|
||||
) {
|
||||
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
|
||||
|
||||
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 org.db3.airmq.features.common.MockScreenScaffold
|
||||
import org.db3.airmq.features.common.ScreenAction
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
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
|
||||
fun ManageScreen(
|
||||
onOpenDevice: () -> Unit,
|
||||
onOpenDevice: (String) -> Unit,
|
||||
onOpenSetup: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenLogin: () -> Unit,
|
||||
onOpenLocation: () -> Unit,
|
||||
onOpenWidgetConstructor: () -> Unit,
|
||||
onBackToDashboard: () -> Unit
|
||||
onOpenAddLocation: () -> Unit,
|
||||
viewModel: ManageViewModel = hiltViewModel()
|
||||
) {
|
||||
MockScreenScaffold(
|
||||
title = "Manage",
|
||||
subtitle = "Bottom-tab equivalent: manage",
|
||||
actions = listOf(
|
||||
ScreenAction("Open Device", onOpenDevice),
|
||||
ScreenAction("Start Setup", onOpenSetup),
|
||||
ScreenAction("Open Settings", onOpenSettings),
|
||||
ScreenAction("Open Login", onOpenLogin),
|
||||
ScreenAction("Select Location", onOpenLocation),
|
||||
ScreenAction("Open Widget Constructor", onOpenWidgetConstructor),
|
||||
ScreenAction("Back to Dashboard", onBackToDashboard)
|
||||
)
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner, viewModel) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
viewModel.refreshAuthState()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
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
|
||||
|
||||
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 org.db3.airmq.features.common.MockScreenScaffold
|
||||
import org.db3.airmq.features.common.ScreenAction
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
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
|
||||
fun MapScreen(
|
||||
onOpenDevice: () -> Unit,
|
||||
onBackToDashboard: () -> Unit
|
||||
viewModel: MapViewModel = hiltViewModel()
|
||||
) {
|
||||
MockScreenScaffold(
|
||||
title = "Map",
|
||||
subtitle = "Bottom-tab equivalent: map",
|
||||
actions = listOf(
|
||||
ScreenAction("Open Device", onOpenDevice),
|
||||
ScreenAction("Back to Dashboard", onBackToDashboard)
|
||||
)
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.actions.collectLatest { action ->
|
||||
when (action) {
|
||||
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 SETUP = "detail/setup"
|
||||
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 = "detail/news/{newsId}"
|
||||
const val DEVICE = "detail/device/{deviceId}"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package org.db3.airmq.features.news
|
||||
|
||||
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.ScreenAction
|
||||
|
||||
@Composable
|
||||
fun NewsDetailScreen(newsId: String, onBackToNews: () -> Unit) {
|
||||
MockScreenScaffold(
|
||||
title = "News Detail",
|
||||
subtitle = "Mock newsId: $newsId",
|
||||
actions = listOf(ScreenAction("Back to News", onBackToNews))
|
||||
title = stringResource(id = R.string.news_detail_title),
|
||||
subtitle = stringResource(id = R.string.coming_soon),
|
||||
actions = listOf(ScreenAction(stringResource(id = R.string.back_to_news), onBackToNews))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.db3.airmq.features.news
|
||||
|
||||
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.ScreenAction
|
||||
|
||||
@@ -10,11 +12,11 @@ fun NewsScreen(
|
||||
onBackToDashboard: () -> Unit
|
||||
) {
|
||||
MockScreenScaffold(
|
||||
title = "News",
|
||||
subtitle = "Mock news list screen.",
|
||||
title = stringResource(id = R.string.text_widget_news),
|
||||
subtitle = stringResource(id = R.string.coming_soon),
|
||||
actions = listOf(
|
||||
ScreenAction("Open News Detail", onOpenNewsDetail),
|
||||
ScreenAction("Back to Dashboard", onBackToDashboard)
|
||||
ScreenAction(stringResource(id = R.string.news_open_detail), onOpenNewsDetail),
|
||||
ScreenAction(stringResource(id = R.string.back_to_dashboard), onBackToDashboard)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,307 @@
|
||||
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 org.db3.airmq.features.common.MockScreenScaffold
|
||||
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.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
|
||||
fun SettingsScreen(
|
||||
onOpenDebug: () -> Unit,
|
||||
onOpenCity: () -> Unit,
|
||||
onLogOutToManage: () -> Unit
|
||||
onLogOutToManage: () -> Unit,
|
||||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
MockScreenScaffold(
|
||||
title = "Settings",
|
||||
subtitle = "Settings and account actions.",
|
||||
actions = listOf(
|
||||
ScreenAction("Open Debug", onOpenDebug),
|
||||
ScreenAction("Open City", onOpenCity),
|
||||
ScreenAction("Log Out to Manage", onLogOutToManage)
|
||||
)
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.actions.collectLatest { action ->
|
||||
when (action) {
|
||||
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
|
||||
|
||||
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.ScreenAction
|
||||
|
||||
@@ -10,11 +12,11 @@ fun SetupScreen(
|
||||
onCancelSetup: () -> Unit
|
||||
) {
|
||||
MockScreenScaffold(
|
||||
title = "Setup",
|
||||
subtitle = "Mock setup flow for device onboarding.",
|
||||
title = stringResource(id = R.string.button_setup),
|
||||
subtitle = stringResource(id = R.string.coming_soon),
|
||||
actions = listOf(
|
||||
ScreenAction("Finish Setup", onFinishSetup),
|
||||
ScreenAction("Cancel Setup", onCancelSetup)
|
||||
ScreenAction(stringResource(id = R.string.button_finish), onFinishSetup),
|
||||
ScreenAction(stringResource(id = R.string.button_cancel), onCancelSetup)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,60 @@ package org.db3.airmq.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
val LegacyPrimary = Color(0xFF295989)
|
||||
val LegacyPrimaryDark = Color(0xFF0069C0)
|
||||
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)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
/** Dashboard city selector pill (legacy vectors city_left / city_right / city_middle) */
|
||||
val DashboardCityChipGradientStart = Color(0xFF3EDEA5)
|
||||
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
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
primary = LegacyPrimary,
|
||||
onPrimary = LegacyOnPrimary,
|
||||
secondary = LegacyAccent,
|
||||
onSecondary = LegacyOnSecondary,
|
||||
tertiary = LegacyPrimaryDark,
|
||||
background = LegacyBackground,
|
||||
onBackground = LegacyOnBackground,
|
||||
surface = LegacySurface,
|
||||
onSurface = LegacyOnSurface,
|
||||
outline = LegacyOutline,
|
||||
onSurfaceVariant = LegacyNavUnselected
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
primary = LegacyPrimary,
|
||||
onPrimary = LegacyOnPrimary,
|
||||
secondary = LegacyAccent,
|
||||
onSecondary = LegacyOnSecondary,
|
||||
tertiary = LegacyPrimaryDark,
|
||||
background = LegacyBackground,
|
||||
onBackground = LegacyOnBackground,
|
||||
surface = LegacySurface,
|
||||
onSurface = LegacyOnSurface,
|
||||
outline = LegacyOutline,
|
||||
onSurfaceVariant = LegacyNavUnselected
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AirMQTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
darkTheme: Boolean = false,
|
||||
dynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
dynamicColor -> LightColorScheme
|
||||
darkTheme -> DarkColorScheme
|
||||
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