Repository: bk20dev/forest Branch: main Commit: 42b6b3ded72f Files: 144 Total size: 247.3 KB Directory structure: gitextract__9jakuws/ ├── .gitignore ├── .idea/ │ ├── .gitignore │ ├── .name │ ├── AndroidProjectSystem.xml │ ├── codeStyles/ │ │ ├── Project.xml │ │ └── codeStyleConfig.xml │ ├── compiler.xml │ ├── deploymentTargetSelector.xml │ ├── deviceManager.xml │ ├── gradle.xml │ ├── kotlinc.xml │ ├── migrations.xml │ ├── misc.xml │ ├── runConfigurations.xml │ └── vcs.xml ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── pl/ │ │ └── bartek537/ │ │ └── forest/ │ │ └── ExampleInstrumentedTest.kt │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── pl/ │ │ │ └── bartek537/ │ │ │ └── forest/ │ │ │ ├── ForestApplication.kt │ │ │ ├── core/ │ │ │ │ ├── data/ │ │ │ │ │ ├── repository/ │ │ │ │ │ │ └── DayRepositoryImpl.kt │ │ │ │ │ └── source/ │ │ │ │ │ ├── DayDao.kt │ │ │ │ │ ├── ForestDatabase.kt │ │ │ │ │ └── util/ │ │ │ │ │ └── Converters.kt │ │ │ │ ├── domain/ │ │ │ │ │ ├── model/ │ │ │ │ │ │ ├── Day.kt │ │ │ │ │ │ ├── DaySettings.kt │ │ │ │ │ │ └── StatsSummary.kt │ │ │ │ │ ├── repository/ │ │ │ │ │ │ └── DayRepository.kt │ │ │ │ │ └── usecase/ │ │ │ │ │ ├── DayUseCases.kt │ │ │ │ │ ├── GetDay.kt │ │ │ │ │ ├── GetDayImpl.kt │ │ │ │ │ ├── IncrementStepCount.kt │ │ │ │ │ └── IncrementStepCountImpl.kt │ │ │ │ └── presentation/ │ │ │ │ ├── ActivityRecognitionPermissionFragment.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── OnboardingActivity.kt │ │ │ │ └── SplashActivity.kt │ │ │ ├── progress/ │ │ │ │ ├── ProgressFragment.kt │ │ │ │ ├── ProgressState.kt │ │ │ │ └── ProgressViewModel.kt │ │ │ ├── service/ │ │ │ │ ├── StepCounterController.kt │ │ │ │ ├── StepCounterEvent.kt │ │ │ │ ├── StepCounterService.kt │ │ │ │ ├── StepCounterServiceLauncher.kt │ │ │ │ └── StepCounterState.kt │ │ │ ├── settings/ │ │ │ │ ├── SettingsActivity.kt │ │ │ │ ├── SettingsFragment.kt │ │ │ │ ├── SettingsViewModel.kt │ │ │ │ ├── data/ │ │ │ │ │ ├── repository/ │ │ │ │ │ │ └── SettingsRepositoryImpl.kt │ │ │ │ │ └── source/ │ │ │ │ │ ├── SettingsStore.kt │ │ │ │ │ └── SettingsStoreImpl.kt │ │ │ │ └── domain/ │ │ │ │ ├── model/ │ │ │ │ │ └── Settings.kt │ │ │ │ ├── repository/ │ │ │ │ │ └── SettingsRepository.kt │ │ │ │ └── usecase/ │ │ │ │ ├── GetSettings.kt │ │ │ │ ├── SettingsUseCases.kt │ │ │ │ └── UpdateDaySettings.kt │ │ │ ├── stats/ │ │ │ │ ├── StatsFragment.kt │ │ │ │ ├── domain/ │ │ │ │ │ └── usecase/ │ │ │ │ │ ├── GetFirstDate.kt │ │ │ │ │ ├── GetSummary.kt │ │ │ │ │ ├── GetWeek.kt │ │ │ │ │ ├── StatsChartPageUseCases.kt │ │ │ │ │ ├── StatsDetailsUseCases.kt │ │ │ │ │ └── StatsSummaryUseCases.kt │ │ │ │ ├── presentation/ │ │ │ │ │ ├── ChartAdapter.kt │ │ │ │ │ ├── StatsChartFragment.kt │ │ │ │ │ ├── StatsChartPageFragment.kt │ │ │ │ │ ├── StatsChartPageViewModel.kt │ │ │ │ │ ├── StatsChartState.kt │ │ │ │ │ ├── StatsDetailsFragment.kt │ │ │ │ │ ├── StatsDetailsState.kt │ │ │ │ │ ├── StatsDetailsViewModel.kt │ │ │ │ │ ├── StatsSummaryFragment.kt │ │ │ │ │ ├── StatsSummaryState.kt │ │ │ │ │ └── StatsSummaryViewModel.kt │ │ │ │ └── util/ │ │ │ │ ├── ContextExtension.kt │ │ │ │ ├── DayExtension.kt │ │ │ │ └── LocalDateExtension.kt │ │ │ └── trees/ │ │ │ ├── ForestFragment.kt │ │ │ ├── ForestState.kt │ │ │ ├── ForestViewModel.kt │ │ │ └── domain/ │ │ │ └── usecase/ │ │ │ ├── ForestUseCases.kt │ │ │ └── GetTreeCount.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── bubble_chart_fill0_wght400_grad0_opsz24.xml │ │ │ ├── chevron_left_fill0_wght400_grad0_opsz24.xml │ │ │ ├── chevron_right_fill0_wght400_grad0_opsz24.xml │ │ │ ├── conversion_path_fill0_wght400_grad0_opsz24.xml │ │ │ ├── directions_walk_fill0_wght400_grad0_opsz48.xml │ │ │ ├── do_not_disturb_on_fill0_wght400_grad0_opsz24.xml │ │ │ ├── forest_fill0_wght400_grad0_opsz24.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_launcher_foreground.xml │ │ │ ├── local_fire_department_fill0_wght400_grad0_opsz24.xml │ │ │ ├── nature_fill0_wght400_grad0_opsz24.xml │ │ │ ├── shape_chart_bar.xml │ │ │ ├── shape_circle.xml │ │ │ ├── shape_divider.xml │ │ │ ├── shape_ground.xml │ │ │ ├── show_chart_fill0_wght400_grad0_opsz24.xml │ │ │ ├── stage_1.xml │ │ │ ├── stage_2.xml │ │ │ ├── stage_3.xml │ │ │ ├── stage_4.xml │ │ │ ├── stage_5.xml │ │ │ ├── stage_6.xml │ │ │ ├── steps_fill0_wght400_grad0_opsz24.xml │ │ │ └── tree_collected.xml │ │ ├── layout/ │ │ │ ├── activity_main.xml │ │ │ ├── activity_onboarding.xml │ │ │ ├── activity_settings.xml │ │ │ ├── fragment_activity_recognition_permission.xml │ │ │ ├── fragment_forest.xml │ │ │ ├── fragment_progress.xml │ │ │ ├── fragment_stats.xml │ │ │ ├── fragment_stats_chart.xml │ │ │ ├── fragment_stats_details.xml │ │ │ ├── fragment_stats_page_chart.xml │ │ │ ├── fragment_stats_summary.xml │ │ │ └── item_chart_bar.xml │ │ ├── menu/ │ │ │ ├── bottom_navigation_menu.xml │ │ │ └── main_menu.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── navigation/ │ │ │ ├── nav_graph.xml │ │ │ └── onboarding_nav_graph.xml │ │ ├── values/ │ │ │ ├── colors.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── strings.xml │ │ │ └── themes.xml │ │ ├── values-night/ │ │ │ └── themes.xml │ │ ├── values-v29/ │ │ │ └── themes.xml │ │ └── xml/ │ │ ├── backup_rules.xml │ │ ├── data_extraction_rules.xml │ │ └── settings.xml │ └── test/ │ └── java/ │ └── pl/ │ └── bartek537/ │ └── forest/ │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ *.iml .gradle /local.properties /.idea/caches /.idea/libraries /.idea/modules.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml .DS_Store /build /captures .externalNativeBuild .cxx local.properties ================================================ FILE: .idea/.gitignore ================================================ # Default ignored files /shelf/ /workspace.xml ================================================ FILE: .idea/.name ================================================ Forest ================================================ FILE: .idea/AndroidProjectSystem.xml ================================================ ================================================ FILE: .idea/codeStyles/Project.xml ================================================ ================================================ FILE: .idea/codeStyles/codeStyleConfig.xml ================================================ ================================================ FILE: .idea/compiler.xml ================================================ ================================================ FILE: .idea/deploymentTargetSelector.xml ================================================ ================================================ FILE: .idea/deviceManager.xml ================================================ ================================================ FILE: .idea/gradle.xml ================================================ ================================================ FILE: .idea/kotlinc.xml ================================================ ================================================ FILE: .idea/migrations.xml ================================================ ================================================ FILE: .idea/misc.xml ================================================ ================================================ FILE: .idea/runConfigurations.xml ================================================ ================================================ FILE: .idea/vcs.xml ================================================ ================================================ FILE: README.md ================================================ # Forest Track your daily step count, stay healthy and fight the climate change, one step at a time. ![banner](https://user-images.githubusercontent.com/60577942/221682753-a0251f61-63e0-4ae9-bb40-2854864cebc3.jpg) ## 🦁 Table of Contents - [Forest](#forest) - [🦁 Table of Contents](#-table-of-contents) - [🌳 Inspiration](#-inspiration) - [🥕 Features](#-features) - [🐻‍❄️ Installation and First Launch](#-installation-and-first-launch) - [🪴 Technologies](#-technologies) - [🐌 Resources](#-resources) ## 🌳 Inspiration A couple of years ago together with my friends, I took part in a programming competition. The objective was to build a mobile app that solves a global problem. We didn't win, but the app we built quickly spread in our families. ## 🥕 Features application demo
- Track your step count, burned calories, distance traveled and CO₂ saved - Get rewarded by completing your daily goal and stay motivated - Get handy notifications when your daily stats get updated - View a daily history of your progress - View a detailed summary of your overall progress ## 🐻‍❄️ Installation and First Launch 1. Download the **latest stable** application binary (.apk file) from [Releases](https://github.com/bartek537/forest/releases). 2. Tap on the downloaded file and temporarily **allow installation from unknown sources**, if prompted (turn it back off after installation). 3. On some devices you may encounter a Play Protect warning, but don't worry — the app is safe to use and open-sourced. I'm just an unverified developer. 4. Click “Install” and wait for the app to install. 5. You are now good to go 🚀. [//]: # (@formatter:off) > [!CAUTION] > On most devices you'll need to **turn off the app battery optimizations** for the > app to count steps accurately. Forest uses a minimal amount of power and it won't impact your > battery life. > > - Xiaomi devices running MIUI 14 > 1. Go to Settings > Apps > Manage apps > Forest > Battery saver. > 2. Select "No restrictions". > > - Devices running Lineage OS 22.2 > 1. Go to Settings > Apps > All apps > Forest > App battery usage > Allow background usage > (tap on the setting name to enter another menu). > 2. Enable “Allow background usage”. > 3. Change battery optimizations to “Unrestricted”. [//]: # (@formatter:on) ## 🪴 Technologies - Kotlin - Flows and Coroutines - Room - Shared Preferences - Navigation Component - AndroidX Preference Library - MVVM Design Pattern - Clean Architecture - Material You Dynamic Theming ## 🐌 Resources https://www.notion.so/bartek537/Forest-223bc0c0f5be80bcbb4cc738eefe1ddd ================================================ FILE: app/.gitignore ================================================ /build ================================================ FILE: app/build.gradle ================================================ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias libs.plugins.android.application alias libs.plugins.kotlin.android alias libs.plugins.devtools.ksp } android { namespace = 'pl.bartek537.forest' compileSdk = 36 defaultConfig { applicationId "pl.bk20.forest" minSdk 24 targetSdk 33 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildFeatures { viewBinding = true } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 coreLibraryDesugaringEnabled = true } kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_11 } } } dependencies { implementation libs.koin.androidx coreLibraryDesugaring libs.tools.desugar.jdk.libs implementation libs.androidx.core.ktx implementation libs.androidx.appcompat // User interface implementation libs.android.material.design implementation libs.androidx.activity.ktx implementation libs.androidx.constraintlayout implementation libs.androidx.lifecycle.runtime.ktx implementation libs.androidx.lifecycle.service implementation libs.androidx.lifecycle.viewmodel.ktx implementation libs.androidx.navigation.fragment.ktx implementation libs.androidx.navigation.ui.ktx implementation libs.androidx.swiperefreshlayout // Persistence implementation libs.androidx.preference implementation libs.androidx.room.ktx ksp libs.androidx.room.compiler // Test testImplementation libs.junit androidTestImplementation libs.androidx.test.espresso androidTestImplementation libs.androidx.test.junit } ================================================ FILE: app/proguard-rules.pro ================================================ # 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 ================================================ FILE: app/src/androidTest/java/pl/bartek537/forest/ExampleInstrumentedTest.kt ================================================ package pl.bartek537.forest 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("pl.bk20.forest", appContext.packageName) } } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/java/pl/bartek537/forest/ForestApplication.kt ================================================ package pl.bartek537.forest import android.app.Application import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import androidx.preference.PreferenceManager import androidx.room.Room import com.google.android.material.color.DynamicColors import kotlinx.coroutines.flow.MutableStateFlow import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin import org.koin.dsl.module import pl.bartek537.forest.core.data.source.ForestDatabase import pl.bartek537.forest.settings.data.source.SettingsStore import pl.bartek537.forest.settings.data.source.SettingsStoreImpl import java.time.LocalDate val forestApplicationModule = module { } class ForestApplication : Application() { lateinit var settingsStore: SettingsStore lateinit var forestDatabase: ForestDatabase val currentDate = MutableStateFlow(LocalDate.now()) override fun onCreate() { super.onCreate() startKoin { androidLogger() androidContext(this@ForestApplication) modules(forestApplicationModule) } DynamicColors.applyToActivitiesIfAvailable(this) PreferenceManager.setDefaultValues(this, R.xml.settings, false) registerMidnightTimer() val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) settingsStore = SettingsStoreImpl(sharedPreferences) forestDatabase = Room.databaseBuilder( applicationContext, ForestDatabase::class.java, ForestDatabase.DATABASE_NAME ).build() } private fun registerMidnightTimer() { val intentFilter = IntentFilter().apply { addAction(Intent.ACTION_TIME_TICK) addAction(Intent.ACTION_TIME_CHANGED) addAction(Intent.ACTION_TIMEZONE_CHANGED) } registerReceiver(midnightBroadcastReceiver, intentFilter) } private val midnightBroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { val today = LocalDate.now() if (today != currentDate.value) { currentDate.value = today } } } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/data/repository/DayRepositoryImpl.kt ================================================ package pl.bartek537.forest.core.data.repository import kotlinx.coroutines.flow.Flow import pl.bartek537.forest.core.data.source.DayDao import pl.bartek537.forest.core.domain.model.Day import pl.bartek537.forest.core.domain.model.DaySettings import pl.bartek537.forest.core.domain.repository.DayRepository import java.time.LocalDate class DayRepositoryImpl( private val dao: DayDao ) : DayRepository { override fun getTreeCount(): Flow { return dao.getTreeCount() } override fun getFirstDay(): Flow { return dao.getFirstDay() } override fun getDay(date: LocalDate): Flow { return dao.getDay(date) } override suspend fun getAllDays(): List { return dao.getAllDays() } override fun getDays(range: ClosedRange): Flow> { return dao.getDays(range.start, range.endInclusive) } override suspend fun upsertDay(day: Day) { dao.upsertDay(day) } override suspend fun updateDaySettings(daySettings: DaySettings) { dao.updateDaySettings(daySettings) } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/data/source/DayDao.kt ================================================ package pl.bartek537.forest.core.data.source import androidx.room.* import kotlinx.coroutines.flow.Flow import pl.bartek537.forest.core.domain.model.Day import pl.bartek537.forest.core.domain.model.DaySettings import java.time.LocalDate @Dao interface DayDao { @Query("SELECT COUNT(*) FROM day WHERE steps >= goal") fun getTreeCount(): Flow @Query("SELECT * FROM day ORDER BY date ASC LIMIT 1") fun getFirstDay(): Flow @Query("SELECT * FROM day WHERE date = :date") fun getDay(date: LocalDate): Flow @Query("SELECT * FROM day") suspend fun getAllDays(): List @Query("SELECT * FROM day WHERE date BETWEEN :start AND :endInclusive") fun getDays(start: LocalDate, endInclusive: LocalDate): Flow> @Upsert suspend fun upsertDay(day: Day) @Update(entity = Day::class) suspend fun updateDaySettings(day: DaySettings) } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/data/source/ForestDatabase.kt ================================================ package pl.bartek537.forest.core.data.source import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import pl.bartek537.forest.core.data.source.util.Converters import pl.bartek537.forest.core.domain.model.Day @Database(entities = [Day::class], version = 1) @TypeConverters(Converters::class) abstract class ForestDatabase : RoomDatabase() { abstract val dayDao: DayDao companion object { const val DATABASE_NAME = "forest_database" } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/data/source/util/Converters.kt ================================================ package pl.bartek537.forest.core.data.source.util import androidx.room.TypeConverter import java.time.LocalDate @Suppress("unused") class Converters { @TypeConverter fun localDateToTimestamp(date: LocalDate): Long { return date.toEpochDay() } @TypeConverter fun timestampToLocalDate(timestamp: Long): LocalDate { return LocalDate.ofEpochDay(timestamp) } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/domain/model/Day.kt ================================================ package pl.bartek537.forest.core.domain.model import androidx.room.Entity import androidx.room.PrimaryKey import pl.bartek537.forest.settings.domain.model.Settings import java.time.LocalDate @Entity(tableName = "day") data class Day( @PrimaryKey val date: LocalDate, val steps: Int = 0, val goal: Int, val height: Int = 188, val weight: Int = 70, val stepLength: Int = 72, val pace: Double = 1.0 ) { companion object val distanceTravelled get() = run { val distanceCentimeters = steps * stepLength distanceCentimeters.toDouble() / 100_000 } val calorieBurned get() = run { val modifier = height / 182.0 + weight / 70.0 - 1 0.04 * steps * pace * modifier } val carbonDioxideSaved get() = run { steps * 0.1925 / 1000.0 } } fun Day.Companion.of(date: LocalDate, settings: Settings, steps: Int = 0): Day { return settings.run { Day( date = date, steps = steps, goal = dailyGoal, height = height, weight = weight, stepLength = stepLength, pace = pace ) } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/domain/model/DaySettings.kt ================================================ package pl.bartek537.forest.core.domain.model import java.time.LocalDate data class DaySettings( val date: LocalDate, val goal: Int, val height: Int, val weight: Int, val stepLength: Int, val pace: Double ) ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/domain/model/StatsSummary.kt ================================================ package pl.bartek537.forest.core.domain.model data class StatsSummary( val treesCollected: Int = 0, val stepsTaken: Long = 0L, val calorieBurned: Double = 0.0, val distanceTravelled: Double = 0.0, val carbonDioxideSaved: Double = 0.0, ) { companion object } fun StatsSummary.Companion.of(days: List): StatsSummary { val treesCollected = days.count { it.steps >= it.goal } val stepsTaken = days.sumOf { it.steps.toLong() } val calorieBurned = days.sumOf { it.calorieBurned } val distanceTravelled = days.sumOf { it.distanceTravelled } val carbonDioxideSaved = days.sumOf { it.carbonDioxideSaved } return StatsSummary( treesCollected = treesCollected, stepsTaken = stepsTaken, calorieBurned = calorieBurned, distanceTravelled = distanceTravelled, carbonDioxideSaved = carbonDioxideSaved ) } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/domain/repository/DayRepository.kt ================================================ package pl.bartek537.forest.core.domain.repository import kotlinx.coroutines.flow.Flow import pl.bartek537.forest.core.domain.model.Day import pl.bartek537.forest.core.domain.model.DaySettings import java.time.LocalDate interface DayRepository { fun getTreeCount(): Flow fun getFirstDay(): Flow fun getDay(date: LocalDate): Flow suspend fun getAllDays(): List fun getDays(range: ClosedRange): Flow> suspend fun upsertDay(day: Day) suspend fun updateDaySettings(daySettings: DaySettings) } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/domain/usecase/DayUseCases.kt ================================================ package pl.bartek537.forest.core.domain.usecase import pl.bartek537.forest.core.domain.repository.DayRepository import pl.bartek537.forest.settings.domain.repository.SettingsRepository class DayUseCases( dayRepository: DayRepository, settingsRepository: SettingsRepository ) { val getDay: GetDay = GetDayImpl(dayRepository, settingsRepository) val incrementStepCount: IncrementStepCount = IncrementStepCountImpl(dayRepository, getDay) } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/domain/usecase/GetDay.kt ================================================ package pl.bartek537.forest.core.domain.usecase import kotlinx.coroutines.flow.Flow import pl.bartek537.forest.core.domain.model.Day import java.time.LocalDate interface GetDay { operator fun invoke(date: LocalDate): Flow } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/domain/usecase/GetDayImpl.kt ================================================ package pl.bartek537.forest.core.domain.usecase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import pl.bartek537.forest.core.domain.model.Day import pl.bartek537.forest.core.domain.model.of import pl.bartek537.forest.core.domain.repository.DayRepository import pl.bartek537.forest.settings.domain.repository.SettingsRepository import java.time.LocalDate class GetDayImpl( private val dayRepository: DayRepository, private val settingsRepository: SettingsRepository, ) : GetDay { override fun invoke(date: LocalDate): Flow { val settingsFlow = settingsRepository.getSettings() val dayFlow = dayRepository.getDay(date) return settingsFlow.combine(dayFlow) { settings, day -> day ?: Day.of(date, settings, steps = 0) } } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/domain/usecase/IncrementStepCount.kt ================================================ package pl.bartek537.forest.core.domain.usecase import java.time.LocalDate interface IncrementStepCount { suspend operator fun invoke(date: LocalDate, by: Int) } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/domain/usecase/IncrementStepCountImpl.kt ================================================ package pl.bartek537.forest.core.domain.usecase import kotlinx.coroutines.flow.first import pl.bartek537.forest.core.domain.repository.DayRepository import java.time.LocalDate class IncrementStepCountImpl( private val repository: DayRepository, private val getDayUseCase: GetDay ) : IncrementStepCount { override suspend fun invoke(date: LocalDate, by: Int) { val day = getDayUseCase(date).first() val updatedDay = day.copy(steps = day.steps + by) repository.upsertDay(updatedDay) } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/presentation/ActivityRecognitionPermissionFragment.kt ================================================ package pl.bartek537.forest.core.presentation import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts.RequestPermission import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import pl.bartek537.forest.R import pl.bartek537.forest.databinding.FragmentActivityRecognitionPermissionBinding class ActivityRecognitionPermissionFragment : Fragment() { private var _binding: FragmentActivityRecognitionPermissionBinding? = null private val binding get() = _binding!! @RequiresApi(Build.VERSION_CODES.Q) private val requestPermissionLauncher = registerForActivityResult(RequestPermission()) { when (ContextCompat.checkSelfPermission( requireContext(), Manifest.permission.ACTIVITY_RECOGNITION )) { PackageManager.PERMISSION_GRANTED -> openMainActivity() PackageManager.PERMISSION_DENIED -> openPermissionSettings() } } private fun openMainActivity() { val action = R.id.action_activityRecognitionPermissionFragment_to_mainActivity findNavController().navigate(action) requireActivity().finish() } private fun openPermissionSettings() { startActivity(Intent().apply { action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS data = Uri.fromParts("package", requireContext().packageName, null) }) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentActivityRecognitionPermissionBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { openMainActivity() return } binding.buttonContinue.setOnClickListener { requestPermission() } } @RequiresApi(Build.VERSION_CODES.Q) private fun requestPermission() { requestPermissionLauncher.launch(Manifest.permission.ACTIVITY_RECOGNITION) } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/presentation/MainActivity.kt ================================================ package pl.bartek537.forest.core.presentation import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.view.Menu import android.view.MenuItem import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.setupActionBarWithNavController import androidx.navigation.ui.setupWithNavController import pl.bartek537.forest.R import pl.bartek537.forest.databinding.ActivityMainBinding import pl.bartek537.forest.service.StepCounterService import pl.bartek537.forest.settings.SettingsActivity class MainActivity : AppCompatActivity() { private lateinit var navController: NavController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) val binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.toolbar) val navHostFragment = supportFragmentManager .findFragmentById(R.id.nav_host_fragment) as NavHostFragment navController = navHostFragment.navController val appBarConfiguration = AppBarConfiguration( setOf( R.id.progressFragment, R.id.statsFragment, R.id.forestFragment, ) ) setupActionBarWithNavController(navController, appBarConfiguration) binding.bottomNavigation.setupWithNavController(navController) startStepCounterService() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { askForNotificationPermission() } } private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} @RequiresApi(Build.VERSION_CODES.TIRAMISU) private fun askForNotificationPermission() { val notificationPermission = android.Manifest.permission.POST_NOTIFICATIONS val notificationPermissionStatus = ContextCompat .checkSelfPermission(this, notificationPermission) if (notificationPermissionStatus == PackageManager.PERMISSION_DENIED) { requestPermissionLauncher.launch(notificationPermission) } } private fun startStepCounterService() { val intent = Intent(this, StepCounterService::class.java) ContextCompat.startForegroundService(this, intent) } override fun onCreateOptionsMenu(menu: Menu?): Boolean { menuInflater.inflate(R.menu.main_menu, menu) return true } override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { R.id.settings -> { openSettings() true } else -> false } private fun openSettings() { val settingsIntent = Intent(this, SettingsActivity::class.java) startActivity(settingsIntent) } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/presentation/OnboardingActivity.kt ================================================ package pl.bartek537.forest.core.presentation import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import pl.bartek537.forest.R import pl.bartek537.forest.databinding.ActivityOnboardingBinding class OnboardingActivity : AppCompatActivity() { private lateinit var navController: NavController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = ActivityOnboardingBinding.inflate(layoutInflater) setContentView(binding.root) val navHostFragment = supportFragmentManager .findFragmentById(R.id.nav_host_fragment) as NavHostFragment navController = navHostFragment.navController } override fun onSupportNavigateUp(): Boolean { return navController.navigateUp() || super.onSupportNavigateUp() } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/core/presentation/SplashActivity.kt ================================================ package pl.bartek537.forest.core.presentation import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat @SuppressLint("CustomSplashScreen") class SplashActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (shouldOpenOnboarding()) { openOnboardingActivity() } else { openMainActivity() } finish() } private fun shouldOpenOnboarding(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { return false } val permission = Manifest.permission.ACTIVITY_RECOGNITION return !hasPermission(this, permission) } private fun openOnboardingActivity() { val intent = Intent(this, OnboardingActivity::class.java) startActivity(intent) } private fun openMainActivity() { val intent = Intent(this, MainActivity::class.java) startActivity(intent) } @Suppress("SameParameterValue") private fun hasPermission(context: Context, permission: String): Boolean { val status = ContextCompat.checkSelfPermission(context, permission) return status == PackageManager.PERMISSION_GRANTED } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/progress/ProgressFragment.kt ================================================ package pl.bartek537.forest.progress import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.launch import pl.bartek537.forest.R import pl.bartek537.forest.databinding.FragmentProgressBinding import java.text.DecimalFormat class ProgressFragment : Fragment() { private val viewModel: ProgressViewModel by activityViewModels { ProgressViewModel } private var _binding: FragmentProgressBinding? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentProgressBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.progress.collect { progress -> updateUserInterface(progress) } } } } private fun updateUserInterface(state: ProgressState) { updateProgress(state) updateTree(state) updateTiles(state) } private fun updateProgress(state: ProgressState) = state.apply { val numberFormat = DecimalFormat.getIntegerInstance() val formattedStepCount = numberFormat.format(stepsTaken) val dailyGoalStepCount = numberFormat.format(dailyGoal) val dailyGoalText = getString(R.string.step_goal, dailyGoalStepCount) binding.apply { textStepCount.text = formattedStepCount textDailyGoal.text = dailyGoalText progressDailyGoal.max = dailyGoal progressDailyGoal.progress = stepsTaken } } private fun updateTree(state: ProgressState) = state.apply { val treeResource = getTreeResource(stepsTaken.toDouble() / dailyGoal) binding.imageTree.setImageResource(treeResource) } private fun updateTiles(state: ProgressState) = state.apply { val calorieText = getString( R.string.calorie_burned_format, calorieBurned ) val distanceText = getString( R.string.distance_travelled_format, distanceTravelled ) val carbonDioxideText = getString( R.string.carbon_dioxide_saved_format, carbonDioxideSaved ) binding.apply { textCalorieBurned.text = calorieText textDistanceTravelled.text = distanceText textCarbonDioxideSaved.text = carbonDioxideText } } private fun getTreeResource(progress: Double) = when { progress < .2 -> R.drawable.stage_1 progress < .4 -> R.drawable.stage_2 progress < .6 -> R.drawable.stage_3 progress < .8 -> R.drawable.stage_4 progress < 1 -> R.drawable.stage_5 else -> R.drawable.stage_6 } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/progress/ProgressState.kt ================================================ package pl.bartek537.forest.progress import java.time.LocalDate data class ProgressState( val date: LocalDate, val stepsTaken: Int, val dailyGoal: Int, val calorieBurned: Int, val distanceTravelled: Double, val carbonDioxideSaved: Double, ) ================================================ FILE: app/src/main/java/pl/bartek537/forest/progress/ProgressViewModel.kt ================================================ package pl.bartek537.forest.progress import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import pl.bartek537.forest.ForestApplication import pl.bartek537.forest.core.data.repository.DayRepositoryImpl import pl.bartek537.forest.core.domain.usecase.DayUseCases import pl.bartek537.forest.settings.data.repository.SettingsRepositoryImpl import java.time.LocalDate import kotlin.math.roundToInt class ProgressViewModel( private val dayUseCases: DayUseCases, private val currentDateFlow: StateFlow ) : ViewModel() { private val _progress = MutableStateFlow( ProgressState( date = LocalDate.MIN, stepsTaken = 0, dailyGoal = 0, calorieBurned = 0, distanceTravelled = 0.0, carbonDioxideSaved = 0.0, ) ) val progress: StateFlow = _progress.asStateFlow() private var getProgressJob: Job? = null init { viewModelScope.launch { currentDateFlow.collect { date -> getProgress(date) } } } private fun getProgress(date: LocalDate) { getProgressJob?.cancel() getProgressJob = dayUseCases.getDay(date).onEach { day -> _progress.value = progress.value.copy( date = day.date, stepsTaken = day.steps, dailyGoal = day.goal, calorieBurned = day.calorieBurned.roundToInt(), distanceTravelled = day.distanceTravelled, carbonDioxideSaved = day.carbonDioxideSaved, ) }.launchIn(viewModelScope) } companion object Factory : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class, extras: CreationExtras): T { val application = checkNotNull(extras[APPLICATION_KEY]) as ForestApplication val settingsStore = application.settingsStore val settingsRepository = SettingsRepositoryImpl(settingsStore) val dayDatabase = application.forestDatabase val dayRepository = DayRepositoryImpl(dayDatabase.dayDao) val dayUseCases = DayUseCases(dayRepository, settingsRepository) val currentDateFlow = application.currentDate return ProgressViewModel(dayUseCases, currentDateFlow) as T } } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/service/StepCounterController.kt ================================================ package pl.bartek537.forest.service import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import pl.bartek537.forest.core.domain.usecase.DayUseCases import java.time.LocalDate import kotlin.math.roundToInt class StepCounterController( private val dayUseCases: DayUseCases, private val coroutineScope: CoroutineScope, currentDateFlow: StateFlow, ) { private val _stats = MutableStateFlow(StepCounterState(LocalDate.now(), 0, 0, 0.0, 0)) val stats: StateFlow = _stats.asStateFlow() private var getStatsJob: Job? = null init { coroutineScope.launch { currentDateFlow.collect { getStats(it) } } } private fun getStats(date: LocalDate) { getStatsJob?.cancel() getStatsJob = dayUseCases.getDay(date).onEach { day -> _stats.value = day.run { StepCounterState( date = date, steps = steps, goal = goal, distanceTravelled = distanceTravelled, calorieBurned = calorieBurned.roundToInt() ) } }.launchIn(coroutineScope) } private val rawStepSensorReadings = MutableStateFlow(StepCounterEvent(0, LocalDate.MIN)) private var previousStepCount: Int? = null init { rawStepSensorReadings.drop(1).onEach { event -> val stepCountDifference = event.stepCount - (previousStepCount ?: event.stepCount) previousStepCount = event.stepCount dayUseCases.incrementStepCount(event.eventDate, stepCountDifference) }.launchIn(coroutineScope) } fun onStepCountChanged(newStepCount: Int, eventDate: LocalDate) { rawStepSensorReadings.value = StepCounterEvent(newStepCount, eventDate) } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/service/StepCounterEvent.kt ================================================ package pl.bartek537.forest.service import java.time.LocalDate data class StepCounterEvent( val stepCount: Int, val eventDate: LocalDate, ) ================================================ FILE: app/src/main/java/pl/bartek537/forest/service/StepCounterService.kt ================================================ package pl.bartek537.forest.service import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager import android.os.Build import android.os.Build.VERSION_CODES import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.launch import pl.bartek537.forest.ForestApplication import pl.bartek537.forest.R import pl.bartek537.forest.core.data.repository.DayRepositoryImpl import pl.bartek537.forest.core.domain.usecase.DayUseCases import pl.bartek537.forest.core.presentation.MainActivity import pl.bartek537.forest.settings.data.repository.SettingsRepositoryImpl import java.time.LocalDate class StepCounterService : LifecycleService(), SensorEventListener { private lateinit var sensorManager: SensorManager private lateinit var controller: StepCounterController companion object { private const val NOTIFICATION_CHANNEL_ID = "step_counter_channel" private const val NOTIFICATION_ID = 0x1 private const val PENDING_INTENT_ID = 0x1 } override fun onCreate() { super.onCreate() if (Build.VERSION.SDK_INT >= VERSION_CODES.O) { val notificationChannel = createNotificationChannel() registerNotificationChannel(notificationChannel) } sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager registerStepCounter(sensorManager) // Initialise controller val application = application as ForestApplication val settingsStore = application.settingsStore val settingsRepository = SettingsRepositoryImpl(settingsStore) val dayDatabase = application.forestDatabase val dayRepository = DayRepositoryImpl(dayDatabase.dayDao) val dayUseCases = DayUseCases(dayRepository, settingsRepository) controller = StepCounterController(dayUseCases, lifecycleScope, application.currentDate) // Create notification val notification = createNotification(controller.stats.value) startForeground(NOTIFICATION_ID, notification) val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { controller.stats.collect { val updatedNotification = createNotification(it) notificationManager.notify(NOTIFICATION_ID, updatedNotification) } } } } private fun createNotification(state: StepCounterState): Notification = state.run { val title = resources.getQuantityString(R.plurals.step_count, steps, steps) val progress = if (goal == 0) 0 else steps * 100 / goal val content = getString( R.string.step_counter_stats, calorieBurned, distanceTravelled, progress ) NotificationCompat.Builder(this@StepCounterService, NOTIFICATION_CHANNEL_ID) .setContentIntent(launchApplicationPendingIntent) .setSmallIcon(R.drawable.nature_fill0_wght400_grad0_opsz24) .setContentTitle(title) .setContentText(content) .setOnlyAlertOnce(true) .setOngoing(true) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setSilent(true) .build() } private val launchApplicationPendingIntent get(): PendingIntent { val intent = Intent(applicationContext, MainActivity::class.java) val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE return PendingIntent.getActivity(this, PENDING_INTENT_ID, intent, flags) } private fun registerStepCounter(sensorManager: SensorManager) { val stepCounterSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) stepCounterSensor?.let { sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL) } } override fun onSensorChanged(event: SensorEvent?) { event?.let { val eventStepCount = it.values[0].toInt() controller.onStepCountChanged(eventStepCount, LocalDate.now()) } } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} override fun onDestroy() { super.onDestroy() sensorManager.unregisterListener(this) } @RequiresApi(VERSION_CODES.O) private fun createNotificationChannel(): NotificationChannel { val name = getString(R.string.step_counter_channel) val importance = NotificationManager.IMPORTANCE_DEFAULT return NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance).apply { setShowBadge(false) } } @RequiresApi(VERSION_CODES.O) private fun registerNotificationChannel(channel: NotificationChannel) { val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/service/StepCounterServiceLauncher.kt ================================================ package pl.bartek537.forest.service import android.Manifest import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build import androidx.core.content.ContextCompat class StepCounterServiceLauncher : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { context?.run { if (intent?.action == Intent.ACTION_BOOT_COMPLETED && hasPermissions(context)) { val launchIntent = Intent(applicationContext, StepCounterService::class.java) ContextCompat.startForegroundService(applicationContext, launchIntent) } } } private fun hasPermissions(context: Context): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (!hasPermission(context, Manifest.permission.ACTIVITY_RECOGNITION)) { return false } } return true } @Suppress("SameParameterValue") private fun hasPermission(context: Context, permission: String): Boolean { val status = ContextCompat.checkSelfPermission(context, permission) return status == PackageManager.PERMISSION_GRANTED } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/service/StepCounterState.kt ================================================ package pl.bartek537.forest.service import java.time.LocalDate data class StepCounterState( val date: LocalDate, val steps: Int, val goal: Int, val distanceTravelled: Double, val calorieBurned: Int ) ================================================ FILE: app/src/main/java/pl/bartek537/forest/settings/SettingsActivity.kt ================================================ package pl.bartek537.forest.settings import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.core.view.WindowCompat import pl.bartek537.forest.databinding.ActivitySettingsBinding class SettingsActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) val binding = ActivitySettingsBinding.inflate(layoutInflater) setContentView(binding.root) setupActionBar(binding) } private fun setupActionBar(binding: ActivitySettingsBinding) { setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/settings/SettingsFragment.kt ================================================ package pl.bartek537.forest.settings import android.os.Bundle import android.text.InputType import androidx.fragment.app.activityViewModels import androidx.preference.EditTextPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import pl.bartek537.forest.R class SettingsFragment : PreferenceFragmentCompat() { private val viewModel: SettingsViewModel by activityViewModels { SettingsViewModel } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewModel.observeSettingsChanges() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.settings, rootKey) val dailyGoalPreference = preferenceManager.findPreference("daily_goal") dailyGoalPreference?.summaryProvider = Preference.SummaryProvider { val dailyGoal = it.text?.toIntOrNull() ?: 0 resources.getQuantityString(R.plurals.daily_goal_summary, dailyGoal, dailyGoal) } val numericPreferenceKeys = listOf("daily_goal", "step_length", "height", "weight") numericPreferenceKeys.forEach { val preference = preferenceManager.findPreference(it) preference?.setOnBindEditTextListener { editText -> editText.inputType = InputType.TYPE_CLASS_NUMBER } } } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/settings/SettingsViewModel.kt ================================================ package pl.bartek537.forest.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import pl.bartek537.forest.ForestApplication import pl.bartek537.forest.core.data.repository.DayRepositoryImpl import pl.bartek537.forest.core.domain.model.DaySettings import pl.bartek537.forest.settings.data.repository.SettingsRepositoryImpl import pl.bartek537.forest.settings.domain.usecase.SettingsUseCases import java.time.LocalDate class SettingsViewModel( private val settingsUseCases: SettingsUseCases ) : ViewModel() { private var observeSettingsChangesJob: Job? = null fun observeSettingsChanges() { observeSettingsChangesJob?.cancel() observeSettingsChangesJob = settingsUseCases.getSettings().onEach { settingsUseCases.updateDaySettings( DaySettings( date = LocalDate.now(), goal = it.dailyGoal, height = it.height, weight = it.weight, stepLength = it.stepLength, pace = it.pace ) ) }.launchIn(viewModelScope) } companion object Factory : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class, extras: CreationExtras): T { val application = checkNotNull(extras[APPLICATION_KEY]) as ForestApplication val settingsStore = application.settingsStore val settingsRepository = SettingsRepositoryImpl(settingsStore) val dayDatabase = application.forestDatabase val dayRepository = DayRepositoryImpl(dayDatabase.dayDao) val settingsUseCases = SettingsUseCases(settingsRepository, dayRepository) return SettingsViewModel(settingsUseCases) as T } } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/settings/data/repository/SettingsRepositoryImpl.kt ================================================ package pl.bartek537.forest.settings.data.repository import kotlinx.coroutines.flow.Flow import pl.bartek537.forest.settings.data.source.SettingsStore import pl.bartek537.forest.settings.domain.model.Settings import pl.bartek537.forest.settings.domain.repository.SettingsRepository class SettingsRepositoryImpl( private val settingsStore: SettingsStore ) : SettingsRepository { override fun getSettings(): Flow { return settingsStore.getSettings() } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/settings/data/source/SettingsStore.kt ================================================ package pl.bartek537.forest.settings.data.source import kotlinx.coroutines.flow.Flow import pl.bartek537.forest.settings.domain.model.Settings interface SettingsStore { fun getSettings(): Flow } ================================================ FILE: app/src/main/java/pl/bartek537/forest/settings/data/source/SettingsStoreImpl.kt ================================================ package pl.bartek537.forest.settings.data.source import android.content.SharedPreferences import android.content.SharedPreferences.OnSharedPreferenceChangeListener import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import pl.bartek537.forest.settings.domain.model.Settings class SettingsStoreImpl( private val sharedPreferences: SharedPreferences ) : SettingsStore, OnSharedPreferenceChangeListener { private val settings: MutableStateFlow init { val parsedSettings = parseSettings(sharedPreferences) settings = MutableStateFlow(parsedSettings) sharedPreferences.registerOnSharedPreferenceChangeListener(this) } override fun getSettings(): Flow { return settings.asStateFlow() } private fun parseSettings(sharedPreferences: SharedPreferences): Settings = sharedPreferences.run { Settings( dailyGoal = getNumericString("daily_goal", 0), stepLength = getNumericString("step_length", 0), height = getNumericString("height", 0), weight = getNumericString("weight", 0), pace = getNumericString("pace", 0.0) ) } private fun SharedPreferences.getNumericString(key: String, defaultValue: Int): Int = getString(key, "")?.toIntOrNull() ?: defaultValue private fun SharedPreferences.getNumericString(key: String, defaultValue: Double): Double = getString(key, "")?.toDoubleOrNull() ?: defaultValue override fun onSharedPreferenceChanged( updatedSharedPreferences: SharedPreferences?, key: String? ) { settings.value = parseSettings(sharedPreferences) } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/settings/domain/model/Settings.kt ================================================ package pl.bartek537.forest.settings.domain.model data class Settings( val dailyGoal: Int, val stepLength: Int, val height: Int, val weight: Int, val pace: Double ) ================================================ FILE: app/src/main/java/pl/bartek537/forest/settings/domain/repository/SettingsRepository.kt ================================================ package pl.bartek537.forest.settings.domain.repository import kotlinx.coroutines.flow.Flow import pl.bartek537.forest.settings.domain.model.Settings interface SettingsRepository { fun getSettings(): Flow } ================================================ FILE: app/src/main/java/pl/bartek537/forest/settings/domain/usecase/GetSettings.kt ================================================ package pl.bartek537.forest.settings.domain.usecase import kotlinx.coroutines.flow.Flow import pl.bartek537.forest.settings.domain.model.Settings import pl.bartek537.forest.settings.domain.repository.SettingsRepository interface GetSettings { operator fun invoke(): Flow } class GetSettingsImpl( private val repository: SettingsRepository ) : GetSettings { override fun invoke(): Flow { return repository.getSettings() } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/settings/domain/usecase/SettingsUseCases.kt ================================================ package pl.bartek537.forest.settings.domain.usecase import pl.bartek537.forest.core.domain.repository.DayRepository import pl.bartek537.forest.settings.domain.repository.SettingsRepository class SettingsUseCases( settingsRepository: SettingsRepository, dayRepository: DayRepository, ) { val getSettings: GetSettings = GetSettingsImpl(settingsRepository) val updateDaySettings: UpdateDaySettings = UpdateDaySettingsImpl(dayRepository) } ================================================ FILE: app/src/main/java/pl/bartek537/forest/settings/domain/usecase/UpdateDaySettings.kt ================================================ package pl.bartek537.forest.settings.domain.usecase import pl.bartek537.forest.core.domain.model.DaySettings import pl.bartek537.forest.core.domain.repository.DayRepository interface UpdateDaySettings { suspend operator fun invoke(daySettings: DaySettings) } class UpdateDaySettingsImpl( private val dayRepository: DayRepository ) : UpdateDaySettings { override suspend fun invoke(daySettings: DaySettings) { dayRepository.updateDaySettings(daySettings) } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/StatsFragment.kt ================================================ package pl.bartek537.forest.stats import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter import com.google.android.material.tabs.TabLayoutMediator import pl.bartek537.forest.R import pl.bartek537.forest.databinding.FragmentStatsBinding import pl.bartek537.forest.stats.presentation.StatsDetailsFragment import pl.bartek537.forest.stats.presentation.StatsSummaryFragment class StatsFragment : Fragment() { private lateinit var binding: FragmentStatsBinding companion object { private val fragments = listOf( R.string.details to { StatsDetailsFragment() }, R.string.summary to { StatsSummaryFragment() }, ) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentStatsBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val statsPageAdapter = StatsPageAdapter(this) binding.pager.apply { isUserInputEnabled = false adapter = statsPageAdapter } TabLayoutMediator(binding.tabLayout, binding.pager) { tab, position -> val tabTitleRes = fragments[position].first tab.text = getString(tabTitleRes) }.attach() } class StatsPageAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { override fun getItemCount(): Int = fragments.size override fun createFragment(position: Int): Fragment { return fragments[position].second() } } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/domain/usecase/GetFirstDate.kt ================================================ package pl.bartek537.forest.stats.domain.usecase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import pl.bartek537.forest.core.domain.repository.DayRepository import java.time.LocalDate interface GetFirstDate { operator fun invoke(): Flow } class GetFirstDateImpl( private val dayRepository: DayRepository ) : GetFirstDate { override fun invoke(): Flow { return dayRepository.getFirstDay().map { it?.date ?: LocalDate.now() } } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/domain/usecase/GetSummary.kt ================================================ package pl.bartek537.forest.stats.domain.usecase import pl.bartek537.forest.core.domain.model.StatsSummary import pl.bartek537.forest.core.domain.model.of import pl.bartek537.forest.core.domain.repository.DayRepository interface GetSummary { suspend operator fun invoke(): StatsSummary } class GetSummaryImpl( private val dayRepository: DayRepository ) : GetSummary { override suspend operator fun invoke(): StatsSummary { val allDays = dayRepository.getAllDays() return StatsSummary.of(allDays) } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/domain/usecase/GetWeek.kt ================================================ package pl.bartek537.forest.stats.domain.usecase import kotlinx.coroutines.flow.Flow import pl.bartek537.forest.core.domain.model.Day import pl.bartek537.forest.core.domain.repository.DayRepository import java.time.LocalDate interface GetWeek { operator fun invoke(startingAt: LocalDate): Flow> } class GetWeekImpl( private val dayRepository: DayRepository ) : GetWeek { override fun invoke(startingAt: LocalDate): Flow> { val endingAt = startingAt.plusDays(6) return dayRepository.getDays(startingAt..endingAt) } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/domain/usecase/StatsChartPageUseCases.kt ================================================ package pl.bartek537.forest.stats.domain.usecase import pl.bartek537.forest.core.domain.repository.DayRepository class StatsChartPageUseCases( dayRepository: DayRepository ) { val getWeek: GetWeek = GetWeekImpl(dayRepository) } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/domain/usecase/StatsDetailsUseCases.kt ================================================ package pl.bartek537.forest.stats.domain.usecase import pl.bartek537.forest.core.domain.repository.DayRepository class StatsDetailsUseCases( dayRepository: DayRepository ) { val getFirstDate: GetFirstDate = GetFirstDateImpl(dayRepository) } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/domain/usecase/StatsSummaryUseCases.kt ================================================ package pl.bartek537.forest.stats.domain.usecase import pl.bartek537.forest.core.domain.repository.DayRepository class StatsSummaryUseCases( dayRepository: DayRepository ) { val getSummary: GetSummary = GetSummaryImpl(dayRepository) } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/presentation/ChartAdapter.kt ================================================ package pl.bartek537.forest.stats.presentation import android.content.res.ColorStateList import android.view.LayoutInflater import android.view.ViewGroup import androidx.annotation.AttrRes import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView.ViewHolder import pl.bartek537.forest.databinding.ItemChartBarBinding import pl.bartek537.forest.stats.util.getThemeColor class ChartAdapter( private val listener: OnValueSelected ) : ListAdapter, ChartAdapter.ChartItemViewHolder>(DiffCallback()) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChartItemViewHolder { val layoutInflater = LayoutInflater.from(parent.context) val binding = ItemChartBarBinding.inflate(layoutInflater, parent, false) return ChartItemViewHolder(binding) } override fun onBindViewHolder(holder: ChartItemViewHolder, position: Int) { val value = getItem(position) holder.bind(value, listener) } class ChartItemViewHolder( private val binding: ItemChartBarBinding ) : ViewHolder(binding.root) { fun bind(chartValue: ChartValue, listener: OnValueSelected) { binding.root.setOnClickListener { listener.onSelect(chartValue) } binding.textSupporting.apply { text = chartValue.label val color = context.getThemeColor(chartValue.textColor) setTextColor(color) } binding.barFilled.apply { val color = context.getThemeColor(chartValue.barColor) backgroundTintList = ColorStateList.valueOf(color) val params = layoutParams as ConstraintLayout.LayoutParams params.matchConstraintPercentHeight = chartValue.value.toFloat() requestLayout() } } } private class DiffCallback : DiffUtil.ItemCallback>() { override fun areItemsTheSame(oldItem: ChartValue, newItem: ChartValue): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: ChartValue, newItem: ChartValue): Boolean { return oldItem == newItem } } data class ChartValue( val id: T, val value: Double, val label: String, @field:AttrRes val barColor: Int, @field:AttrRes val textColor: Int, ) fun interface OnValueSelected { fun onSelect(value: ChartValue) } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/presentation/StatsChartFragment.kt ================================================ package pl.bartek537.forest.stats.presentation import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.viewpager2.adapter.FragmentStateAdapter import kotlinx.coroutines.launch import pl.bartek537.forest.databinding.FragmentStatsChartBinding import java.time.LocalDate import java.time.Period import java.time.format.DateTimeFormatter class StatsChartFragment : Fragment() { private val statsDetailsViewModel: StatsDetailsViewModel by activityViewModels { StatsDetailsViewModel.Factory } private lateinit var binding: FragmentStatsChartBinding private lateinit var chartPageAdapter: ChartPageAdapter private val dateFormatter = DateTimeFormatter.ofPattern("EEE, MMM dd") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentStatsChartBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) chartPageAdapter = ChartPageAdapter(this) binding.viewPagerChart.adapter = chartPageAdapter binding.buttonPreviousDay.setOnClickListener { changeSelectedDate(offset = -1) } binding.buttonNextDay.setOnClickListener { changeSelectedDate(offset = 1) } lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { statsDetailsViewModel.day.collect { updateUserInterface(it.date, it.chartDateRange) } } } } private fun changeSelectedDate(offset: Long) { val currentDate = statsDetailsViewModel.day.value.date statsDetailsViewModel.selectDay(currentDate.plusDays(offset)) } private fun updateUserInterface(selectedDate: LocalDate, dateRange: ClosedRange) { binding.apply { textSelectedDate.text = selectedDate.format(dateFormatter) buttonPreviousDay.isVisible = selectedDate.isAfter(dateRange.start) buttonNextDay.isVisible = selectedDate.isBefore(dateRange.endInclusive) chartPageAdapter.dateRange = dateRange scrollChartTo(selectedDate) } } private fun scrollChartTo( selectedDate: LocalDate, ) { val pageIndex = chartPageAdapter.getPageContaining(selectedDate) binding.viewPagerChart.currentItem = pageIndex } class ChartPageAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { var dateRange = LocalDate.now()..LocalDate.now() fun getPageContaining(selectedDate: LocalDate): Int { val period = Period.between(selectedDate, dateRange.endInclusive) return (period.days / 7).coerceIn(0, itemCount) } override fun getItemCount(): Int = dateRange.run { val period = Period.between(start, endInclusive) return period.days / 7 + 1 } override fun createFragment(position: Int): Fragment { val fragment = StatsChartPageFragment() fragment.arguments = Bundle().apply { putLong(StatsChartPageFragment.ARG_PAGE_NUMBER, position.toLong()) } return fragment } } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/presentation/StatsChartPageFragment.kt ================================================ package pl.bartek537.forest.stats.presentation import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import pl.bartek537.forest.core.domain.model.Day import pl.bartek537.forest.databinding.FragmentStatsPageChartBinding import pl.bartek537.forest.stats.util.toChartValues import java.lang.Integer.max import java.time.LocalDate class StatsChartPageFragment : Fragment() { companion object { const val ARG_PAGE_NUMBER = "__page_number" } private lateinit var binding: FragmentStatsPageChartBinding private val statsChartPageViewModel: StatsChartPageViewModel by viewModels { StatsChartPageViewModel.Factory } private val statsDetailsViewModel: StatsDetailsViewModel by activityViewModels { StatsDetailsViewModel.Factory } private var pageNumber: Long = 0 private val chartAdapter = ChartAdapter { statsDetailsViewModel.selectDay(it.id) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) pageNumber = arguments?.getLong(ARG_PAGE_NUMBER) ?: 0 } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentStatsPageChartBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding.recyclerViewChart.apply { adapter = chartAdapter } lifecycleScope.launch { val activeDayFlow = statsDetailsViewModel.day viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { launch { val weekFlow = statsChartPageViewModel.week weekFlow.combine(activeDayFlow) { week, activeDay -> updateUserInterface(week, activeDay.date) }.collect() } launch { activeDayFlow.collect { updateSelectedWeek(it.chartDateRange.endInclusive) } } } } } private fun updateUserInterface(week: List, activeDate: LocalDate) { val highestChartValue = week.maxOfOrNull { max(it.steps, it.goal) } ?: 1 val locale = resources.configuration.locales[0] val chartValues = week.toChartValues(highestChartValue, locale, activeDate) chartAdapter.submitList(chartValues) } private fun updateSelectedWeek(lastDate: LocalDate) { val daysToSubtract = 7 * pageNumber + 6 val firstDate = lastDate.minusDays(daysToSubtract) statsChartPageViewModel.selectWeek(firstDate) } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/presentation/StatsChartPageViewModel.kt ================================================ package pl.bartek537.forest.stats.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import pl.bartek537.forest.ForestApplication import pl.bartek537.forest.core.data.repository.DayRepositoryImpl import pl.bartek537.forest.core.domain.model.Day import pl.bartek537.forest.stats.domain.usecase.StatsChartPageUseCases import pl.bartek537.forest.stats.util.alignWeek import java.time.LocalDate class StatsChartPageViewModel( private val statsChartPageUseCases: StatsChartPageUseCases ) : ViewModel() { private val _week = MutableStateFlow>(emptyList()) val week: StateFlow> = _week.asStateFlow() private var getWeekJob: Job? = null fun selectWeek(firstDate: LocalDate) { getWeekJob?.cancel() getWeekJob = viewModelScope.launch { statsChartPageUseCases.getWeek(firstDate).collect { week -> _week.value = week.alignWeek(firstDate) } } } companion object Factory : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class, extras: CreationExtras): T { val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]) as ForestApplication val forestDatabase = application.forestDatabase val dayRepository = DayRepositoryImpl(forestDatabase.dayDao) val statsChartPageUseCases = StatsChartPageUseCases(dayRepository) return StatsChartPageViewModel(statsChartPageUseCases) as T } } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/presentation/StatsChartState.kt ================================================ package pl.bartek537.forest.stats.presentation import pl.bartek537.forest.core.domain.model.Day import java.time.LocalDate data class StatsChartState( val week: List, val dateRange: ClosedRange ) { companion object } fun StatsChartState.Companion.of(currentDate: LocalDate) = StatsChartState( week = emptyList(), dateRange = currentDate..currentDate ) ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/presentation/StatsDetailsFragment.kt ================================================ package pl.bartek537.forest.stats.presentation import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.launch import pl.bartek537.forest.R import pl.bartek537.forest.databinding.FragmentStatsDetailsBinding class StatsDetailsFragment : Fragment() { private val viewModel: StatsDetailsViewModel by activityViewModels { StatsDetailsViewModel } private lateinit var binding: FragmentStatsDetailsBinding override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentStatsDetailsBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { viewModel.day.collect { updateUserInterface(it) } } } } private fun updateUserInterface(state: StatsDetailsState) = state.apply { val stepsText = resources.getQuantityString( R.plurals.step_count_format, stepsTaken, stepsTaken ) val calorieText = getString( R.string.calorie_burned_format, calorieBurned ) val distanceText = getString( R.string.distance_travelled_format, distanceTravelled ) val carbonDioxideText = getString( R.string.carbon_dioxide_saved_format, carbonDioxideSaved ) binding.apply { textStepCount.text = stepsText viewGroupTree.isVisible = treeCollected textCalorieBurned.text = calorieText textDistanceTravelled.text = distanceText textCarbonDioxideSaved.text = carbonDioxideText } } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/presentation/StatsDetailsState.kt ================================================ package pl.bartek537.forest.stats.presentation import java.time.LocalDate data class StatsDetailsState( val date: LocalDate, val stepsTaken: Int, val treeCollected: Boolean, val calorieBurned: Int, val distanceTravelled: Double, val carbonDioxideSaved: Double, val chartDateRange: ClosedRange ) ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/presentation/StatsDetailsViewModel.kt ================================================ package pl.bartek537.forest.stats.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import pl.bartek537.forest.ForestApplication import pl.bartek537.forest.core.data.repository.DayRepositoryImpl import pl.bartek537.forest.core.domain.usecase.DayUseCases import pl.bartek537.forest.settings.data.repository.SettingsRepositoryImpl import pl.bartek537.forest.stats.domain.usecase.StatsDetailsUseCases import java.time.LocalDate import kotlin.math.roundToInt class StatsDetailsViewModel( private val dayUseCases: DayUseCases, statsDetailsUseCases: StatsDetailsUseCases, currentDateFlow: StateFlow ) : ViewModel() { private val _day = MutableStateFlow( StatsDetailsState( date = LocalDate.MIN, stepsTaken = 0, treeCollected = false, calorieBurned = 0, distanceTravelled = 0.0, carbonDioxideSaved = 0.0, chartDateRange = currentDateFlow.value..currentDateFlow.value ) ) val day: StateFlow = _day.asStateFlow() init { selectDay(currentDateFlow.value) viewModelScope.launch { val firstDateFlow = statsDetailsUseCases.getFirstDate() firstDateFlow .combine(currentDateFlow) { firstDate, currentDate -> firstDate..currentDate }.collect { dateRange -> _day.value = day.value.copy(chartDateRange = dateRange) } } } private var selectDateJob: Job? = null fun selectDay(date: LocalDate) { selectDateJob?.cancel() selectDateJob = dayUseCases.getDay(date).onEach { _day.value = day.value.copy( date = it.date, stepsTaken = it.steps, treeCollected = it.steps >= it.goal, calorieBurned = it.calorieBurned.roundToInt(), distanceTravelled = it.distanceTravelled, carbonDioxideSaved = it.carbonDioxideSaved ) }.launchIn(viewModelScope) } companion object Factory : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class, extras: CreationExtras): T { val application = checkNotNull(extras[APPLICATION_KEY]) as ForestApplication val dayDatabase = application.forestDatabase val dayRepository = DayRepositoryImpl(dayDatabase.dayDao) val settingsStore = application.settingsStore val settingsRepository = SettingsRepositoryImpl(settingsStore) val dayUseCases = DayUseCases(dayRepository, settingsRepository) val statsDetailsUseCases = StatsDetailsUseCases(dayRepository) return StatsDetailsViewModel( dayUseCases, statsDetailsUseCases, application.currentDate ) as T } } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/presentation/StatsSummaryFragment.kt ================================================ package pl.bartek537.forest.stats.presentation import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.launch import pl.bartek537.forest.R import pl.bartek537.forest.databinding.FragmentStatsSummaryBinding import kotlin.math.roundToInt class StatsSummaryFragment : Fragment() { private lateinit var binding: FragmentStatsSummaryBinding private val viewModel: StatsSummaryViewModel by viewModels { StatsSummaryViewModel } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentStatsSummaryBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding.swipeRefreshContainer.setOnRefreshListener { viewModel.refreshStatsSummary() } lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { viewModel.statsSummary.collect { updateUserInterface(it) } } } } private fun updateUserInterface(state: StatsSummaryState) = state.apply { val treesText = resources.getQuantityString( R.plurals.trees_collected_format, treesCollected, treesCollected ) val stepsText = resources.getQuantityString( R.plurals.step_count_format, stepsTaken.toInt(), stepsTaken ) val calorieText = getString( R.string.calorie_burned_format, calorieBurned.roundToInt() ) val distanceText = getString( R.string.distance_travelled_format, distanceTravelled ) val carbonDioxideText = getString( R.string.carbon_dioxide_saved_format, carbonDioxideSaved ) binding.apply { swipeRefreshContainer.isRefreshing = state.isRefreshing textTreesCollected.text = treesText textStepCount.text = stepsText textCalorieBurned.text = calorieText textDistanceTravelled.text = distanceText textCarbonDioxideSaved.text = carbonDioxideText } } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/presentation/StatsSummaryState.kt ================================================ package pl.bartek537.forest.stats.presentation data class StatsSummaryState( val isRefreshing: Boolean = false, val treesCollected: Int = 0, val stepsTaken: Long = 0L, val calorieBurned: Double = 0.0, val distanceTravelled: Double = 0.0, val carbonDioxideSaved: Double = 0.0, ) ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/presentation/StatsSummaryViewModel.kt ================================================ package pl.bartek537.forest.stats.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import pl.bartek537.forest.ForestApplication import pl.bartek537.forest.core.data.repository.DayRepositoryImpl import pl.bartek537.forest.stats.domain.usecase.StatsSummaryUseCases class StatsSummaryViewModel( private val statsSummaryUseCases: StatsSummaryUseCases ) : ViewModel() { private val _statsStatsSummary = MutableStateFlow(StatsSummaryState()) val statsSummary: StateFlow = _statsStatsSummary.asStateFlow() init { refreshStatsSummary() } private var refreshStatsSummaryJob: Job? = null fun refreshStatsSummary() { refreshStatsSummaryJob?.cancel() refreshStatsSummaryJob = viewModelScope.launch { _statsStatsSummary.value = statsSummary.value.copy( isRefreshing = true ) val updatedSummary = statsSummaryUseCases.getSummary() updatedSummary.run { _statsStatsSummary.value = statsSummary.value.copy( isRefreshing = false, treesCollected = treesCollected, stepsTaken = stepsTaken, calorieBurned = calorieBurned, distanceTravelled = distanceTravelled, carbonDioxideSaved = carbonDioxideSaved, ) } } } companion object Factory : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class, extras: CreationExtras): T { val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]) as ForestApplication val dayDatabase = application.forestDatabase val dayRepository = DayRepositoryImpl(dayDatabase.dayDao) val statsSummaryUseCases = StatsSummaryUseCases(dayRepository) return StatsSummaryViewModel(statsSummaryUseCases) as T } } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/util/ContextExtension.kt ================================================ package pl.bartek537.forest.stats.util import android.content.Context import android.util.TypedValue import androidx.annotation.AttrRes fun Context.getThemeColor( @AttrRes attrColor: Int ): Int { val typedValue = TypedValue() theme.resolveAttribute(attrColor, typedValue, true) return typedValue.data } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/util/DayExtension.kt ================================================ package pl.bartek537.forest.stats.util import pl.bartek537.forest.core.domain.model.Day import java.time.LocalDate fun List.alignWeek( firstDay: LocalDate, lastDay: LocalDate = firstDay.plusDays(6), ): List { val alignedWeek = mutableListOf() for (date in firstDay..lastDay) { val currentDay = singleOrNull { it.date == date } alignedWeek.add(currentDay ?: Day(date, goal = 0)) } return alignedWeek } ================================================ FILE: app/src/main/java/pl/bartek537/forest/stats/util/LocalDateExtension.kt ================================================ package pl.bartek537.forest.stats.util import com.google.android.material.R import pl.bartek537.forest.core.domain.model.Day import pl.bartek537.forest.stats.presentation.ChartAdapter import java.time.LocalDate import java.time.format.TextStyle import java.util.* operator fun ClosedRange.iterator() = object : Iterator { private var current = start.minusDays(1) override fun hasNext(): Boolean { return current.isBefore(endInclusive) } override fun next(): LocalDate { if (current.isBefore(endInclusive)) { current = current.plusDays(1) } return current } } fun List.toChartValues( max: Int, locale: Locale, activeDay: LocalDate ): List> = map { val value = it.steps / max.toDouble() val weekdayName = it.date.dayOfWeek.getDisplayName(TextStyle.SHORT, locale) val isSelected = it.date.isEqual(activeDay) val barColor = if (isSelected) android.R.attr.colorPrimary else R.attr.colorPrimaryContainer val textColor = if (isSelected) android.R.attr.colorPrimary else R.attr.colorOnSurface ChartAdapter.ChartValue( it.date, value = value, label = weekdayName, barColor = barColor, textColor = textColor ) } ================================================ FILE: app/src/main/java/pl/bartek537/forest/trees/ForestFragment.kt ================================================ package pl.bartek537.forest.trees import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.launch import pl.bartek537.forest.R import pl.bartek537.forest.databinding.FragmentForestBinding import kotlin.random.Random class ForestFragment : Fragment() { private val viewModel: ForestViewModel by viewModels { ForestViewModel.Factory } private lateinit var binding: FragmentForestBinding override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentForestBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { viewModel.trees.collect { updateUserInterface(it) } } } } private fun updateUserInterface(forestState: ForestState) { val treeCount = forestState.treeCount binding.apply { textTreesCollected.text = treeCount.toString() textTreesCollectedLabel.text = resources.getQuantityString(R.plurals.trees, treeCount) } generateTrees(forestState.treeCount) } private fun generateTrees(treeCount: Int) { val parentLayout = binding.constraintLayoutTrees parentLayout.removeAllViews() val gapCount = treeCount + 1 repeat(treeCount) { val fixedPosition = (it + 1.0) / gapCount val randomOffset = (Random.nextDouble() - 0.5) / 5 val horizontalPosition = fixedPosition + randomOffset createTree(parentLayout, horizontalPosition) } } private fun createTree(parentLayout: ConstraintLayout, horizontalPosition: Double) { val treeImageView = ImageView(context) treeImageView.setImageResource(R.drawable.tree_collected) parentLayout.addView(treeImageView) treeImageView.updateLayoutParams { startToStart = parentLayout.id endToEnd = parentLayout.id bottomToBottom = parentLayout.id horizontalBias = horizontalPosition.toFloat() } } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/trees/ForestState.kt ================================================ package pl.bartek537.forest.trees data class ForestState( val treeCount: Int ) ================================================ FILE: app/src/main/java/pl/bartek537/forest/trees/ForestViewModel.kt ================================================ package pl.bartek537.forest.trees import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import pl.bartek537.forest.ForestApplication import pl.bartek537.forest.core.data.repository.DayRepositoryImpl import pl.bartek537.forest.trees.domain.usecase.ForestUseCases class ForestViewModel( forestUseCases: ForestUseCases ) : ViewModel() { private val _trees = MutableStateFlow(ForestState(treeCount = 0)) val trees: StateFlow = _trees.asStateFlow() init { viewModelScope.launch { forestUseCases.getTreeCount().collect { _trees.value = _trees.value.copy( treeCount = it ) } } } object Factory : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class, extras: CreationExtras): T { val application = checkNotNull(extras[APPLICATION_KEY]) as ForestApplication val forestDatabase = application.forestDatabase val dayRepository = DayRepositoryImpl(forestDatabase.dayDao) val forestUseCases = ForestUseCases(dayRepository) return ForestViewModel(forestUseCases) as T } } } ================================================ FILE: app/src/main/java/pl/bartek537/forest/trees/domain/usecase/ForestUseCases.kt ================================================ package pl.bartek537.forest.trees.domain.usecase import pl.bartek537.forest.core.domain.repository.DayRepository class ForestUseCases( dayRepository: DayRepository ) { val getTreeCount: GetTreeCount = GetTreeCountImpl(dayRepository) } ================================================ FILE: app/src/main/java/pl/bartek537/forest/trees/domain/usecase/GetTreeCount.kt ================================================ package pl.bartek537.forest.trees.domain.usecase import kotlinx.coroutines.flow.Flow import pl.bartek537.forest.core.domain.repository.DayRepository interface GetTreeCount { operator fun invoke(): Flow } class GetTreeCountImpl( private val dayRepository: DayRepository ) : GetTreeCount { override fun invoke(): Flow { return dayRepository.getTreeCount() } } ================================================ FILE: app/src/main/res/drawable/bubble_chart_fill0_wght400_grad0_opsz24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/chevron_left_fill0_wght400_grad0_opsz24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/chevron_right_fill0_wght400_grad0_opsz24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/conversion_path_fill0_wght400_grad0_opsz24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/directions_walk_fill0_wght400_grad0_opsz48.xml ================================================ ================================================ FILE: app/src/main/res/drawable/do_not_disturb_on_fill0_wght400_grad0_opsz24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/forest_fill0_wght400_grad0_opsz24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/local_fire_department_fill0_wght400_grad0_opsz24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/nature_fill0_wght400_grad0_opsz24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_chart_bar.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_circle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_divider.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_ground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/show_chart_fill0_wght400_grad0_opsz24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/stage_1.xml ================================================ ================================================ FILE: app/src/main/res/drawable/stage_2.xml ================================================ ================================================ FILE: app/src/main/res/drawable/stage_3.xml ================================================ ================================================ FILE: app/src/main/res/drawable/stage_4.xml ================================================ ================================================ FILE: app/src/main/res/drawable/stage_5.xml ================================================ ================================================ FILE: app/src/main/res/drawable/stage_6.xml ================================================ ================================================ FILE: app/src/main/res/drawable/steps_fill0_wght400_grad0_opsz24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/tree_collected.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_main.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_onboarding.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_settings.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_activity_recognition_permission.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_forest.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_progress.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_stats.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_stats_chart.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_stats_details.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_stats_page_chart.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_stats_summary.xml ================================================ ================================================ FILE: app/src/main/res/layout/item_chart_bar.xml ================================================ ================================================ FILE: app/src/main/res/menu/bottom_navigation_menu.xml ================================================ ================================================ FILE: app/src/main/res/menu/main_menu.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: app/src/main/res/navigation/nav_graph.xml ================================================ ================================================ FILE: app/src/main/res/navigation/onboarding_nav_graph.xml ================================================ ================================================ FILE: app/src/main/res/values/colors.xml ================================================ #646106 #646100 #FFFFFF #ECE76D #1E1D00 #616042 #FFFFFF #E8E4BE #1D1C05 #3E6655 #FFFFFF #C0ECD6 #002116 #BA1A1A #FFDAD6 #FFFFFF #410002 #FFFBFF #1C1C16 #FFFBFF #1C1C16 #E7E3D1 #49473A #7A7768 #F4F0E7 #31302B #D0CB54 #000000 #646100 #CAC7B5 #000000 #D0CB54 #343200 #4B4900 #ECE76D #CBC8A4 #333118 #49482C #E8E4BE #A5D0BB #0D3729 #264E3E #C0ECD6 #FFB4AB #93000A #690005 #FFDAD6 #1C1C16 #E6E2D9 #1C1C16 #E6E2D9 #49473A #CAC7B5 #949181 #1C1C16 #E6E2D9 #646100 #000000 #D0CB54 #49473A #000000 ================================================ FILE: app/src/main/res/values/ic_launcher_background.xml ================================================ #EDEDE4 ================================================ FILE: app/src/main/res/values/strings.xml ================================================ Forest Step counter %d step today %d steps today %1$d kcal · %2$.2f km · %3$d%% of your daily goal steps Goal %s Continue You can revoke this permission in system settings, but keep in mind the app will stop working All your data is stored locally on your device and will not be shared with anyone Allow activity recognition This permission is required to detect and count the steps you take. Forest Tree Stats tree trees Carbon dioxide saved %.3f kg Calorie burned %d kcal Distance travelled %.2f km Previous day Next day Step count %d step %d steps Tree collected Trees collected %d tree %d trees Daily goal reached Details Summary Settings Goals Daily goal %d step %d steps Advanced Step length Height Weight Pace Slow Normal Fast 0.8 1 1.2 About GitHub Check out our GitHub repository and help us improve the app https://github.com/bk20dev/forest ================================================ FILE: app/src/main/res/values/themes.xml ================================================ ================================================ FILE: app/src/main/res/values-v29/themes.xml ================================================ ================================================ FILE: app/src/main/res/xml/backup_rules.xml ================================================ ================================================ FILE: app/src/main/res/xml/data_extraction_rules.xml ================================================ ================================================ FILE: app/src/main/res/xml/settings.xml ================================================ ================================================ FILE: app/src/test/java/pl/bartek537/forest/ExampleUnitTest.kt ================================================ package pl.bartek537.forest 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) } } ================================================ FILE: build.gradle ================================================ plugins { alias libs.plugins.android.application apply false alias libs.plugins.kotlin.android apply false alias libs.plugins.devtools.ksp apply false } ================================================ FILE: gradle/libs.versions.toml ================================================ [versions] activity = "1.11.0" agp = "8.13.0" appcompat = "1.7.1" constraintLayout = "2.2.1" coreKtx = "1.17.0" desugarJdkLibs = "2.1.5" espresso = "3.7.0" junit = "4.13.2" junitAndroid = "1.3.0" koin = "4.1.1" #noinspection NewerVersionAvailable Version 2.2.20-2.0.2 of com.google.devtools.ksp is not yet available. kotlin = "2.2.10" kotlinKsp = "2.2.10-2.0.2" lifecycle = "2.9.3" materialDesign = "1.13.0" navigation = "2.9.4" preference = "1.2.1" room = "2.8.0" swipeRefreshLayout = "1.1.0" [libraries] androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintLayout" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" } androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" } androidx-preference = { group = "androidx.preference", name = "preference", version.ref = "preference" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swipeRefreshLayout" } androidx-test-espresso = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitAndroid" } android-material-design = { group = "com.google.android.material", name = "material", version.ref = "materialDesign" } junit = { group = "junit", name = "junit", version.ref = "junit" } koin-androidx = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } tools-desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugarJdkLibs" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "kotlinKsp" } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ # 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. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true android.nonFinalResIds=false ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015-2021 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. # ############################################################################## # # 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/subprojects/plugins/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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || 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=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # 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, 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" \ org.gradle.wrapper.GradleWrapperMain \ "$@" # 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" "$@" ================================================ FILE: gradlew.bat ================================================ @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 @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. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :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 ================================================ FILE: settings.gradle ================================================ pluginManagement { repositories { google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } } rootProject.name = "Forest" include ':app'