Compare commits

...

61 Commits

Author SHA1 Message Date
34ad7e4af7 feat(sdk): AuthGoogleApp mutation and Google ID token auth without Firebase
Made-with: Cursor
2026-04-06 22:48:37 +02:00
9165d26b72 refactor(auth): token store, Apollo auth, and email UI
Update ApiTokenStore, AuthServiceImpl, and interceptor; adjust login/register screens, dashboard, chart, and Firebase auth tests.

Made-with: Cursor
2026-04-06 22:20:13 +02:00
d34b3bf70e feat(map): load device time series for map panel chart
Add LocationTimeSeries GraphQL query and DeviceTimeSeriesRepository.

Extend DevicePanelState with chart data/loading/offset; MapViewModel fetches via location _id and maps with DashboardChartMapper.

Wire MapScreen preview with sample chart data.

Made-with: Cursor
2026-04-06 22:20:04 +02:00
9cbc521a0d fix(city): persist full city list and re-emit dashboard context on re-selection
- Map cityList rows without coordinates; fall back en/ru/be for display names
- Replace local city cache on sync when opening city screen
- Use SharedFlow + prefs reads so re-selecting the same city (e.g. Minsk) notifies dashboard

Made-with: Cursor
2026-04-06 22:19:42 +02:00
3057d9c2d4 fix(map): clip chart sensor selector ripple to pill shape
Made-with: Cursor
2026-04-06 22:13:27 +02:00
ac334db940 Harden Google sign-in when backend auth fails
Sign out of Firebase and clear API and local profile if the GraphQL exchange or token save fails after Firebase Google sign-in, so the user is not left in a half-authenticated state. Rename the exchange helper to exchangeFirebaseIdTokenWithBackend for clarity.

Login: ignore duplicate Google taps while loading and disable the Google button during the flow. Credential Manager: treat GetCredentialInterruptedException as cancellation and rethrow CancellationException so coroutines cancel correctly.

Tests: assert signOut is invoked when the backend exchange fails.
Made-with: Cursor
2026-04-06 20:50:20 +02:00
200ce74cb5 Add email registration screen with AuthService integration
Introduce EmailRegisterScreen (Compose) with contract, validation, and
loading UX matching the email sign-in layout. Register submits via
AuthService.registerWithEmail; success navigates to Manage and clears
the login stack.

Email login Register action opens the new route instead of handling
registration on the same screen.

Made-with: Cursor
2026-04-06 19:55:32 +02:00
9869ad2476 feat(auth): email sign-in and register via GraphQL
Add loginLocal and register Apollo mutations, LocalEmailAuthStore for profile snapshot when not using Firebase, extend AuthService/FirebaseAuthService with session handoff vs Google, wire EmailLoginViewModel, refresh Manage on resume, and expand unit tests.

Made-with: Cursor
2026-04-06 19:30:31 +02:00
df4e6f9c56 feat(dashboard): city average GraphQL, metrics repository, and wiring
Adds CityAverage operations and DashboardMetricsRepository, extends CityService, updates dashboard contract/VM and chart mapping, refreshes schema, tweaks map screen and Apollo logging, and adds debug manifest.

Made-with: Cursor
2026-04-06 17:02:19 +02:00
35d23110d7 feat(map): add OSMDroid heatmap with legacy dust tiers
Port legacy Google Maps heatmap behavior: PM2.5-style buckets, tier colors, halos under markers. Draw halos in Overlay.draw with live projection so heat tracks during pan (no debounced bitmap). Radioactivity uses single green tier to match marker styling.

Made-with: Cursor
2026-03-24 00:21:00 +01:00
fc034ad520 fix(chart): clear tap marker when dataset changes
LaunchedEffect(data) resets marker state so stale selection is not shown after refresh or sensor switch.

Made-with: Cursor
2026-03-24 00:05:40 +01:00
24c5731c69 Add OSMDroid map marker clustering with gentler merge thresholds.
Group nearby markers into count bubbles; cluster tap zooms to member bounds.
Rebuild overlays on a debounced map listener so clusters track pan and zoom.
Add cluster bubble layout, strings, and pixel-distance clustering with a 48dp
base threshold scaled down when zoomed in so clustering stays less aggressive.

