commit 267079285f1d0a772743476a2c69f8dd37370dac Author: retr0volkov Date: Sat Aug 22 14:24:05 2026 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d38068 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..92f29bc --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000..57b73be Binary files /dev/null and b/app/release/baselineProfiles/0/app-release.dm differ diff --git a/app/release/baselineProfiles/1/app-release.dm b/app/release/baselineProfiles/1/app-release.dm new file mode 100644 index 0000000..869b13d Binary files /dev/null and b/app/release/baselineProfiles/1/app-release.dm differ diff --git a/app/src/androidTest/java/ru/pirogov/grapehealthchecker/ExampleInstrumentedTest.kt b/app/src/androidTest/java/ru/pirogov/grapehealthchecker/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..c702d75 --- /dev/null +++ b/app/src/androidTest/java/ru/pirogov/grapehealthchecker/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4acbfcb --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/nice.onnx b/app/src/main/assets/nice.onnx new file mode 100644 index 0000000..a60912b Binary files /dev/null and b/app/src/main/assets/nice.onnx differ diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/AppContainer.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/AppContainer.kt new file mode 100644 index 0000000..ef09d77 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/AppContainer.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/Application.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/Application.kt new file mode 100644 index 0000000..17a5e77 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/Application.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/MainActivity.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/MainActivity.kt new file mode 100644 index 0000000..9612f97 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/MainActivity.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/data/AppRepository.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/data/AppRepository.kt new file mode 100644 index 0000000..12cbd9f --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/data/AppRepository.kt @@ -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 { + return appDao.getFullDetailById(detailId) + } + + fun getAllDetails(): Flow> { + 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) { + 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? = 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" + + "¤t=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 = context.dataStore.data + .map { preferences -> preferences[PreferencesKeys.IS_CELSIUS] ?: true } + + val showWeatherInHistory: Flow = context.dataStore.data + .map { preferences -> preferences[PreferencesKeys.SHOW_WEATHER_HISTORY] ?: true } + + val openOnHistory: Flow = 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/data/DB.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/data/DB.kt new file mode 100644 index 0000000..a0907dd --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/data/DB.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/data/Daos.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/data/Daos.kt new file mode 100644 index 0000000..1b1d2ed --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/data/Daos.kt @@ -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 + + @Transaction + @Query("SELECT * FROM details") + fun getAllFullDetailRecords(): Flow> + + @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) +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/data/Data.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/data/Data.kt new file mode 100644 index 0000000..8f1d2d5 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/data/Data.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/data/remote/Weather.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/data/remote/Weather.kt new file mode 100644 index 0000000..f1e9da9 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/data/remote/Weather.kt @@ -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)" +// } +// } +//} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/ui/theme/Color.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/ui/theme/Color.kt new file mode 100644 index 0000000..db79aab --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/ui/theme/Color.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/ui/theme/Theme.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/ui/theme/Theme.kt new file mode 100644 index 0000000..b3c2da7 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/ui/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/ui/theme/Type.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/ui/theme/Type.kt new file mode 100644 index 0000000..9ec6c55 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/ui/theme/Type.kt @@ -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) +) \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/utils/OnnxClassifier.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/utils/OnnxClassifier.kt new file mode 100644 index 0000000..5dbc775 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/utils/OnnxClassifier.kt @@ -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 // 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/utils/WeatherActions.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/utils/WeatherActions.kt new file mode 100644 index 0000000..f96152d --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/utils/WeatherActions.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/viewmodels/CameraScreenViewModel.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/viewmodels/CameraScreenViewModel.kt new file mode 100644 index 0000000..09d7beb --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/viewmodels/CameraScreenViewModel.kt @@ -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 = _isSaving.asStateFlow() + val classificationResult = MutableLiveData() // 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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/viewmodels/DetailItemViewModel.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/viewmodels/DetailItemViewModel.kt new file mode 100644 index 0000000..5954346 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/viewmodels/DetailItemViewModel.kt @@ -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(null) + val selectedItem: StateFlow = _selectedItem.asStateFlow() + + // Load an existing record + fun loadDetail(detailId: String) { + viewModelScope.launch { + appRepository.getDetail(detailId).collect { item -> + _selectedItem.value = item + } + } + } + + val isCelsius: StateFlow = 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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/viewmodels/DetailListViewModel.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/viewmodels/DetailListViewModel.kt new file mode 100644 index 0000000..bcc7be6 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/viewmodels/DetailListViewModel.kt @@ -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> = appRepository.getAllDetails() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + val showWeatherInHistory: StateFlow = userPreferencesRepository.showWeatherInHistory + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + + fun deleteItems(ids: Set) { + 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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/viewmodels/HomeScreenViewModel.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/viewmodels/HomeScreenViewModel.kt new file mode 100644 index 0000000..f0a9aa2 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/viewmodels/HomeScreenViewModel.kt @@ -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 = userPreferencesRepository.isCelsius + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + + private val _uiState = MutableStateFlow(WeatherUiState.Loading) + val uiState: StateFlow = _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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/viewmodels/SettingsViewModel.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/viewmodels/SettingsViewModel.kt new file mode 100644 index 0000000..0f45050 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/viewmodels/SettingsViewModel.kt @@ -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 = userPreferencesRepository.isCelsius + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + + val showWeatherInHistory: StateFlow = userPreferencesRepository.showWeatherInHistory + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + + val openOnHistory: StateFlow = 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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/views/CameraCaptureScreen.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/views/CameraCaptureScreen.kt new file mode 100644 index 0000000..c6e4348 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/views/CameraCaptureScreen.kt @@ -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() + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/views/DetailScreen.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/views/DetailScreen.kt new file mode 100644 index 0000000..4de24d6 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/views/DetailScreen.kt @@ -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), + ) +} diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/views/HistoryScreen.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/views/HistoryScreen.kt new file mode 100644 index 0000000..b956142 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/views/HistoryScreen.kt @@ -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, + modifier: Modifier = Modifier, + showWeather: Boolean, + onItemClick: (String) -> Unit = {}, + onDeleteItems: (Set) -> Unit = {} +) { + var selectedIds by rememberSaveable { mutableStateOf(setOf()) } + + 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), + ) +} diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/views/HomeScreen.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/views/HomeScreen.kt new file mode 100644 index 0000000..099349c --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/views/HomeScreen.kt @@ -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)) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/views/Navigation.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/views/Navigation.kt new file mode 100644 index 0000000..a2caae2 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/views/Navigation.kt @@ -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(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("Ошибка") } + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/pirogov/grapehealthchecker/views/SettingsScreen.kt b/app/src/main/java/ru/pirogov/grapehealthchecker/views/SettingsScreen.kt new file mode 100644 index 0000000..68e0af9 --- /dev/null +++ b/app/src/main/java/ru/pirogov/grapehealthchecker/views/SettingsScreen.kt @@ -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 + ) { } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/weather_icons.ttf b/app/src/main/res/font/weather_icons.ttf new file mode 100644 index 0000000..948f0a5 Binary files /dev/null and b/app/src/main/res/font/weather_icons.ttf differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..2652020 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Grape Health Checker + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..28828ca --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +