Initial commit

This commit is contained in:
2026-08-22 14:24:05 +03:00 Unverified
commit 267079285f
63 changed files with 3092 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
.DS_Store
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.aab
*.apk
output-metadata.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof
+1
View File
@@ -0,0 +1 @@
/build
+87
View File
@@ -0,0 +1,87 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.jetbrains.kotlin.serialization)
alias(libs.plugins.kotlin.ksp)
}
android {
namespace = "ru.pirogov.grapehealthchecker"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
defaultConfig {
applicationId = "ru.pirogov.grapehealthchecker"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
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.navigation3.ui)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.material3.adaptive.navigation3)
implementation(libs.kotlinx.serialization.core)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.compose.ui.test)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
implementation(libs.play.services.location)
ksp(libs.androidx.room.compiler)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.onnxruntime.android)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.accompanist.permissions)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
// androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,24 @@
package ru.pirogov.grapehealthchecker
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("ru.pirogov.grapehealthchecker", appContext.packageName)
}
}
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.camera.any" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:name=".GrapeHealthChecker"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:largeHeap="true"
android:theme="@style/Theme.GrapeHealthChecker">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.GrapeHealthChecker">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Binary file not shown.
@@ -0,0 +1,47 @@
package ru.pirogov.grapehealthchecker
import android.content.Context
import androidx.room.Room
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import ru.pirogov.grapehealthchecker.data.AppDatabase
import ru.pirogov.grapehealthchecker.data.AppRepository
import ru.pirogov.grapehealthchecker.data.UserPreferencesRepository
import ru.pirogov.grapehealthchecker.data.WeatherRepository
import ru.pirogov.grapehealthchecker.utils.OnnxClassifier
class AppContainer(private val context: Context) {
// 1. Local Room DB Initialization
private val database: AppDatabase by lazy {
Room.databaseBuilder(context, AppDatabase::class.java, "photo_app.db").build()
}
// 2. HTTP Engine setup
private val httpClient by lazy {
HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
}
// 3. Exposed Single Source Repositories
val appRepository: AppRepository by lazy {
AppRepository(database.appDao)
}
val weatherRepository: WeatherRepository by lazy {
WeatherRepository(database.appDao, httpClient)
}
val onnxClassifier: OnnxClassifier by lazy {
OnnxClassifier(context)
}
val userPreferencesRepository: UserPreferencesRepository by lazy {
UserPreferencesRepository(context)
}
}
@@ -0,0 +1,13 @@
package ru.pirogov.grapehealthchecker
import android.app.Application
class GrapeHealthChecker : Application() {
// Single instance accessed across the layout manually
lateinit var container: AppContainer
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
}
@@ -0,0 +1,38 @@
package ru.pirogov.grapehealthchecker
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import ru.pirogov.grapehealthchecker.ui.theme.GrapeHealthCheckerTheme
import ru.pirogov.grapehealthchecker.views.MainHostScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appContainer = (application as GrapeHealthChecker).container
val settingsRepository = appContainer.userPreferencesRepository
// 1. Fetch the first value synchronously at startup
val openOnHistory = runBlocking {
settingsRepository.openOnHistory.first()
}
enableEdgeToEdge()
setContent {
GrapeHealthCheckerTheme {
MainHostScreen(openOnHistory)
}
}
}
}
@@ -0,0 +1,155 @@
package ru.pirogov.grapehealthchecker.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import ru.pirogov.grapehealthchecker.data.remote.OpenMeteoResponse
import ru.pirogov.grapehealthchecker.data.remote.WeatherCodeMapper
import java.util.UUID
private val Context.dataStore by preferencesDataStore(name = "user_settings")
class AppRepository(private val appDao: AppDao) {
fun getDetail(detailId: String): Flow<FullDetailRecord?> {
return appDao.getFullDetailById(detailId)
}
fun getAllDetails(): Flow<List<FullDetailRecord>> {
return appDao.getAllFullDetailRecords()
}
suspend fun saveDetailAndWeather(detail: DetailEntity, weather: WeatherEntity, photo: PhotoEntity) {
appDao.insertWeather(weather)
appDao.insertDetail(detail)
appDao.insertPhoto(photo)
}
suspend fun deleteDetails(ids: List<String>) {
appDao.deleteDetailsByIds(ids)
}
}
class WeatherRepository(
private val appDao: AppDao,
private val httpClient: HttpClient
) {
// Cache reference in memory to check the 1-hour window quickly
private var lastSavedWeather: WeatherEntity? = null
private var lastLocation: Pair<Double, Double>? = null
suspend fun getLatestCachedOrFetchWeather(lat: Double, lon: Double): WeatherEntity {
val currentTimeMs = System.currentTimeMillis()
val oneHourInMs = 60 * 60 * 1000
// 1. Check if we have a valid cache item less than 1 hour old
// AND from the same approximate location (optional, but good practice)
lastSavedWeather?.let { cached ->
lastLocation?.let { (cachedLat, cachedLon) ->
if (currentTimeMs - cached.lastUpdated < oneHourInMs &&
isSameLocation(cachedLat, cachedLon, lat, lon)) {
return cached
}
}
}
// 2. Cache expired, different location, or missing -> Fetch from Open-Meteo API
return try {
// Build URL with coordinates
val url = "https://api.open-meteo.com/v1/forecast?" +
"latitude=$lat&longitude=$lon" +
"&current=temperature_2m,wind_speed_10m,relative_humidity_2m,weather_code,precipitation_probability"
val response: OpenMeteoResponse = httpClient.get(url).body()
val newWeather = WeatherEntity(
weatherId = "${lat}_${lon}_${System.currentTimeMillis()}", // More meaningful ID
temperature = response.current.temperature_2m,
condition = WeatherCodeMapper.mapToCondition(response.current.weather_code),
humidity = response.current.relative_humidity_2m,
lastUpdated = currentTimeMs,
weatherCode = response.current.weather_code,
precipitation = response.current.precipitation_probability,
wind_speed = response.current.wind_speed_10m,
)
// Save to Local DB & update Runtime reference memory
appDao.insertWeather(newWeather)
lastSavedWeather = newWeather
lastLocation = Pair(lat, lon)
newWeather
} catch (e: Exception) {
e.printStackTrace() // Log the error for debugging
// Fallback: Use old data if network fails and cache exists
// Otherwise, generate fallback empty entity
lastSavedWeather ?: WeatherEntity(
weatherId = "fallback_${System.currentTimeMillis()}",
temperature = 0.0,
condition = "Unknown",
humidity = 0,
lastUpdated = currentTimeMs,
weatherCode = 0,
precipitation = 0,
wind_speed = 0.0
)
}
}
// Optional: Check if locations are within ~10km (0.1 degrees approximate)
private fun isSameLocation(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Boolean {
val deltaLat = Math.abs(lat1 - lat2)
val deltaLon = Math.abs(lon1 - lon2)
// Roughly 0.1 degree ≈ 11km at equator
return deltaLat < 0.1 && deltaLon < 0.1
}
// Helper method to force refresh (if needed)
suspend fun refreshWeather(lat: Double, lon: Double): WeatherEntity {
// Clear cache before fetching
lastSavedWeather = null
lastLocation = null
return getLatestCachedOrFetchWeather(lat, lon)
}
}
class UserPreferencesRepository(private val context: Context) {
private object PreferencesKeys {
val IS_CELSIUS = booleanPreferencesKey("is_celsius")
val SHOW_WEATHER_HISTORY = booleanPreferencesKey("show_weather_history")
val OPEN_ON_HISTORY = booleanPreferencesKey("open_on_history")
}
val isCelsius: Flow<Boolean> = context.dataStore.data
.map { preferences -> preferences[PreferencesKeys.IS_CELSIUS] ?: true }
val showWeatherInHistory: Flow<Boolean> = context.dataStore.data
.map { preferences -> preferences[PreferencesKeys.SHOW_WEATHER_HISTORY] ?: true }
val openOnHistory: Flow<Boolean> = context.dataStore.data
.map { preferences -> preferences[PreferencesKeys.OPEN_ON_HISTORY] ?: false }
suspend fun saveTemperatureUnit(useCelsius: Boolean) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.IS_CELSIUS] = useCelsius
}
}
suspend fun saveWeatherInHistory(show: Boolean) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.SHOW_WEATHER_HISTORY] = show
}
}
suspend fun saveOpenOnHistory(open: Boolean) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.OPEN_ON_HISTORY] = open
}
}
}
@@ -0,0 +1,9 @@
package ru.pirogov.grapehealthchecker.data
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = [DetailEntity::class, WeatherEntity::class, PhotoEntity::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract val appDao: AppDao
}
@@ -0,0 +1,42 @@
package ru.pirogov.grapehealthchecker.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
interface AppDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertWeather(weather: WeatherEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertDetail(detail: DetailEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPhoto(photo: PhotoEntity)
@Transaction
suspend fun insertDetailWithPhoto(detail: DetailEntity, photo: PhotoEntity) {
insertDetail(detail)
insertPhoto(photo)
}
@Transaction
@Query("SELECT * FROM details WHERE detailId = :detailId")
fun getFullDetailById(detailId: String): Flow<FullDetailRecord?>
@Transaction
@Query("SELECT * FROM details")
fun getAllFullDetailRecords(): Flow<List<FullDetailRecord>>
@Query("SELECT * FROM weather ORDER BY lastUpdated DESC LIMIT 1")
suspend fun getLatestWeather(): WeatherEntity?
@Query("DELETE FROM weather WHERE lastUpdated < :cutoffTime")
suspend fun deleteOldWeather(cutoffTime: Long)
@Query("DELETE FROM details WHERE detailId IN (:ids)")
suspend fun deleteDetailsByIds(ids: List<String>)
}
@@ -0,0 +1,76 @@
package ru.pirogov.grapehealthchecker.data
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
@Entity(tableName = "weather")
data class WeatherEntity(
@PrimaryKey val weatherId: String,
val temperature: Double,
val condition: String,
val humidity: Int,
val lastUpdated: Long,
val weatherCode: Int,
val precipitation: Int,
val wind_speed: Double,
)
@Entity(
tableName = "details",
foreignKeys = [
ForeignKey(
entity = WeatherEntity::class,
parentColumns = ["weatherId"],
childColumns = ["detailWeatherId"],
onDelete = ForeignKey.SET_NULL
)
],
indices = [Index(value = ["detailWeatherId"])]
)
data class DetailEntity(
@PrimaryKey val detailId: String,
val title: String,
val description: String,
val detailWeatherId: String?, // Foreign key reference
val dateText: String, // e.g., "May 24, 2026"
val timeText: String // e.g., "18:15"
)
@Entity(
tableName = "photos",
foreignKeys = [
ForeignKey(
entity = DetailEntity::class,
parentColumns = ["detailId"],
childColumns = ["photoDetailId"],
onDelete = ForeignKey.CASCADE // Deleting a detail deletes its photos
)
],
indices = [Index(value = ["photoDetailId"])]
)
data class PhotoEntity(
@PrimaryKey val photoId: String,
val photoDetailId: String, // Foreign key linking to DetailEntity
val filePath: String // Path to the saved image file
)
data class FullDetailRecord(
@Embedded
val detail: DetailEntity,
@Relation(
parentColumn = "detailId",
entityColumn = "photoDetailId"
)
val photo: PhotoEntity?, // One-to-one mapping
@Relation(
parentColumn = "detailWeatherId",
entityColumn = "weatherId"
)
val weather: WeatherEntity? // One-to-one mapping
)
@@ -0,0 +1,64 @@
package ru.pirogov.grapehealthchecker.data.remote
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class OpenMeteoResponse(
val latitude: Double,
val longitude: Double,
val current: CurrentWeather
)
@Serializable
data class CurrentWeather(
val temperature_2m: Double,
val wind_speed_10m: Double,
val relative_humidity_2m: Int,
val weather_code: Int,
val precipitation_probability: Int
)
object WeatherCodeMapper {
fun mapToCondition(weatherCode: Int): String {
return when (weatherCode) {
0 -> "Ясно"
1, 2 -> "Част. обл."
3 -> "Облачно"
45, 48 -> "Туманно"
51, 53, 55 -> "Моросит"
56, 57 -> "Хол. моросит"
61, 63 -> "Дождь"
65 -> "Сил. дождь"
66, 67 -> "Хол. дождь"
71, 73 -> "Снег"
75, 77 -> "Сил. снег"
80, 81 -> "Ливень"
82 -> "Сил. ливень"
85, 86 -> "Снегопад"
95 -> "Гроза"
96, 99 -> "Град"
else -> "Код: ($weatherCode)"
}
}
}
//object WeatherCodeMapper {
// fun mapToCondition(weatherCode: Int): String {
// return when (weatherCode) {
// 0 -> "Ясно"
// 1, 2, 3 -> "Облачно"
// 45, 48 -> "Туманно"
// 51, 53, 55 -> "Моросит"
// 56, 57 -> "Freezing drizzle"
// 61, 63, 65 -> "Rain"
// 66, 67 -> "Freezing rain"
// 71, 73, 75 -> "Snow"
// 77 -> "Snow grains"
// 80, 81, 82 -> "Rain showers"
// 85, 86 -> "Snow showers"
// 95 -> "Thunderstorm"
// 96, 99 -> "Thunderstorm with hail"
// else -> "Unknown ($weatherCode)"
// }
// }
//}
@@ -0,0 +1,11 @@
package ru.pirogov.grapehealthchecker.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,58 @@
package ru.pirogov.grapehealthchecker.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
)
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),
*/
)
@Composable
fun GrapeHealthCheckerTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,40 @@
package ru.pirogov.grapehealthchecker.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import ru.pirogov.grapehealthchecker.R
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
val WeatherIconsFont = FontFamily(
Font(R.font.weather_icons, FontWeight.Normal)
)
@@ -0,0 +1,95 @@
package ru.pirogov.grapehealthchecker.utils
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.content.Context
import android.graphics.Bitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.FloatBuffer
import kotlin.to
import androidx.core.graphics.scale
class OnnxClassifier(private val context: Context) {
private var ortEnv: OrtEnvironment = OrtEnvironment.getEnvironment()
private var ortSession: OrtSession? = null
// Call this once during app initialization or ViewModel creation
fun initSession() {
if (ortSession == null) {
// Assumes your 'nice.onnx' file is placed inside the 'app/src/main/assets' directory
val modelBytes = context.assets.open("nice.onnx").readBytes()
ortSession = ortEnv.createSession(modelBytes)
}
}
// Suspend function to process and classify the image
suspend fun classifyImage(bitmap: Bitmap): FloatArray = withContext(Dispatchers.Default) {
val session = ortSession ?: throw IllegalStateException("ONNX Session not initialized")
// 1. Preprocess: Resize Bitmap to 224x224 to match PyTorch dummy input shape
val resizedBitmap = bitmap.scale(224, 224)
// 2. Convert Bitmap pixels to NCHW FloatBuffer
val floatBuffer = createFloatBufferFromBitmap(resizedBitmap)
// 3. Create input tensor wrapper
val inputShape = longArrayOf(1, 3, 224, 224) // Batch size = 1, Channels = 3, H = 224, W = 224
val inputTensor = OnnxTensor.createTensor(ortEnv, floatBuffer, inputShape)
// 4. Execute inference
inputTensor.use { tensor ->
val inputs = mapOf("input" to tensor) // 'input' matches your Python script name
session.run(inputs).use { result ->
// 5. Extract output array (logits)
@Suppress("UNCHECKED_CAST")
val outputRaw = result[0].value as Array<FloatArray> // Shape: [1, num_classes]
return@withContext outputRaw[0] // Returns the array of raw logits
}
}
}
// Preprocesses Bitmap into PyTorch standard NCHW format
private fun createFloatBufferFromBitmap(bitmap: Bitmap): FloatBuffer {
val imgSize = 224
val numChannels = 3
val floatBuffer = FloatBuffer.allocate(imgSize * imgSize * numChannels)
val intValues = IntArray(imgSize * imgSize)
bitmap.getPixels(intValues, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
// Standard PyTorch ImageNet normalization parameters (adjust if your model uses different values)
val mean = floatArrayOf(0.485f, 0.456f, 0.406f)
val std = floatArrayOf(0.229f, 0.224f, 0.225f)
// Convert HWC format (Bitmap) to NCHW format (PyTorch expects channels first)
for (c in 0 until numChannels) {
for (i in 0 until imgSize * imgSize) {
val pixelValue = intValues[i]
// Extract R, G, B channels based on channel iteration index
val channelValue = when (c) {
0 -> (pixelValue shr 16) and 0xFF // Red
1 -> (pixelValue shr 8) and 0xFF // Green
else -> pixelValue and 0xFF // Blue
}
// Scale to [0.0, 1.0] and normalize
val normalizedValue = ((channelValue / 255.0f) - mean[c]) / std[c]
floatBuffer.put(normalizedValue)
}
}
floatBuffer.rewind()
return floatBuffer
}
// Free resources when the component is destroyed
fun close() {
ortSession?.close()
ortEnv.close()
}
}
@@ -0,0 +1,32 @@
package ru.pirogov.grapehealthchecker.utils
import android.content.Context
import ru.pirogov.grapehealthchecker.data.WeatherEntity
fun getIconResourceName(weatherCode: Int): String {
return when (weatherCode) {
0 -> "\uF00D"
1, 2 -> "\uf002"
3 -> "\uf041"
45, 48 -> "\uf003"
51, 53, 55 -> "\uf00b"
56, 57 -> "\uf0b2"
61, 63 -> "\uf008"
65 -> "\uf019"
66, 67 -> "\uf006"
71, 73 -> "\uf00a"
75, 77 -> "\uf01b"
80, 81 -> "\uf009"
82 -> "\uf01a"
85, 86 -> "\uf065"
95 -> "\uf010"
96, 99 -> "\uf00e"
else -> ""
}
}
sealed interface WeatherUiState {
object Loading : WeatherUiState
data class Success(val data: WeatherEntity) : WeatherUiState
data class Error(val message: String) : WeatherUiState
}
@@ -0,0 +1,102 @@
package ru.pirogov.grapehealthchecker.viewmodels
import android.R.attr.bitmap
import android.graphics.BitmapFactory
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ru.pirogov.grapehealthchecker.GrapeHealthChecker
import ru.pirogov.grapehealthchecker.data.AppRepository
import ru.pirogov.grapehealthchecker.data.DetailEntity
import ru.pirogov.grapehealthchecker.data.PhotoEntity
import ru.pirogov.grapehealthchecker.data.WeatherRepository
import ru.pirogov.grapehealthchecker.utils.OnnxClassifier
import java.io.File
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.UUID
class CameraScreenViewModel(
private val appRepository: AppRepository,
private val weatherRepository: WeatherRepository,
private val onnxClassifier: OnnxClassifier,
) : ViewModel() {
private val _isSaving = MutableStateFlow(false)
val isSaving: StateFlow<Boolean> = _isSaving.asStateFlow()
val classificationResult = MutableLiveData<Int>() // Emits the predicted class index
val classTitleMap = arrayOf("Черная гниль", "Эска", "Здоровый", "Пятнистость", "Ошибка определения")
val classDescMap = arrayOf("болен черной гнилью", "болен эской", "здоров", "болен пятнистостью", "")
init {
onnxClassifier.initSession()
}
fun processAndSavePhoto(lat: Double, lon: Double, photo: File, onNavigation: () -> Unit) {
_isSaving.value = true
viewModelScope.launch {
try {
// Suspends and shifts work to background thread
val logits = onnxClassifier.classifyImage(BitmapFactory.decodeFile(photo.absolutePath))
// Post-process: Find ArgMax (index of the highest logit value)
val predictedClassIndex = logits.indices.maxByOrNull { logits[it] } ?: -1
classificationResult.value = predictedClassIndex
Log.d("IDK", "LOOK HERE THIS IS RESULT: ${classificationResult.value}")
} catch (e: Exception) {
e.printStackTrace()
}
try {
val weatherRecord = weatherRepository.getLatestCachedOrFetchWeather(lat, lon)
val time = Calendar.getInstance().time
val formatterDate = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault())
val formatterTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
val newDetail = DetailEntity(
detailId = UUID.randomUUID().toString(),
title = classTitleMap[classificationResult.value ?: 4],
description = classDescMap[classificationResult.value ?: 4],
detailWeatherId = weatherRecord.weatherId,
dateText = formatterDate.format(time),
timeText = formatterTime.format(time),
)
val newPhoto = PhotoEntity(
photoId = UUID.randomUUID().toString(),
filePath = photo.absolutePath,
photoDetailId = newDetail.detailId
)
appRepository.saveDetailAndWeather(newDetail, weatherRecord, newPhoto)
} catch (e: Exception) {
} finally {
_isSaving.value = false
onNavigation()
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = this[APPLICATION_KEY] as GrapeHealthChecker
CameraScreenViewModel(
appRepository = application.container.appRepository,
weatherRepository = application.container.weatherRepository,
onnxClassifier = application.container.onnxClassifier
)
}
}
}
}
@@ -0,0 +1,54 @@
package ru.pirogov.grapehealthchecker.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import ru.pirogov.grapehealthchecker.GrapeHealthChecker
import ru.pirogov.grapehealthchecker.data.AppRepository
import ru.pirogov.grapehealthchecker.data.DetailEntity
import ru.pirogov.grapehealthchecker.data.FullDetailRecord
import ru.pirogov.grapehealthchecker.data.UserPreferencesRepository
import ru.pirogov.grapehealthchecker.data.WeatherRepository
import java.util.UUID
class DetailItemViewModel(
private val appRepository: AppRepository,
private val userPreferencesRepository: UserPreferencesRepository
) : ViewModel() {
private val _selectedItem = MutableStateFlow<FullDetailRecord?>(null)
val selectedItem: StateFlow<FullDetailRecord?> = _selectedItem.asStateFlow()
// Load an existing record
fun loadDetail(detailId: String) {
viewModelScope.launch {
appRepository.getDetail(detailId).collect { item ->
_selectedItem.value = item
}
}
}
val isCelsius: StateFlow<Boolean> = userPreferencesRepository.isCelsius
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = this[APPLICATION_KEY] as GrapeHealthChecker
DetailItemViewModel(
appRepository = application.container.appRepository,
userPreferencesRepository = application.container.userPreferencesRepository
)
}
}
}
}
@@ -0,0 +1,60 @@
package ru.pirogov.grapehealthchecker.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import ru.pirogov.grapehealthchecker.GrapeHealthChecker
import ru.pirogov.grapehealthchecker.data.AppRepository
import ru.pirogov.grapehealthchecker.data.FullDetailRecord
import ru.pirogov.grapehealthchecker.data.UserPreferencesRepository
import ru.pirogov.grapehealthchecker.data.WeatherRepository
import java.io.File
class DetailListViewModel(
private val appRepository: AppRepository,
private val userPreferencesRepository: UserPreferencesRepository
) : ViewModel() {
// Converts the database Flow into a Compose-friendly StateFlow
val uiState: StateFlow<List<FullDetailRecord>> = appRepository.getAllDetails()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
val showWeatherInHistory: StateFlow<Boolean> = userPreferencesRepository.showWeatherInHistory
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
fun deleteItems(ids: Set<String>) {
viewModelScope.launch {
val itemsToDelete = uiState.value.filter { it.detail.detailId in ids }
itemsToDelete.forEach { item ->
item.photo?.filePath?.let { path ->
val file = File(path)
if (file.exists()) file.delete()
}
}
appRepository.deleteDetails(ids.toList())
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = this[APPLICATION_KEY] as GrapeHealthChecker
DetailListViewModel(
appRepository = application.container.appRepository,
userPreferencesRepository = application.container.userPreferencesRepository
)
}
}
}
}
@@ -0,0 +1,56 @@
package ru.pirogov.grapehealthchecker.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import ru.pirogov.grapehealthchecker.GrapeHealthChecker
import ru.pirogov.grapehealthchecker.data.UserPreferencesRepository
import ru.pirogov.grapehealthchecker.data.WeatherRepository
import ru.pirogov.grapehealthchecker.utils.WeatherUiState
class HomeScreenViewModel (
private val userPreferencesRepository: UserPreferencesRepository,
private val weatherRepository: WeatherRepository,
) : ViewModel() {
val isCelsius: StateFlow<Boolean> = userPreferencesRepository.isCelsius
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
private val _uiState = MutableStateFlow<WeatherUiState>(WeatherUiState.Loading)
val uiState: StateFlow<WeatherUiState> = _uiState.asStateFlow()
fun loadWeatherForLocation(lat: Double, lon: Double) {
viewModelScope.launch {
_uiState.value = WeatherUiState.Loading // Show loading spinner
try {
val weatherRecord = weatherRepository.getLatestCachedOrFetchWeather(lat, lon)
// THIS IS WHAT THE COMMENT IMPLIED:
_uiState.value = WeatherUiState.Success(weatherRecord)
} catch (e: Exception) {
_uiState.value = WeatherUiState.Error(e.localizedMessage ?: "Unknown error")
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = this[APPLICATION_KEY] as GrapeHealthChecker
HomeScreenViewModel(
userPreferencesRepository = application.container.userPreferencesRepository,
weatherRepository = application.container.weatherRepository
)
}
}
}
}
@@ -0,0 +1,57 @@
package ru.pirogov.grapehealthchecker.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import ru.pirogov.grapehealthchecker.GrapeHealthChecker
import ru.pirogov.grapehealthchecker.data.UserPreferencesRepository
class SettingsViewModel (
private val userPreferencesRepository: UserPreferencesRepository
) : ViewModel() {
// Expose settings directly from the repository stream
val isCelsius: StateFlow<Boolean> = userPreferencesRepository.isCelsius
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
val showWeatherInHistory: StateFlow<Boolean> = userPreferencesRepository.showWeatherInHistory
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
val openOnHistory: StateFlow<Boolean> = userPreferencesRepository.openOnHistory
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
fun toggleTemperatureUnit(useCelsius: Boolean) {
viewModelScope.launch {
userPreferencesRepository.saveTemperatureUnit(useCelsius)
}
}
fun toggleWeatherInHistory(show: Boolean) {
viewModelScope.launch {
userPreferencesRepository.saveWeatherInHistory(show)
}
}
fun toggleOpenOnHistory(open: Boolean) {
viewModelScope.launch {
userPreferencesRepository.saveOpenOnHistory(open)
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = this[APPLICATION_KEY] as GrapeHealthChecker
SettingsViewModel(
userPreferencesRepository = application.container.userPreferencesRepository
)
}
}
}
}
@@ -0,0 +1,217 @@
package ru.pirogov.grapehealthchecker.views
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapRegionDecoder
import android.graphics.Rect
import android.net.Uri
import android.view.ViewGroup
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@Composable
fun CameraCaptureScreen(
isLoading: Boolean,
onPhotoSaved: (File) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
val previewView = remember { PreviewView(context) }
val imageCapture = remember { ImageCapture.Builder().build() }
// Bind the CameraX pipeline
LaunchedEffect(Unit) {
val cameraProviderProvider = ProcessCameraProvider.getInstance(context)
cameraProviderProvider.addListener({
val cameraProvider = cameraProviderProvider.get()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageCapture
)
} catch (e: Exception) {
e.printStackTrace()
}
}, ContextCompat.getMainExecutor(context))
}
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1F),
contentAlignment = Alignment.Center
) {
AndroidView(
factory = { previewView },
)
// 2. Adaptable Canvas Overlay
Canvas(modifier = Modifier.matchParentSize()) {
val width = size.width
val height = size.height
// The target square size will always match the screen width
val squareSize = width
// Calculate the vertical center offset for the square window
val topOffset = (height - squareSize) / 2f
val bottomOffset = topOffset + squareSize
// Check if the camera height is actually tall enough to accommodate the square
if (height >= squareSize) {
// Top Masking Bar (covers the top excess area)
drawRect(
color = androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.6f),
size = androidx.compose.ui.geometry.Size(width, topOffset)
)
// Bottom Masking Bar (covers the bottom excess area)
drawRect(
color = androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.6f),
topLeft = androidx.compose.ui.geometry.Offset(0f, bottomOffset),
size = androidx.compose.ui.geometry.Size(width, height - bottomOffset)
)
// Thin white border outlining the exact square capture frame
drawRect(
color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.8f),
topLeft = androidx.compose.ui.geometry.Offset(0f, topOffset),
size = androidx.compose.ui.geometry.Size(squareSize, squareSize),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx())
)
}
}
}
Spacer(modifier = Modifier.height(64.dp))
FloatingActionButton(onClick = { takeAndCropSquarePhoto(context, imageCapture, cameraExecutor, onPhotoSaved) }) {
Icon(
imageVector = Icons.Default.CameraAlt,
contentDescription = "Take shot"
)
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f)) // Dim the background
.pointerInput(Unit) {}, // Blocks user touches from reaching buttons underneath
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = Color.White)
}
}
}
}
private fun takeAndCropSquarePhoto(
context: Context,
imageCapture: ImageCapture,
executor: ExecutorService,
onPhotoSaved: (File) -> Unit
) {
// Create temporary file output
val rawFile = File(context.cacheDir, "raw_capture_${System.currentTimeMillis()}.jpg")
val outputOptions = ImageCapture.OutputFileOptions.Builder(rawFile).build()
imageCapture.takePicture(
outputOptions,
executor,
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
// Photo is captured! Now crop it to a 1:1 matching square.
val croppedFile = File(context.filesDir, "square_${System.currentTimeMillis()}.jpg")
val uri = Uri.fromFile(rawFile)
context.contentResolver.openInputStream(uri).use { inputStream ->
if (inputStream != null) {
// Use BitmapRegionDecoder to crop cleanly without loading the huge full image into RAM
val decoder = BitmapRegionDecoder.newInstance(inputStream, false)
val width = decoder?.width ?: 0
val height = decoder?.height ?: 0
// Calculate center square coordinates
val squareSize = minOf(width, height)
val left = (width - squareSize) / 2
val top = (height - squareSize) / 2
val rect = Rect(left, top, left + squareSize, top + squareSize)
val croppedBitmap = decoder?.decodeRegion(rect, null)
// Save the cropped image
FileOutputStream(croppedFile).use { out ->
croppedBitmap?.compress(Bitmap.CompressFormat.JPEG, 90, out)
}
croppedBitmap?.recycle()
rawFile.delete() // Clean up raw temporary uncropped file
// Hand over the perfect square photo file to your repository/ViewModel layer
onPhotoSaved(croppedFile)
}
}
}
override fun onError(exception: ImageCaptureException) {
exception.printStackTrace()
}
}
)
}
@@ -0,0 +1,84 @@
package ru.pirogov.grapehealthchecker.views
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil3.compose.rememberAsyncImagePainter
import ru.pirogov.grapehealthchecker.R
import ru.pirogov.grapehealthchecker.data.FullDetailRecord
import java.io.File
@Preview
@Composable
fun DetailScreen(fullDetailRecord: FullDetailRecord, isCelsius: Boolean) {
val imageFile = File(fullDetailRecord.photo!!.filePath)
Column(modifier = Modifier
.padding(16.dp)) {
Text(
text = "Фотография от ${fullDetailRecord.detail.dateText}, ${fullDetailRecord.detail.timeText}",
style = MaterialTheme.typography.titleLarge
)
Text(
text = "Виноград ${fullDetailRecord.detail.description}",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "Погодная статистика во время создания фотографии:",
style = MaterialTheme.typography.bodyMedium
)
Row(modifier = Modifier.fillMaxWidth(0.5f), horizontalArrangement = Arrangement.SpaceBetween) {
Column() {
Text(
text = "Температура:",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Влажность:",
style = MaterialTheme.typography.bodyMedium
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = if (isCelsius) "${fullDetailRecord.weather?.temperature}°C" else "${fullDetailRecord.weather?.temperature?.let { it * 9 / 5 + 32 }}°F",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "${fullDetailRecord.weather?.humidity}%",
style = MaterialTheme.typography.bodyMedium
)
}
}
Spacer(modifier = Modifier.size(16.dp))
PhotoComponent(
painter = rememberAsyncImagePainter(model = imageFile),
modifier = Modifier
.fillMaxWidth()
)
}
}
@Composable
private fun PhotoComponent(painter: Painter, modifier: Modifier) {
Image(
painter = painter,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = modifier.clip(MaterialTheme.shapes.medium),
)
}
@@ -0,0 +1,178 @@
package ru.pirogov.grapehealthchecker.views
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
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.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.rememberAsyncImagePainter
import ru.pirogov.grapehealthchecker.data.FullDetailRecord
import ru.pirogov.grapehealthchecker.ui.theme.WeatherIconsFont
import ru.pirogov.grapehealthchecker.utils.getIconResourceName
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(
items: List<FullDetailRecord>,
modifier: Modifier = Modifier,
showWeather: Boolean,
onItemClick: (String) -> Unit = {},
onDeleteItems: (Set<String>) -> Unit = {}
) {
var selectedIds by rememberSaveable { mutableStateOf(setOf<String>()) }
Scaffold(
topBar = {
if (selectedIds.isNotEmpty()) {
TopAppBar(
title = { Text("Выбрано: ${selectedIds.size}") },
actions = {
IconButton(onClick = {
onDeleteItems(selectedIds)
selectedIds = emptySet()
}) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
)
}
},
modifier = modifier
) { paddingValues ->
LazyColumn(modifier = Modifier.padding(paddingValues)) {
item { Spacer(modifier = Modifier.size(4.dp)) }
items(items, key = { it.detail.detailId }) { itemWithWeather ->
val isSelected = selectedIds.contains(itemWithWeather.detail.detailId)
HistoryItem(
item = itemWithWeather,
showWeather = showWeather,
isSelected = isSelected,
onItemClick = {
if (selectedIds.isNotEmpty()) {
if (isSelected) {
selectedIds = selectedIds - itemWithWeather.detail.detailId
} else {
selectedIds = selectedIds + itemWithWeather.detail.detailId
}
} else {
onItemClick(itemWithWeather.detail.detailId)
}
},
onItemLongClick = {
if (selectedIds.isEmpty()) {
selectedIds = setOf(itemWithWeather.detail.detailId)
}
}
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun HistoryItem(
item: FullDetailRecord,
showWeather: Boolean,
isSelected: Boolean,
onItemClick: () -> Unit,
onItemLongClick: () -> Unit
) {
val imageFile = File(item.photo!!.filePath)
Card(
colors = CardDefaults.cardColors(
containerColor = if (isSelected) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.surfaceVariant,
),
modifier = Modifier
.padding(8.dp, 4.dp)
.combinedClickable(
onClick = onItemClick,
onLongClick = onItemLongClick
)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()) {
Column() {
Text(
text = item.detail.title,
style = MaterialTheme.typography.titleLarge
)
Text(
text = item.detail.dateText,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = item.detail.timeText,
style = MaterialTheme.typography.bodyMedium
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (showWeather) {
WeatherIcon(getIconResourceName(item.weather?.weatherCode ?: 0))
Spacer(modifier = Modifier.size(16.dp))
}
PhotoComponent(
painter = rememberAsyncImagePainter(model = imageFile),
modifier = Modifier
.size(96.dp)
)
}
}
}
}
@Composable
fun WeatherIcon(iconCode: String, modifier: Modifier = Modifier, color: Color = Color.Black) {
Text(
modifier = modifier,
text = iconCode,
fontFamily = WeatherIconsFont,
fontSize = 48.sp,
color = color
)
}
@Composable
private fun PhotoComponent(painter: Painter, modifier: Modifier) {
Image(
painter = painter,
contentDescription = null,
modifier = modifier.clip(MaterialTheme.shapes.medium),
)
}
@@ -0,0 +1,171 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package ru.pirogov.grapehealthchecker.views
import android.R
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
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.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withLink
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import ru.pirogov.grapehealthchecker.data.WeatherEntity
import ru.pirogov.grapehealthchecker.utils.WeatherUiState
import ru.pirogov.grapehealthchecker.utils.getIconResourceName
import ru.pirogov.grapehealthchecker.viewmodels.HomeScreenViewModel
@Composable
fun HomeScreen(
homeScreenViewModel: HomeScreenViewModel,
modifier: Modifier = Modifier,
) {
val screenState by homeScreenViewModel.uiState.collectAsStateWithLifecycle()
val myId = "inline_button"
val annotatedString1 = buildAnnotatedString {
append("Система для распознавания болезни по фотографиям листа винограда. Чтобы начать, нажмите на кнопку ")
appendInlineContent(myId, "[plus]")
append(" в правом нижнем углу.")
}
val isCelsius by homeScreenViewModel.isCelsius.collectAsStateWithLifecycle()
val inlineContent = mapOf(
myId to InlineTextContent(
Placeholder(
width = 30.sp,
height = 30.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
FloatingActionButton(onClick = { }) {
Icon(Icons.Default.CameraAlt, contentDescription = "Add")
}
}
)
val annotatedString2 = buildAnnotatedString {
append("Примечание: чтобы приложение открывалось со страницы истории, поменяйте ")
// Use Clickable for custom logic
withLink(
link = LinkAnnotation.Clickable(
tag = "terms_link", // Unique ID for this link
linkInteractionListener = {
// Your custom onClick logic here
println("Terms clicked!")
}
)
) {
append("соответствующий параметр")
}
append(" в настройках.")
}
Column(modifier = Modifier
.padding(16.dp)
.fillMaxWidth()) {
Text(
text = "GrapeHealthChecker",
style = MaterialTheme.typography.displaySmall
)
Spacer(modifier = Modifier.size(16.dp))
Text(
text = annotatedString1,
style = MaterialTheme.typography.bodyLarge,
inlineContent = inlineContent,
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = annotatedString2,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.size(16.dp))
when (val state = screenState) {
is WeatherUiState.Loading -> {
Column {
Spacer(modifier = Modifier.size(64.dp))
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
CircularProgressIndicator()
}
}
}
is WeatherUiState.Success -> {
WeatherWidget(state.data, isCelsius)
}
is WeatherUiState.Error -> {
Text(text = "Ошибка загрузки погоды")
}
}
}
}
@Composable
private fun WeatherWidget(weatherEntity: WeatherEntity, isCelsius: Boolean) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceAround, modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
WeatherIcon("\uf055")
Spacer(modifier = Modifier.size(4.dp))
Text(
text = if (isCelsius) "${weatherEntity.temperature.toInt()}" else "${(weatherEntity.temperature * 9 / 5 + 32).toInt() }",
style = MaterialTheme.typography.displayLarge,
modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 4.dp)
)
if (isCelsius) {
WeatherIcon("\uf03c")
} else {
WeatherIcon("\uf045")
}
}
Spacer(modifier = Modifier.size(8.dp))
Column() {
Text(text = weatherEntity.condition, style = MaterialTheme.typography.displaySmall)
Text(text = "Влажность: ${weatherEntity.humidity}%", style = MaterialTheme.typography.bodyMedium)
Text(text = "Ветер: ${(weatherEntity.wind_speed / 3.6).toInt()} м/с", style = MaterialTheme.typography.bodyMedium)
Text(text = "Шанс осадков: ${weatherEntity.precipitation}%", style = MaterialTheme.typography.bodyMedium)
}
WeatherIcon(getIconResourceName(weatherEntity.weatherCode))
}
}
@@ -0,0 +1,349 @@
package ru.pirogov.grapehealthchecker.views
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import kotlinx.serialization.Serializable
import ru.pirogov.grapehealthchecker.viewmodels.CameraScreenViewModel
import ru.pirogov.grapehealthchecker.viewmodels.DetailItemViewModel
import ru.pirogov.grapehealthchecker.viewmodels.DetailListViewModel
import java.io.File
import android.Manifest
import android.util.Log
import androidx.compose.runtime.collectAsState
import ru.pirogov.grapehealthchecker.data.UserPreferencesRepository
import ru.pirogov.grapehealthchecker.viewmodels.HomeScreenViewModel
import ru.pirogov.grapehealthchecker.viewmodels.SettingsViewModel
@Serializable
sealed interface TopLevelRoute : NavKey {
@Serializable data object Home : TopLevelRoute
@Serializable data object History : TopLevelRoute
@Serializable data object Settings : TopLevelRoute
@Serializable data object Camera: TopLevelRoute
@Serializable
data class Detail(val id: String) : TopLevelRoute
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@Composable
fun MainHostScreen(openOnHistory: Boolean) {
val backStack = rememberNavBackStack(if (openOnHistory) TopLevelRoute.History else TopLevelRoute.Home)
val currentKey = backStack.last()
val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
androidx.compose.runtime.LaunchedEffect(cameraPermissionState.status.isGranted) {
if (cameraPermissionState.status.isGranted) {
backStack.add(TopLevelRoute.Camera)
}
}
Scaffold(
topBar = {
TopAppBar(
colors = topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
title = {
Text("Здоровье винограда")
},
navigationIcon = {
AnimatedVisibility(
visible = currentKey is TopLevelRoute.Detail || currentKey is TopLevelRoute.Camera,
enter = fadeIn() + expandHorizontally(),
exit = fadeOut() + shrinkHorizontally()
) {
IconButton(onClick = { backStack.removeLastOrNull() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
}
)
},
bottomBar = {
AnimatedVisibility(
visible = currentKey !is TopLevelRoute.Camera,
enter = fadeIn() + expandHorizontally(),
exit = fadeOut() + shrinkHorizontally()
) {
NavigationBar {
val items = listOf(TopLevelRoute.Home, TopLevelRoute.History, TopLevelRoute.Settings)
items.forEach { route ->
val isSelected = currentKey == route || (route == TopLevelRoute.History && currentKey is TopLevelRoute.Detail)
NavigationBarItem(
selected = isSelected,
onClick = { if (currentKey != route) {
backStack.clear()
backStack.add(route)
} },
icon = {
// Conditional icon based on selection state
Icon(
imageVector = when (route) {
is TopLevelRoute.Home -> if (isSelected) Icons.Filled.Home else Icons.Outlined.Home
is TopLevelRoute.History -> if (isSelected) Icons.Filled.History else Icons.Outlined.History
is TopLevelRoute.Settings -> if (isSelected) Icons.Filled.Settings else Icons.Outlined.Settings
else -> Icons.Filled.Warning
},
contentDescription = null
)
},
label = {
Text(
text = when (route) {
is TopLevelRoute.Home -> "Главная"
is TopLevelRoute.History -> "История"
is TopLevelRoute.Settings -> "Настройки"
else -> ""
}
)
}
)
}
}
}
},
floatingActionButton = {
if (currentKey is TopLevelRoute.History || currentKey is TopLevelRoute.Home) {
FloatingActionButton(
onClick = {
if (cameraPermissionState.status.isGranted) {
backStack.add(TopLevelRoute.Camera)
} else {
// Triggers the system runtime permission dialog on click
cameraPermissionState.launchPermissionRequest()
}
}
) {
Icon(Icons.Default.CameraAlt, contentDescription = "Add")
}
}
}
) { innerPadding ->
NavDisplay(
backStack = backStack,
modifier = Modifier.padding(innerPadding),
transitionSpec = { fadeIn() togetherWith fadeOut() },
popTransitionSpec = { fadeIn() togetherWith fadeOut() },
entryProvider = { key ->
when (key) {
is TopLevelRoute.Home -> NavEntry(key) {
val context = LocalContext.current
val homeScreenViewModel: HomeScreenViewModel = viewModel(factory = HomeScreenViewModel.Factory)
val fusedLocationClient = remember {
LocationServices.getFusedLocationProviderClient(context)
}
fun fetchLocation(onLocationRetrieved: (Double, Double) -> Unit) {
val hasPermission = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
if (hasPermission) {
fusedLocationClient.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null)
.addOnSuccessListener { location ->
val lat = location?.latitude ?: 0.0
val lon = location?.longitude ?: 0.0
onLocationRetrieved(lat, lon)
}
.addOnFailureListener {
onLocationRetrieved(0.0, 0.0) // Fallback on failure
}
} else {
onLocationRetrieved(0.0, 0.0) // Fallback if called without permission
}
}
var pendingLocationAction by remember { mutableStateOf<(() -> Unit)?>(null) }
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
fetchLocation { lat, lon ->
// Pass lat/lon to your weather fetch function here
homeScreenViewModel.loadWeatherForLocation(lat, lon)
Log.d("LOOK HERE", "THESE COORDS: $lat $lon")
}
} else {
homeScreenViewModel.loadWeatherForLocation(0.0, 0.0) // Fallback if user denies
}
}
androidx.compose.runtime.LaunchedEffect(key) {
val hasPermission = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
if (hasPermission) {
// Happy path: Permission exists, fetch coordinates immediately
fetchLocation { lat, lon ->
homeScreenViewModel.loadWeatherForLocation(lat, lon)
Log.d("LOOK HERE", "THESE COORDS: $lat $lon")
}
} else {
// Prompt path: Launch permission dialog
permissionLauncher.launch(Manifest.permission.ACCESS_COARSE_LOCATION)
}
}
HomeScreen(homeScreenViewModel)
}
is TopLevelRoute.History -> NavEntry(key) {
val listViewModel: DetailListViewModel = viewModel(factory = DetailListViewModel.Factory)
val itemsState by listViewModel.uiState.collectAsStateWithLifecycle()
val showWeather by listViewModel.showWeatherInHistory.collectAsStateWithLifecycle()
HistoryScreen(
items = itemsState,
showWeather = showWeather,
onItemClick = { id -> backStack.add(TopLevelRoute.Detail(id)) },
onDeleteItems = listViewModel::deleteItems
)
}
is TopLevelRoute.Detail -> NavEntry(key) {
val detailViewModel: DetailItemViewModel = viewModel(factory = DetailItemViewModel.Factory)
androidx.compose.runtime.LaunchedEffect(key.id) {
detailViewModel.loadDetail(key.id)
}
val itemState by detailViewModel.selectedItem.collectAsStateWithLifecycle()
val isCelsius by detailViewModel.isCelsius.collectAsStateWithLifecycle()
itemState?.let { detailWithWeather ->
DetailScreen(fullDetailRecord = detailWithWeather, isCelsius = isCelsius)
} ?: Text("Загружаем данные...")
}
is TopLevelRoute.Settings -> NavEntry(key) {
val settingsViewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory)
SettingsScreen(settingsViewModel)
}
is TopLevelRoute.Camera -> NavEntry(key) {
val context = LocalContext.current
val cameraScreenViewModel: CameraScreenViewModel = viewModel(factory = CameraScreenViewModel.Factory)
val fusedLocationClient = remember {
LocationServices.getFusedLocationProviderClient(context)
}
val isSaving by cameraScreenViewModel.isSaving.collectAsStateWithLifecycle()
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
fun navigateBack() {
backStack.clear()
backStack.add(TopLevelRoute.History)
}
fun fetchLocationAndSaveToRoom(photo: File) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
== PackageManager.PERMISSION_GRANTED) {
fusedLocationClient.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null)
.addOnSuccessListener { location ->
// Fallback to 0.0 if location engine returns null (e.g. GPS disabled)
val lat = location?.latitude ?: 0.0
val lon = location?.longitude ?: 0.0
cameraScreenViewModel.processAndSavePhoto(lat, lon, photo) { navigateBack() }
}
.addOnFailureListener {
// Fallback to 0.0 if something goes wrong with the location fetch
cameraScreenViewModel.processAndSavePhoto(0.0, 0.0, photo) { navigateBack() }
}
} else {
// Hard fallback if code execution gets here without runtime permission
cameraScreenViewModel.processAndSavePhoto(0.0, 0.0, photo) { navigateBack() }
}
}
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
val photo = pendingPhotoFile
if (photo != null) {
if (isGranted) {
fetchLocationAndSaveToRoom(photo)
} else {
// User denied permission: save photo to Room anyway using default coordinates
cameraScreenViewModel.processAndSavePhoto(0.0, 0.0, photo) { navigateBack() }
}
pendingPhotoFile = null // Reset the hold
}
}
CameraCaptureScreen(
onPhotoSaved = { savedPhotoFile ->
val hasPermission = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
if (hasPermission) {
// Happy path: permission is granted, immediately fetch location and save to Room
fetchLocationAndSaveToRoom(savedPhotoFile)
} else {
// Interrupted path: store the file reference, trigger system permission dialog
pendingPhotoFile = savedPhotoFile
permissionLauncher.launch(Manifest.permission.ACCESS_COARSE_LOCATION)
}
},
isLoading = isSaving
)
}
else -> NavEntry(key) { Text("Ошибка") }
}
}
)
}
}
@@ -0,0 +1,107 @@
package ru.pirogov.grapehealthchecker.views
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Thermostat
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.pirogov.grapehealthchecker.viewmodels.SettingsViewModel
@Composable
fun SettingsScreen(
settingsViewModel: SettingsViewModel,
modifier: Modifier = Modifier
) {
val isCelsius by settingsViewModel.isCelsius.collectAsStateWithLifecycle()
val showWeatherInHistory by settingsViewModel.showWeatherInHistory.collectAsStateWithLifecycle()
val openOnHistory by settingsViewModel.openOnHistory.collectAsStateWithLifecycle()
Column(
modifier = Modifier.padding(8.dp)
) {
SettingsRow(
title = "Использовать градусы\nцельсия",
Icons.Default.Thermostat,
isCelsius
) { checked -> settingsViewModel.toggleTemperatureUnit(checked) }
Spacer(modifier = Modifier.size(8.dp))
SettingsRow(
title = "Показывать погоду\nв истории",
Icons.Default.Cloud,
showWeatherInHistory
) { checked -> settingsViewModel.toggleWeatherInHistory(checked) }
Spacer(modifier = Modifier.size(8.dp))
SettingsRow(
title = "Всегда открывать\nисторию",
Icons.Default.History,
openOnHistory
) { checked -> settingsViewModel.toggleOpenOnHistory(checked) }
}
}
@Composable
fun SettingsRow(
title: String,
icon: ImageVector,
checked: Boolean,
onClick: (Boolean) -> Unit,
) {
Surface(onClick = { onClick(!checked) }) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = title,
style = MaterialTheme.typography.titleLarge
)
}
Switch(checked, onClick)
}
}
}
@Preview
@Composable
private fun idk() {
Column {
SettingsRow(
title = "Cool setting",
Icons.Default.Settings,
true
) { }
SettingsRow(
title = "Cool setting",
Icons.Default.Settings,
false
) { }
}
}
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
Binary file not shown.
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Grape Health Checker</string>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.GrapeHealthChecker" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,17 @@
package ru.pirogov.grapehealthchecker
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
+6
View File
@@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.ksp) apply false
}
+16
View File
@@ -0,0 +1,16 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
android.disallowKotlinSourceSets=false
+12
View File
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21
+80
View File
@@ -0,0 +1,80 @@
[versions]
accompanistPermissions = "0.37.3"
agp = "9.1.0"
cameraxVersion = "1.4.0"
coilCompose = "3.4.0"
coilNetworkOkhttp = "3.4.0"
coreKtx = "1.18.0"
datastorePreferences = "1.2.1"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.13.0"
kotlin = "2.2.10"
ksp = "2.2.10-2.0.2"
composeBom = "2024.09.00"
nav3Core = "1.0.1"
lifecycleViewmodelNav3 = "2.11.0-alpha03"
kotlinSerialization = "2.2.21"
kotlinxSerializationCore = "1.9.0"
material3AdaptiveNav3 = "1.3.0-alpha09"
navigationCompose = "2.9.8"
onnxruntimeAndroid = "1.26.0"
uiTest = "1.11.0"
room = "2.8.4"
ktor = "3.5.0"
playServicesLocation = "21.3.0"
[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraxVersion" }
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "cameraxVersion" }
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraxVersion" }
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "cameraxVersion" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
# Core Navigation 3 libraries
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
# Optional add-on libraries
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" }
androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "material3AdaptiveNav3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test", version.ref = "uiTest" }
# Core client functionality
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
# Android engine to process network requests (or use "io.ktor:ktor-client-okhttp")
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
# Plugin to negotiate content and parse data types (e.g., JSON)
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
# JSON serialization support
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
onnxruntime-android = { module = "com.microsoft.onnxruntime:onnxruntime-android", version.ref = "onnxruntimeAndroid" }
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"}
kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
#Sun Mar 29 21:13:06 EET 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+27
View File
@@ -0,0 +1,27 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Grape Health Checker"
include(":app")