Made-with: Cursor
2026-03-24 00:01:16 +01:00
c05dd31e95 fix(dashboard): city chip as gradient-outline capsule
- Replace three vector segments with transparent pill + horizontal gradient border

- Size chip to city label, centered; add theme colors; remove unused drawables

Made-with: Cursor
2026-03-23 23:16:49 +01:00
49b9ab5617 fix(map): hide OSMDroid zoom buttons and lower location FAB padding
Made-with: Cursor
2026-03-23 22:59:29 +01:00
49318e87a5 fix(map): center osmdroid marker anchor on coordinates
Made-with: Cursor
2026-03-23 22:56:05 +01:00
ca3804195d chore(sdk): point GraphQL API to apitest.tartak.by
Made-with: Cursor
2026-03-23 22:53:08 +01:00
c29ba88fec feat: add city selector frame assets 2026-03-16 16:49:09 +01:00
fee67c35af feat: add AQI/RAD help dialog on map screen 2026-03-16 16:45:48 +01:00
3c41b0d487 feat: integrate city selection into dashboard, nav and settings 2026-03-16 16:45:33 +01:00
11a515b588 feat: implement city selection flow with auto-detect 2026-03-16 16:45:13 +01:00
88ebc14d24 feat: add location dependencies and permissions 2026-03-16 16:44:58 +01:00
0519936531 feat(device): add visibility/luftdata/narodmon settings, device model icons, SDK refactor
- Add SetDeviceVisibilityUseCase, SetLuftdataUseCase, SetNarodmonUseCase

- Introduce DeviceModel enum (Basic, Mobile, Solar, Radiation, Custom)

- Add device-type drawables (active/inactive icons for each model)

- Refactor Device SDK: repository, DAO, database, local/remote data sources

- Update DeviceSettingsScreen, ManageViewModel, theme colors

- Add/update string resources (default, ru, be)

Made-with: Cursor
2026-03-06 20:01:24 +01:00
7815f151f1 chore: ignore IDE-specific .idea files and stop tracking them
Made-with: Cursor
2026-03-05 22:05:36 +01:00
ce3bdd3d72 feat(map): device panel contract, view model, arrow and close icons
Made-with: Cursor
2026-03-05 21:38:07 +01:00
c2eb2df8c0 feat(map): dark status bar on map screen, white on dashboard/manage; faster map animations
Made-with: Cursor
2026-03-05 21:36:02 +01:00
863961405d fix(map): chart fill padding and dropdown selected border
- AirMQChart: add bottom padding so fill extends below line and thick line stays in bounds
- DeviceSensorDropdown: show border on selected item when dropdown is opened

Made-with: Cursor
2026-03-04 22:41:26 +01:00
e29e6ef498 feat(map): dropdown overlay above chart, sensor-based chart colors, supported sensors
Made-with: Cursor
2026-03-04 22:25:21 +01:00
8c54921661 feat(dashboard): add DashboardViewModel, metric gauge pager, chart integration
- Add DashboardScreenContract and DashboardViewModel with dummy data
- Extend MetricGauge with pager, page indicators, sensor config
- Integrate AirMQChart and city selector
- ManageScreen: scaffold contentWindowInsets for edge-to-edge header
- Add compose-foundation dependency for HorizontalPager

Made-with: Cursor
2026-03-04 20:28:53 +01:00
c9c7cedd55 fix(ui): status bar layout and icons for Map, Dashboard, Manage
- Dashboard: apply background before statusBarsPadding so gradient extends under status bar
- Map: add statusBarsPadding to layer selector so it no longer overlaps status bar
- NavGraph: set white status bar icons (isAppearanceLightStatusBars=false) on main tab screens

