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
================================================
.*:id
http://schemas.android.com/apk/res/android
.*:name
http://schemas.android.com/apk/res/android
.*
http://schemas.android.com/apk/res/android
ANDROID_ATTRIBUTE_ORDER
================================================
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.

## 🦁 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
- 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-night/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'