Made-with: Cursor
2026-03-04 20:25:06 +01:00
e59e5aa060 feat(device): implement Device feature with SSOT, offline support, and settings screen
- Add domain layer: Device, PendingMutation, DeviceRepository
- Add Room DB: DeviceEntity, PendingMutationEntity, DAOs, DeviceLocalDataSource
- Add mock DeviceRemoteDataSource and DeviceSubscriptionManager
- Implement DeviceRepositoryImpl with optimistic updates and mutation queue
- Add UseCases: GetMyDevices, Rename, SetLocation, SetDataSharing, TriggerFirmware
- Implement DeviceSettingsScreen with rename, location, data sharing, firmware
- Wire ManageScreen to GetMyDevicesUseCase and DeviceSubscriptionManager
- Update navigation to pass deviceId and show DeviceSettingsScreen
- Add Room 2.7.0-alpha11 and Room dependencies to SDK

Made-with: Cursor
2026-03-04 19:35:07 +01:00
ca5cf8c439 feat(metric): reimplement RoundedDiagram/RingDiagram in Compose
- Add MetricGauge, RingGauge, MetricGaugeRow composables
- Add MetricGaugeContract with SensorType enum and AQI color mapping
- Add AQI colors (SensorGreen, SensorYellow, etc.) to theme
- Copy ic_temperature, ic_humidity, ic_pressure drawables from legacy
- Integrate MetricGaugeRow into DashboardScreen
- Add 9 preview composables for portfolio readiness

Made-with: Cursor
2026-03-04 19:34:59 +01:00
00ad737e7e feat(chart): implement AirMQChart component with legacy design
- Add AirMQChart Composable with 4-segment background, rounded corners, fake piece
- Support single-line and multiline datasets with cubic Bezier curves
- Add value labels (max, mid, min) on left, last value on right
- Add bottom row with time labels and center label (e.g. sensor name)
- Include touch marker, empty state (threshold 3 points), multiline preview
- Add ChartConfig, ChartData, ChartDataGenerator, ChartUtils
- Add chart/sensor colors to Color.kt
- Integrate test chart in DashboardScreen, use in MapUiComponents device panel

Made-with: Cursor
2026-03-04 19:18:03 +01:00
b607d0198b feat(manage): rework Manage screen layout and device list
- Match device item design to reference (ic_chip, status chips, trailing icon)
- Add header (Your devices) and footer to device list
- CTA at bottom, centered 46% width, same style for Sign in/Add device
- Window background #FAFAFA, LegacyBackground on list area
- No spacing between device items, no horizontal margin on list
- Add DeviceItem.extra, ic_go_to_location drawable
- Replace personal data with User/user@example.com
- Strengthen app-recreation-core rule: path check before edits
- Restore real auth logic in ManageViewModel

Made-with: Cursor
2026-03-02 21:51:50 +01:00
9a80ce5dff feat(login): replace Facebook with email-password auth screen
- Remove Facebook provider from Login flow
- Add EmailLoginScreen with gradient background, email/password fields
- Add EmailLoginScreenContract and EmailLoginViewModel with stub logic
- Add navigation: Sign in with email -> EmailLoginScreen
- Use back arrow icon instead of back text
- Move header above email field, add Register button
- Update run command to launch app after install
- Add ic_arrow_back drawable, update strings

Made-with: Cursor
2026-03-02 20:38:09 +01:00
f4b6df10ac Fix Manage screen header size 2026-03-02 20:23:38 +01:00
c96a433307 Add new Cursor commands 2026-03-02 20:23:02 +01:00
31f723cbd6 Implement Google->Firebase->Backend auth flow
- Add authGoogleNew GraphQL mutation and token exchange in AuthServiceImpl
- Add ApiTokenStore and SharedPreferencesApiTokenStore for API token persistence
- Add ApolloAuthInterceptor to inject Bearer token on GraphQL requests
- Introduce FirebaseSessionManager for testable Firebase auth orchestration
- Update LoginViewModel to surface backend auth errors
- Add unit tests for Firebase failure, backend failure, and auth state

Made-with: Cursor
2026-03-02 20:19:54 +01:00
436e165679 Point SDK GraphQL to api-app and switch map markers query to getMarkers.
This aligns the app with the new backend endpoint/schema and keeps map marker mapping compatible with the new response shape.

Made-with: Cursor
2026-03-02 19:22:03 +01:00
8bf076697e Refine social auth UI and simplify manage auth state.
Extract Google sign-in into a dedicated manager with explicit cancel handling, update shared social button variants, and switch manage UI state from enum modes to a direct authorization flag.

Made-with: Cursor
2026-03-02 03:16:46 +01:00
28ad63fb4a Wire Firebase auth state into settings/logout and refresh Google sign-in config.
This updates the settings account section to show/logout based on authenticated user state, refines auth service naming, and aligns app signing/Firebase config changes needed for successful Google authentication.

Made-with: Cursor
2026-03-01 21:40:58 +01:00
91a9521f3e Implement Firebase Google sign-in with enum-based auth contracts.
Refactor AuthService to use AuthProvider and User, add Firebase-backed auth wiring for login/manage flows, and fix app-level Google services configuration so Credential Manager sign-in works reliably.

Made-with: Cursor
2026-03-01 21:15:09 +01:00
90792c601c Recreate legacy login screen in Compose.
Port the old sign-in UI and behavior (gradient, branded social buttons, policy footer, and continue-anonymous dialog) and add Login contract/ViewModel stubs while keeping unauthorized Manage-to-Login navigation intact.

Made-with: Cursor
2026-03-01 19:34:29 +01:00
7c00163304 Refine Compose manage UI parity and button behavior.
Apply legacy-accurate manage header/CTA styling, add reusable button icon support with previews, and include related map/navigation polish updates in this working tree.

Made-with: Cursor
2026-03-01 19:02:13 +01:00
9885162c4e Add firebase project 2026-03-01 18:39:56 +01:00
a2cbb181d5 Migrate legacy localized strings and remove runtime hardcoded UI text.
This copies old locale resources into the 2026 app, reuses legacy string keys where possible, and moves user-facing runtime text to string resources with missing entries added for all supported locales.

Made-with: Cursor
2026-03-01 15:07:26 +01:00
1823d0bf1b Port manage/settings flows to Compose and wire settings persistence.
Add contract-driven state/action/event view models for manage and settings, migrate settings UI toward legacy preference rows (with anonymous stub behavior), and back SettingsServiceImpl with SharedPreferences for real toggle/city storage.

Made-with: Cursor
2026-03-01 15:00:34 +01:00
c155a3cc2e Expose per-setting SettingsService getters/setters and wire map offline visibility to GraphQL.
This moves offline filtering to the API query path so map data respects settings at source instead of client-side post-filtering.

Made-with: Cursor
2026-03-01 00:28:43 +01:00
920a832424 Implement legacy-style map markers with UI model mapping.
Replace default OSM pins with round value-based icons, add DTO-to-domain-to-UI marker mapping, and normalize no-value/offline styling while keeping ownership icon behavior stubbed for future auth integration.

Made-with: Cursor
2026-03-01 00:19:40 +01:00
02c33e5ad5 Update map marker mapping for locations query.
Switch MapMarkers handling from getMarkers to locations, and align mapper fields with the new Location payload while preserving MapItem coordinates and online status.

Made-with: Cursor
2026-02-28 23:09:09 +01:00
4caadd24b9 Set pre-release app version and append a git-based debug suffix.
Made-with: Cursor
2026-02-28 23:03:54 +01:00
10613a36f8 Align map sensor selector with legacy dropdown UI.
Restore the old map sensor control styling and interaction in Compose, and add previews for collapsed/expanded, search, and device panel states to speed up UI iteration.

Made-with: Cursor
2026-02-28 22:56:33 +01:00
18a652a789 Replace launcher icons with old project mipmap assets.
Remove current launcher XML/webp assets and copy the full mipmap icon set from the legacy repo so app icon matches the original branding.

Made-with: Cursor
2026-02-28 22:54:58 +01:00
81627d6b7c Add Apollo-level logging and Kotlin-driven map filter.
Pass the map marker online filter as a GraphQL variable from Kotlin so offline-inclusive behavior is controllable in code, and add a shared Apollo interceptor to log each request payload and response outcome for all operations.

Made-with: Cursor
2026-02-28 22:44:27 +01:00
5cfd32639b Add cursor rule 2026-02-28 22:25:44 +01:00
726e143405 Map: remove MapUiState, add search/my-location drawables and strings
Made-with: Cursor
2026-02-28 22:25:32 +01:00
c4626ca40c Refactor map screen panel state to nullable contracts.
This replaces visibility booleans and error state fields with nullable `searchPanelState`/`devicePanelState` and action-based error toasts for one-shot UI effects.

Made-with: Cursor
2026-02-28 22:24:44 +01:00
117caa9122 Add SDK service contracts for core infrastructure.
Introduce interface-only service APIs and feature models for auth, device, settings, notifications, connectivity, and telemetry to scaffold implementation without backend-specific naming.

Made-with: Cursor
2026-02-28 16:58:55 +01:00
6dedaf0e8b Add SDK map module and migrate map fetching to Apollo with Hilt wiring.
This includes the new sdk module, MapService + mapper implementation, Apollo provider module, Hilt app integration, and the project .gitignore update.

Made-with: Cursor
2026-02-28 16:58:22 +01:00
54092f184f Use the legacy app icon assets for the launcher.
Replace the default adaptive icon visuals with the old AirMQ logo and blue gradient background to match the original project branding.

Made-with: Cursor
2026-02-28 16:24:46 +01:00
9160edda33 Restore the legacy bottom navigation gradient styling.
Apply the old blue gradient treatment to the Compose bottom bar so it visually matches the previous project implementation.

Made-with: Cursor
2026-02-28 16:20:13 +01:00
efcd140966 Add map data flow and port legacy app theming.
This bundles the GraphQL-backed map scaffolding with the old AirMQ visual style by applying legacy colors, reusable button styles, and original bottom navigation icons for UI parity.

Made-with: Cursor
2026-02-28 15:52:55 +01:00
266 changed files with 16716 additions and 635 deletions

View 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
View 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.

View File

@@ -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.

View 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
View File

@@ -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
View File

@@ -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
View 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
View File

@@ -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>

View File

@@ -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
View 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"
}

View File

@@ -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)
}
}

View 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>

View File

@@ -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"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
package org.db3.airmq
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class AirMQApplication : Application()

View File

@@ -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())
}
}
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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
}
}

View 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))
)
}
}
}
}

View File

@@ -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()
)
}
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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>)

View File

@@ -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)
)
}

View File

@@ -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()
}

View File

@@ -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 = {}
)
}
}
}

View File

@@ -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
}

View File

@@ -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 0100, 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
)
}
}

View File

@@ -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))
)
}

View File

@@ -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))
)
}

View File

@@ -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))
)
}

View File

@@ -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))
)
}

View File

@@ -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)
)
)
}

View File

@@ -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,
)
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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
}
}

View File

@@ -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
}
)
}

View File

@@ -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))
)
}

View File

@@ -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)
)
)
}

View File

@@ -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
)
}
}

View File

@@ -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
}
}

View File

@@ -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"
)
)
}
}
}
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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))
)
}

View File

@@ -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))
)
}

View File

@@ -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))
)
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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.")
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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
}
}

View File

@@ -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)
)
)
}
}
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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
}
}

View File

@@ -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()
)
}
}

View File

@@ -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 }
}

View 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
)

View File

@@ -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]++
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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
}
}

View File

@@ -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 = {}
)
}
}

View 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
}
}

View File

@@ -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
)

View File

@@ -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() }
)
}
}
}

View File

@@ -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}"

View File

@@ -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))
)
}

View File

@@ -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)
)
)
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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
}
}

View File

@@ -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)
)
)
}
}
}
}

View File

@@ -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)
)
)
}

View File

@@ -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)

View File

@@ -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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View 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>

View 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>

View 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>

View 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