├── .gitignore
├── .idea
├── .gitignore
├── compiler.xml
├── deploymentTargetDropDown.xml
├── gradle.xml
├── inspectionProfiles
│ └── Project_Default.xml
├── misc.xml
└── vcs.xml
├── IncentiveTimer Play Store Screenshots
├── IncentiveTimer Play Store Screenshots
│ ├── screenshot_1.png
│ ├── screenshot_2.png
│ ├── screenshot_3.png
│ ├── screenshot_4.png
│ └── screenshot_5.png
├── Screenshot_1642856830.png
├── Screenshot_1642857018.png
├── Screenshot_1642857026.png
├── Screenshot_1642936250.png
└── Screenshot_1642936257.png
├── app
├── .gitignore
├── build.gradle
├── proguard-rules.pro
└── src
│ ├── main
│ ├── AndroidManifest.xml
│ ├── ic_launcher-playstore.png
│ ├── java
│ │ └── com
│ │ │ └── florianwalther
│ │ │ └── incentivetimer
│ │ │ ├── application
│ │ │ ├── ITActivity.kt
│ │ │ ├── ITActivityViewModel.kt
│ │ │ └── ITApplication.kt
│ │ │ ├── core
│ │ │ ├── BootReceiver.kt
│ │ │ ├── DailyResetBroadcastReceiver.kt
│ │ │ ├── notification
│ │ │ │ ├── DefaultNotificationHelper.kt
│ │ │ │ ├── NotificationHelper.kt
│ │ │ │ └── TimerNotificationBroadcastReceiver.kt
│ │ │ ├── ui
│ │ │ │ ├── Dimens.kt
│ │ │ │ ├── IconKey.kt
│ │ │ │ ├── composables
│ │ │ │ │ ├── AppInstructionsDialog.kt
│ │ │ │ │ ├── DropdownMenuButton.kt
│ │ │ │ │ ├── ITIconButton.kt
│ │ │ │ │ ├── LabeledCheckbox.kt
│ │ │ │ │ ├── LabeledRadioButton.kt
│ │ │ │ │ ├── NumberPicker.kt
│ │ │ │ │ ├── RoundedCornerCircularProgressIndicator.kt
│ │ │ │ │ ├── RoundedCornerCircularProgressIndicatorWithBackground.kt
│ │ │ │ │ └── SimpleConfirmationDialog.kt
│ │ │ │ ├── screenspecs
│ │ │ │ │ ├── AddEditRewardScreenSpec.kt
│ │ │ │ │ ├── BottomNavScreenSpec.kt
│ │ │ │ │ ├── RewardListScreenSpec.kt
│ │ │ │ │ ├── ScreenSpec.kt
│ │ │ │ │ ├── SettingsScreenSpec.kt
│ │ │ │ │ ├── StatisticsScreenSpec.kt
│ │ │ │ │ └── TimerScreenSpec.kt
│ │ │ │ └── theme
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ ├── Shape.kt
│ │ │ │ │ ├── Theme.kt
│ │ │ │ │ └── Type.kt
│ │ │ └── util
│ │ │ │ ├── ComposeUtils.kt
│ │ │ │ ├── DateTimeFormatUtils.kt
│ │ │ │ ├── DateUtils.kt
│ │ │ │ └── Utils.kt
│ │ │ ├── data
│ │ │ ├── datastore
│ │ │ │ ├── DefaultPomodoroTimerStateManager.kt
│ │ │ │ ├── DefaultPreferencesManager.kt
│ │ │ │ ├── PomodoroTimerStateManager.kt
│ │ │ │ └── PreferencesManager.kt
│ │ │ └── db
│ │ │ │ ├── ITDatabase.kt
│ │ │ │ ├── PomodoroStatistic.kt
│ │ │ │ ├── PomodoroStatisticDao.kt
│ │ │ │ ├── Reward.kt
│ │ │ │ └── RewardDao.kt
│ │ │ ├── di
│ │ │ └── AppModule.kt
│ │ │ └── features
│ │ │ ├── rewards
│ │ │ ├── RewardUnlockManager.kt
│ │ │ ├── addeditreward
│ │ │ │ ├── AddEditRewardScreen.kt
│ │ │ │ ├── AddEditRewardScreenActions.kt
│ │ │ │ ├── AddEditRewardViewModel.kt
│ │ │ │ └── model
│ │ │ │ │ └── AddEditRewardScreenState.kt
│ │ │ └── rewardlist
│ │ │ │ ├── RewardListActions.kt
│ │ │ │ ├── RewardListScreen.kt
│ │ │ │ ├── RewardListViewModel.kt
│ │ │ │ └── model
│ │ │ │ └── RewardListScreenState.kt
│ │ │ ├── settings
│ │ │ ├── SettingsScreen.kt
│ │ │ ├── SettingsScreenActions.kt
│ │ │ ├── SettingsViewModel.kt
│ │ │ └── model
│ │ │ │ └── SettingsScreenState.kt
│ │ │ ├── statistics
│ │ │ ├── StatisticsActions.kt
│ │ │ ├── StatisticsScreen.kt
│ │ │ ├── StatisticsViewModel.kt
│ │ │ └── model
│ │ │ │ ├── DailyPomodoroStatistic.kt
│ │ │ │ └── StatisticsScreenState.kt
│ │ │ └── timer
│ │ │ ├── CountDownTimer.kt
│ │ │ ├── DailyResetManager.kt
│ │ │ ├── DefaultTimeSource.kt
│ │ │ ├── DefaultTimerServiceManager.kt
│ │ │ ├── PomodoroTimerManager.kt
│ │ │ ├── TimeSource.kt
│ │ │ ├── TimerScreen.kt
│ │ │ ├── TimerScreenActions.kt
│ │ │ ├── TimerService.kt
│ │ │ ├── TimerServiceManager.kt
│ │ │ ├── TimerViewModel.kt
│ │ │ └── model
│ │ │ └── TimerScreenState.kt
│ └── res
│ │ ├── drawable
│ │ ├── ic_launcher_foreground.xml
│ │ ├── ic_play.xml
│ │ ├── ic_star.xml
│ │ ├── ic_stop.xml
│ │ └── ic_timer.xml
│ │ ├── mipmap-anydpi-v26
│ │ ├── ic_launcher.xml
│ │ └── ic_launcher_round.xml
│ │ ├── mipmap-hdpi
│ │ ├── ic_launcher.png
│ │ └── ic_launcher_round.png
│ │ ├── mipmap-mdpi
│ │ ├── ic_launcher.png
│ │ └── ic_launcher_round.png
│ │ ├── mipmap-xhdpi
│ │ ├── ic_launcher.png
│ │ └── ic_launcher_round.png
│ │ ├── mipmap-xxhdpi
│ │ ├── ic_launcher.png
│ │ └── ic_launcher_round.png
│ │ ├── mipmap-xxxhdpi
│ │ ├── ic_launcher.png
│ │ └── ic_launcher_round.png
│ │ ├── values-night
│ │ └── themes.xml
│ │ └── values
│ │ ├── colors.xml
│ │ ├── ic_launcher_background.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ └── test
│ └── java
│ └── com
│ └── florianwalther
│ └── incentivetimer
│ ├── LiveDataTestUtil.kt
│ ├── core
│ ├── notification
│ │ └── FakeNotificationHelper.kt
│ └── util
│ │ ├── DateUtilsTest.kt
│ │ └── TimeFormatTest.kt
│ ├── data
│ ├── datastore
│ │ ├── FakePomodoroTimerStateManager.kt
│ │ └── FakePreferencesManager.kt
│ └── db
│ │ ├── FakePomodoroStatisticDao.kt
│ │ └── FakeRewardDao.kt
│ └── features
│ ├── rewards
│ ├── addeditreward
│ │ ├── AddEditRewardViewModelTestWithRewardId.kt
│ │ ├── AddEditRewardViewModelTestWithSavedState.kt
│ │ └── AddEditRewardViewModelTestWithoutRewardId.kt
│ └── rewardlist
│ │ └── RewardListViewModelTest.kt
│ ├── statistics
│ └── StatisticsViewModelTest.kt
│ └── timer
│ ├── CountDownTimerTest.kt
│ ├── FakeTimeSource.kt
│ ├── FakeTimerServiceManager.kt
│ ├── PomodoroTimerManagerTest.kt
│ └── TimerViewModelTest.kt
├── build.gradle
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
└── settings.gradle
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 | .cxx
15 | local.properties
16 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/deploymentTargetDropDown.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
19 |
20 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/IncentiveTimer Play Store Screenshots/IncentiveTimer Play Store Screenshots/screenshot_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/IncentiveTimer Play Store Screenshots/IncentiveTimer Play Store Screenshots/screenshot_1.png
--------------------------------------------------------------------------------
/IncentiveTimer Play Store Screenshots/IncentiveTimer Play Store Screenshots/screenshot_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/IncentiveTimer Play Store Screenshots/IncentiveTimer Play Store Screenshots/screenshot_2.png
--------------------------------------------------------------------------------
/IncentiveTimer Play Store Screenshots/IncentiveTimer Play Store Screenshots/screenshot_3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/IncentiveTimer Play Store Screenshots/IncentiveTimer Play Store Screenshots/screenshot_3.png
--------------------------------------------------------------------------------
/IncentiveTimer Play Store Screenshots/IncentiveTimer Play Store Screenshots/screenshot_4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/IncentiveTimer Play Store Screenshots/IncentiveTimer Play Store Screenshots/screenshot_4.png
--------------------------------------------------------------------------------
/IncentiveTimer Play Store Screenshots/IncentiveTimer Play Store Screenshots/screenshot_5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/IncentiveTimer Play Store Screenshots/IncentiveTimer Play Store Screenshots/screenshot_5.png
--------------------------------------------------------------------------------
/IncentiveTimer Play Store Screenshots/Screenshot_1642856830.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/IncentiveTimer Play Store Screenshots/Screenshot_1642856830.png
--------------------------------------------------------------------------------
/IncentiveTimer Play Store Screenshots/Screenshot_1642857018.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/IncentiveTimer Play Store Screenshots/Screenshot_1642857018.png
--------------------------------------------------------------------------------
/IncentiveTimer Play Store Screenshots/Screenshot_1642857026.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/IncentiveTimer Play Store Screenshots/Screenshot_1642857026.png
--------------------------------------------------------------------------------
/IncentiveTimer Play Store Screenshots/Screenshot_1642936250.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/IncentiveTimer Play Store Screenshots/Screenshot_1642936250.png
--------------------------------------------------------------------------------
/IncentiveTimer Play Store Screenshots/Screenshot_1642936257.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/IncentiveTimer Play Store Screenshots/Screenshot_1642936257.png
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.application'
3 | id 'kotlin-android'
4 | id 'kotlin-kapt'
5 | id 'dagger.hilt.android.plugin'
6 | id 'kotlin-parcelize'
7 | }
8 |
9 | android {
10 | compileSdk 31
11 |
12 | defaultConfig {
13 | applicationId "com.florianwalther.incentivetimer"
14 | minSdk 21
15 | targetSdk 31
16 | versionCode 1
17 | versionName "1.0"
18 |
19 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
20 | vectorDrawables {
21 | useSupportLibrary true
22 | }
23 | }
24 |
25 | buildTypes {
26 | release {
27 | minifyEnabled true
28 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
29 | }
30 | }
31 | compileOptions {
32 | sourceCompatibility JavaVersion.VERSION_1_8
33 | targetCompatibility JavaVersion.VERSION_1_8
34 | }
35 | kotlinOptions {
36 | jvmTarget = '1.8'
37 | useIR = true
38 | }
39 | buildFeatures {
40 | compose true
41 | }
42 | composeOptions {
43 | kotlinCompilerExtensionVersion '1.1.0-rc02'
44 | kotlinCompilerVersion '1.6.10'
45 | }
46 | packagingOptions {
47 | resources {
48 | excludes += '/META-INF/{AL2.0,LGPL2.1}'
49 | }
50 | }
51 | }
52 |
53 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
54 | kotlinOptions {
55 | freeCompilerArgs += ["-Xuse-experimental=androidx.compose.material.ExperimentalMaterialApi"]
56 | freeCompilerArgs += ["-Xuse-experimental=androidx.compose.animation.ExperimentalAnimationApi"]
57 | freeCompilerArgs += ["-Xuse-experimental=androidx.compose.ui.ExperimentalComposeUiApi"]
58 | freeCompilerArgs += ["-Xuse-experimental=androidx.compose.foundation.ExperimentalFoundationApi"]
59 | freeCompilerArgs += ["-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi"]
60 | }
61 | }
62 |
63 | dependencies {
64 |
65 | implementation 'androidx.core:core-ktx:1.7.0'
66 | implementation 'androidx.appcompat:appcompat:1.4.0'
67 | implementation 'com.google.android.material:material:1.4.0'
68 | implementation "androidx.compose.ui:ui:$compose_version"
69 | implementation "androidx.compose.material:material:$compose_version"
70 | implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
71 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
72 | implementation 'androidx.activity:activity-compose:1.4.0'
73 | debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
74 |
75 | // Compose UI Util
76 | implementation "androidx.compose.ui:ui-util:$compose_version"
77 |
78 | // Compose Navigation
79 | implementation "androidx.navigation:navigation-compose:2.4.0-rc01"
80 |
81 | // Compose Extended Icons
82 | implementation "androidx.compose.material:material-icons-extended:$compose_version"
83 |
84 | // ViewModel + LiveData
85 | def lifecycle_version = "2.4.0"
86 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
87 | implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
88 | implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
89 | implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
90 | implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version"
91 | kapt "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
92 |
93 | // DataStore
94 | implementation "androidx.datastore:datastore-preferences:1.0.0"
95 |
96 | // Room
97 | def room_version = "2.4.1"
98 | implementation "androidx.room:room-runtime:$room_version"
99 | kapt "androidx.room:room-compiler:$room_version"
100 | implementation "androidx.room:room-ktx:$room_version"
101 |
102 | // Hilt
103 | implementation "com.google.dagger:hilt-android:2.38.1"
104 | kapt "com.google.dagger:hilt-compiler:2.38.1"
105 | implementation 'androidx.hilt:hilt-navigation-compose:1.0.0'
106 |
107 | // Accompanist libraries
108 | implementation "com.google.accompanist:accompanist-flowlayout:0.21.4-beta"
109 | implementation "com.google.accompanist:accompanist-systemuicontroller:0.24.1-alpha"
110 |
111 | // Logcat
112 | implementation 'com.squareup.logcat:logcat:0.1'
113 |
114 | // Zhuinden flow-combinetuple
115 | implementation 'com.github.Zhuinden:flow-combinetuple-kt:1.1.1'
116 |
117 | // MPAndroidChart
118 | implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
119 |
120 | // Testing dependencies
121 |
122 | // Default dependencies
123 | testImplementation 'junit:junit:4.+'
124 | androidTestImplementation 'androidx.test.ext:junit:1.1.3'
125 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
126 | androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
127 |
128 | // Coroutines
129 | testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
130 |
131 | // Architecture Components
132 | testImplementation 'androidx.arch.core:core-testing:2.1.0'
133 |
134 | // Truth
135 | testImplementation "com.google.truth:truth:1.1.3"
136 | testImplementation "com.google.truth.extensions:truth-java8-extension:1.1.3"
137 |
138 | // Turbine
139 | testImplementation 'app.cash.turbine:turbine:0.7.0'
140 |
141 | // MockK
142 | testImplementation "io.mockk:mockk:1.12.2"
143 | }
144 |
145 | kapt {
146 | correctErrorTypes true
147 | }
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
16 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/app/src/main/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/app/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/application/ITActivity.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.application
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.compose.foundation.isSystemInDarkTheme
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.material.*
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.runtime.getValue
11 | import androidx.compose.runtime.livedata.observeAsState
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.res.stringResource
14 | import androidx.compose.ui.tooling.preview.Preview
15 | import androidx.hilt.navigation.compose.hiltViewModel
16 | import androidx.navigation.NavDestination.Companion.hierarchy
17 | import androidx.navigation.NavGraph.Companion.findStartDestination
18 | import androidx.navigation.compose.NavHost
19 | import androidx.navigation.compose.composable
20 | import androidx.navigation.compose.currentBackStackEntryAsState
21 | import androidx.navigation.compose.rememberNavController
22 | import com.florianwalther.incentivetimer.core.ui.composables.AppInstructionsDialog
23 | import com.florianwalther.incentivetimer.core.ui.screenspecs.*
24 | import com.florianwalther.incentivetimer.core.ui.theme.IncentiveTimerTheme
25 | import com.florianwalther.incentivetimer.data.datastore.ThemeSelection
26 | import dagger.hilt.android.AndroidEntryPoint
27 |
28 | @AndroidEntryPoint
29 | class ITActivity : ComponentActivity() {
30 |
31 | override fun onCreate(savedInstanceState: Bundle?) {
32 | super.onCreate(savedInstanceState)
33 | setContent {
34 | val activityViewModel: ITActivityViewModel = hiltViewModel()
35 | val appPreferences by activityViewModel.appPreferences.observeAsState()
36 |
37 | appPreferences?.let { appPreferences ->
38 | val showAppInstructionsDialog = !appPreferences.appInstructionsDialogShown
39 | val darkTheme = when (appPreferences.selectedTheme) {
40 | ThemeSelection.SYSTEM -> isSystemInDarkTheme()
41 | ThemeSelection.LIGHT -> false
42 | ThemeSelection.DARK -> true
43 | }
44 | IncentiveTimerTheme(
45 | darkTheme = darkTheme
46 | ) {
47 | ScreenContent(
48 | showAppInstructionsDialog = showAppInstructionsDialog,
49 | onAppInstructionsDialogDismissed = activityViewModel::onAppInstructionsDialogDismissed
50 | )
51 | }
52 | }
53 | }
54 | }
55 | }
56 |
57 | @Composable
58 | private fun ScreenContent(
59 | showAppInstructionsDialog: Boolean,
60 | onAppInstructionsDialogDismissed: () -> Unit,
61 | ) {
62 | val navController = rememberNavController()
63 | val navBackStackEntry by navController.currentBackStackEntryAsState()
64 | val currentDestination = navBackStackEntry?.destination
65 |
66 | val screenSpec = ScreenSpec.allScreens[currentDestination?.route]
67 |
68 | Scaffold(
69 | topBar = {
70 | val navBackStackEntry = navBackStackEntry
71 | if (navBackStackEntry != null) {
72 | screenSpec?.TopBar(navController, navBackStackEntry)
73 | }
74 | },
75 | bottomBar = {
76 | val hideBottomBar = navBackStackEntry?.arguments?.getBoolean(ARG_HIDE_BOTTOM_BAR)
77 |
78 | if (hideBottomBar == null || !hideBottomBar) {
79 | BottomNavigation {
80 | BottomNavScreenSpec.screens.forEach { bottomNavDestination ->
81 | BottomNavigationItem(
82 | icon = {
83 | Icon(bottomNavDestination.icon, contentDescription = null)
84 | },
85 | label = {
86 | Text(stringResource(bottomNavDestination.label))
87 | },
88 | alwaysShowLabel = false,
89 | selected = currentDestination?.hierarchy?.any { it.route == bottomNavDestination.navHostRoute } == true,
90 | onClick = {
91 | navController.navigate(bottomNavDestination.navHostRoute) {
92 | popUpTo(navController.graph.findStartDestination().id) {
93 | saveState = true
94 | }
95 | launchSingleTop = true
96 | restoreState = true
97 | }
98 | },
99 | )
100 | }
101 | }
102 | }
103 | }
104 | ) { innerPadding ->
105 | NavHost(
106 | navController = navController,
107 | startDestination = BottomNavScreenSpec.screens[0].navHostRoute,
108 | modifier = Modifier.padding(innerPadding),
109 | ) {
110 | ScreenSpec.allScreens.values.forEach { screen ->
111 | composable(
112 | route = screen.navHostRoute,
113 | arguments = screen.arguments,
114 | deepLinks = screen.deepLinks,
115 | ) { navBackStackEntry ->
116 | screen.Content(
117 | navController = navController,
118 | navBackStackEntry = navBackStackEntry
119 | )
120 | }
121 | }
122 | }
123 | }
124 |
125 | if (showAppInstructionsDialog) {
126 | AppInstructionsDialog(onDismissRequest = onAppInstructionsDialogDismissed)
127 | }
128 | }
129 |
130 | @Preview(showBackground = true)
131 | @Composable
132 | fun ScreenContentPreview() {
133 | IncentiveTimerTheme {
134 | ScreenContent(
135 | showAppInstructionsDialog = false,
136 | onAppInstructionsDialogDismissed = {}
137 | )
138 | }
139 | }
140 |
141 | const val ARG_HIDE_BOTTOM_BAR = "ARG_HIDE_BOTTOM_BAR"
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/application/ITActivityViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.application
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.asLiveData
5 | import androidx.lifecycle.viewModelScope
6 | import com.florianwalther.incentivetimer.data.datastore.PreferencesManager
7 | import dagger.hilt.android.lifecycle.HiltViewModel
8 | import kotlinx.coroutines.flow.map
9 | import kotlinx.coroutines.launch
10 | import javax.inject.Inject
11 |
12 | @HiltViewModel
13 | class ITActivityViewModel @Inject constructor(
14 | private val preferencesManager: PreferencesManager
15 | ) : ViewModel() {
16 | val appPreferences = preferencesManager.appPreferences.asLiveData()
17 |
18 | fun onAppInstructionsDialogDismissed() {
19 | viewModelScope.launch {
20 | preferencesManager.updateAppInstructionsDialogShown(true)
21 | }
22 | }
23 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/application/ITApplication.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.application
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 | import logcat.AndroidLogcatLogger
6 | import logcat.LogPriority
7 |
8 | @HiltAndroidApp
9 | class ITApplication : Application() {
10 |
11 | override fun onCreate() {
12 | super.onCreate()
13 | AndroidLogcatLogger.installOnDebuggableApp(this, minPriority = LogPriority.VERBOSE)
14 | }
15 | }
16 |
17 | // TODO: 23/01/2022 Remove orientation lock & implement layout variations
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/BootReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import com.florianwalther.incentivetimer.features.timer.DailyResetManager
7 | import dagger.hilt.android.AndroidEntryPoint
8 | import logcat.logcat
9 | import javax.inject.Inject
10 |
11 | @AndroidEntryPoint
12 | class BootReceiver : BroadcastReceiver() {
13 |
14 | @Inject
15 | lateinit var dailyResetManager: DailyResetManager
16 |
17 | override fun onReceive(context: Context?, intent: Intent?) {
18 | if (intent?.action == "android.intent.action.BOOT_COMPLETED") {
19 | dailyResetManager.scheduleDailyReset()
20 | }
21 | }
22 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/DailyResetBroadcastReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import com.florianwalther.incentivetimer.core.notification.DefaultNotificationHelper
7 | import com.florianwalther.incentivetimer.data.datastore.PomodoroPhase
8 | import com.florianwalther.incentivetimer.data.datastore.PomodoroTimerStateManager
9 | import com.florianwalther.incentivetimer.di.ApplicationScope
10 | import dagger.hilt.android.AndroidEntryPoint
11 | import kotlinx.coroutines.CoroutineScope
12 | import kotlinx.coroutines.flow.first
13 | import kotlinx.coroutines.launch
14 | import logcat.logcat
15 | import javax.inject.Inject
16 |
17 | @AndroidEntryPoint
18 | class DailyResetBroadcastReceiver : BroadcastReceiver() {
19 |
20 | @Inject
21 | lateinit var notificationHelper: DefaultNotificationHelper
22 |
23 | @Inject
24 | lateinit var timerStateManager: PomodoroTimerStateManager
25 |
26 | @ApplicationScope
27 | @Inject
28 | lateinit var applicationScope: CoroutineScope
29 |
30 | override fun onReceive(context: Context?, intent: Intent?) {
31 | notificationHelper.removeResumeTimerNotification()
32 | applicationScope.launch {
33 | timerStateManager.apply {
34 | val timeTargetInMillis = timerStateManager.timerState.first().timeTargetInMillis
35 | updateTimeLeftInMillis(timeTargetInMillis)
36 | updateCurrentPhase(PomodoroPhase.POMODORO)
37 | updatePomodorosCompletedInSet(0)
38 | updatePomodorosCompletedTotal(0)
39 | }
40 | }
41 | }
42 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/notification/NotificationHelper.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.notification
2 |
3 | import androidx.core.app.NotificationCompat
4 | import com.florianwalther.incentivetimer.data.datastore.PomodoroPhase
5 | import com.florianwalther.incentivetimer.data.db.Reward
6 |
7 | interface NotificationHelper {
8 | fun getBaseTimerServiceNotification(): NotificationCompat.Builder
9 | fun updateTimerServiceNotification(
10 | currentPhase: PomodoroPhase,
11 | timeLeftInMillis: Long,
12 | timerRunning: Boolean
13 | )
14 |
15 | fun showResumeTimerNotification(
16 | currentPhase: PomodoroPhase,
17 | timeLeftInMillis: Long,
18 | )
19 |
20 | fun showTimerCompletedNotification(finishedPhase: PomodoroPhase)
21 | fun showRewardUnlockedNotification(reward: Reward)
22 | fun removeTimerServiceNotification()
23 | fun removeTimerCompletedNotification()
24 | fun removeResumeTimerNotification()
25 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/notification/TimerNotificationBroadcastReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.notification
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import com.florianwalther.incentivetimer.data.datastore.PomodoroPhase
7 | import com.florianwalther.incentivetimer.features.timer.PomodoroTimerManager
8 | import dagger.hilt.android.AndroidEntryPoint
9 | import javax.inject.Inject
10 |
11 | @AndroidEntryPoint
12 | class TimerNotificationBroadcastReceiver : BroadcastReceiver() {
13 |
14 | @Inject
15 | lateinit var pomodoroTimerManager: PomodoroTimerManager
16 |
17 | @Inject
18 | lateinit var notificationHelper: DefaultNotificationHelper
19 |
20 | override fun onReceive(p0: Context?, intent: Intent?) {
21 | val timerRunning = intent?.getBooleanExtra(EXTRA_TIMER_RUNNING, false)
22 | pomodoroTimerManager.startStopTimer()
23 | if (timerRunning == true) {
24 | val currentPhase = intent.getSerializableExtra(EXTRA_POMODORO_PHASE) as? PomodoroPhase
25 | val timeLeftInMillis = intent.getLongExtra(EXTRA_TIME_LEFT_IN_MILLIS, -1)
26 | if (currentPhase != null && timeLeftInMillis != -1L) {
27 | notificationHelper.showResumeTimerNotification(
28 | currentPhase = currentPhase,
29 | timeLeftInMillis = timeLeftInMillis
30 | )
31 | }
32 | }
33 | }
34 | }
35 |
36 | const val EXTRA_TIMER_RUNNING = "EXTRA_TIMER_RUNNING"
37 | const val EXTRA_POMODORO_PHASE = "EXTRA_POMODORO_PHASE"
38 | const val EXTRA_TIME_LEFT_IN_MILLIS = "EXTRA_TIME_LEFT_IN_MILLIS"
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/Dimens.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui
2 |
3 | import androidx.compose.ui.unit.dp
4 |
5 | val ListBottomPadding = 64.dp
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/IconKey.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui
2 |
3 | import androidx.compose.material.icons.Icons
4 | import androidx.compose.material.icons.filled.*
5 | import androidx.compose.ui.graphics.vector.ImageVector
6 |
7 | enum class IconKey(val rewardIcon: ImageVector) {
8 | STAR(Icons.Default.Star),
9 | CAKE(Icons.Default.Cake),
10 | BATH_TUB(Icons.Default.Bathtub),
11 | TV(Icons.Default.Tv),
12 | FAVORITE(Icons.Default.Favorite),
13 | PETS(Icons.Default.Pets),
14 | PHONE(Icons.Default.Phone),
15 | GIFT_CARD(Icons.Default.CardGiftcard),
16 | GAME_PAD(Icons.Default.Gamepad),
17 | MONEY(Icons.Default.Money),
18 | COMPUTER(Icons.Default.Computer),
19 | GROUP(Icons.Default.Group),
20 | HAPPY(Icons.Default.Mood),
21 | BEVERAGE(Icons.Default.EmojiFoodBeverage),
22 | MOTORBIKE(Icons.Default.SportsMotorsports),
23 | FOOTBALL(Icons.Default.SportsFootball),
24 | HEADPHONES(Icons.Default.Headphones),
25 | SHOPPING_CART(Icons.Default.ShoppingCart),
26 | BICYCLE(Icons.Default.DirectionsBike),
27 | PIZZA(Icons.Default.LocalPizza),
28 |
29 | }
30 |
31 | val defaultRewardIconKey = IconKey.STAR
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/composables/AppInstructionsDialog.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.composables
2 |
3 | import androidx.compose.material.AlertDialog
4 | import androidx.compose.material.Text
5 | import androidx.compose.material.TextButton
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.res.stringResource
8 | import com.florianwalther.incentivetimer.R
9 |
10 | @Composable
11 | fun AppInstructionsDialog(
12 | onDismissRequest: () -> Unit,
13 | ) {
14 | AlertDialog(
15 | onDismissRequest = onDismissRequest,
16 | text = { Text(stringResource(R.string.app_instructions_text)) },
17 | title = { Text(stringResource(R.string.instructions)) },
18 |
19 | confirmButton = {
20 | TextButton(onClick = onDismissRequest) {
21 | Text(stringResource(R.string.ok))
22 | }
23 | }
24 | )
25 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/composables/DropdownMenuButton.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.composables
2 |
3 | import android.content.res.Configuration
4 | import androidx.annotation.StringRes
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.material.*
7 | import com.florianwalther.incentivetimer.R
8 | import androidx.compose.material.icons.Icons
9 | import androidx.compose.material.icons.filled.ArrowDropDown
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.runtime.mutableStateOf
12 | import androidx.compose.runtime.saveable.rememberSaveable
13 | import androidx.compose.runtime.getValue
14 | import androidx.compose.runtime.setValue
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.res.stringResource
17 | import androidx.compose.ui.tooling.preview.Preview
18 | import com.florianwalther.incentivetimer.core.ui.theme.IncentiveTimerTheme
19 |
20 | @Composable
21 | fun DropdownMenuButton(
22 | @StringRes optionsLabels: List,
23 | selectedIndex: Int,
24 | onOptionSelected: (index: Int) -> Unit,
25 | modifier: Modifier = Modifier,
26 | ) {
27 | var expanded by rememberSaveable { mutableStateOf(false) }
28 | val buttonText = if (optionsLabels.isNotEmpty()) {
29 | stringResource(optionsLabels[selectedIndex])
30 | } else {
31 | stringResource(R.string.drop_down_menu_button_empty_options)
32 | }
33 |
34 | Box(modifier) {
35 | TextButton(onClick = { expanded = !expanded }) {
36 | Text(buttonText, color = MaterialTheme.colors.onSurface)
37 | Icon(imageVector = Icons.Filled.ArrowDropDown, tint = MaterialTheme.colors.onSurface, contentDescription = null)
38 | }
39 | DropdownMenu(
40 | expanded = expanded,
41 | onDismissRequest = { expanded = false }) {
42 | optionsLabels.forEachIndexed { index, optionLabel ->
43 | DropdownMenuItem(
44 | onClick = {
45 | onOptionSelected(index)
46 | expanded = false
47 | }) {
48 | Text(stringResource(optionLabel))
49 | }
50 | }
51 | }
52 | }
53 | }
54 |
55 | @Preview(
56 | name = "Light mode",
57 | uiMode = Configuration.UI_MODE_NIGHT_NO
58 | )
59 | @Preview(
60 | name = "Dark mode",
61 | uiMode = Configuration.UI_MODE_NIGHT_YES,
62 | )
63 | @Composable
64 | private fun ScreenContentPreview() {
65 | IncentiveTimerTheme {
66 | Surface {
67 | DropdownMenuButton(
68 | optionsLabels = listOf(R.string.last_7_days, R.string.all_time),
69 | selectedIndex = 1,
70 | onOptionSelected = {},
71 | )
72 | }
73 | }
74 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/composables/ITIconButton.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.composables
2 |
3 | import android.content.res.Configuration
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.interaction.MutableInteractionSource
6 | import androidx.compose.foundation.isSystemInDarkTheme
7 | import androidx.compose.foundation.layout.Box
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.foundation.shape.RoundedCornerShape
10 | import androidx.compose.material.Icon
11 | import androidx.compose.material.IconButton
12 | import androidx.compose.material.Surface
13 | import androidx.compose.material.icons.Icons
14 | import androidx.compose.material.icons.filled.Star
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.runtime.remember
17 | import androidx.compose.ui.Alignment
18 | import androidx.compose.ui.Modifier
19 | import androidx.compose.ui.draw.clip
20 | import androidx.compose.ui.graphics.Color
21 | import androidx.compose.ui.res.stringResource
22 | import androidx.compose.ui.tooling.preview.Preview
23 | import androidx.compose.ui.unit.dp
24 | import com.florianwalther.incentivetimer.R
25 | import com.florianwalther.incentivetimer.core.ui.theme.IncentiveTimerTheme
26 |
27 | @Composable
28 | fun ITIconButton(
29 | onClick: () -> Unit,
30 | modifier: Modifier = Modifier,
31 | enabled: Boolean = true,
32 | content: @Composable () -> Unit,
33 | ) {
34 | val iconButtonBackground = if (isSystemInDarkTheme() ) Color.Gray else Color.LightGray
35 | IconButton(
36 | onClick = onClick,
37 | modifier = modifier
38 | .clip(RoundedCornerShape(10.dp))
39 | .background(iconButtonBackground)
40 | ) {
41 | content()
42 | }
43 | }
44 |
45 | @Preview(
46 | name = "Light mode",
47 | uiMode = Configuration.UI_MODE_NIGHT_NO,
48 | showBackground = true,
49 | )
50 | @Preview(
51 | name = "Dark mode",
52 | uiMode = Configuration.UI_MODE_NIGHT_YES,
53 | showBackground = true,
54 | )
55 | @Composable
56 | private fun ScreenContentPreview() {
57 | IncentiveTimerTheme {
58 | Surface {
59 | Box(Modifier.padding(64.dp), contentAlignment = Alignment.Center) {
60 | ITIconButton(
61 | onClick = {},
62 | ) {
63 | Icon(Icons.Default.Star, contentDescription = stringResource(R.string.select_icon))
64 | }
65 | }
66 | }
67 | }
68 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/composables/LabeledCheckbox.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.composables
2 |
3 | import android.content.res.Configuration
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.interaction.MutableInteractionSource
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.Spacer
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.foundation.layout.width
10 | import androidx.compose.material.*
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.remember
13 | import androidx.compose.ui.Alignment
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.tooling.preview.Preview
16 | import androidx.compose.ui.unit.dp
17 | import com.florianwalther.incentivetimer.core.ui.theme.IncentiveTimerTheme
18 |
19 | @Composable
20 | fun LabeledCheckbox(
21 | text: String,
22 | checked: Boolean,
23 | onCheckedChange: ((Boolean) -> Unit)?,
24 | modifier: Modifier = Modifier,
25 | enabled: Boolean = true,
26 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
27 | colors: CheckboxColors = CheckboxDefaults.colors(),
28 | ) {
29 | Row(modifier.clickable { onCheckedChange?.invoke(!checked) }, verticalAlignment = Alignment.CenterVertically) {
30 | Checkbox(
31 | checked = checked,
32 | onCheckedChange = onCheckedChange,
33 | enabled = enabled,
34 | interactionSource = interactionSource,
35 | colors = colors,
36 | )
37 | Text(text, modifier = Modifier.padding(end = 16.dp))
38 | }
39 | }
40 |
41 | @Preview(
42 | name = "Light mode",
43 | uiMode = Configuration.UI_MODE_NIGHT_NO,
44 | showBackground = true,
45 | )
46 | @Preview(
47 | name = "Dark mode",
48 | uiMode = Configuration.UI_MODE_NIGHT_YES,
49 | showBackground = true,
50 | )
51 | @Composable
52 | private fun LabeledCheckboxPreview() {
53 | IncentiveTimerTheme() {
54 | Surface {
55 | LabeledCheckbox(checked = true, onCheckedChange = {}, text = "This is the checkbox label")
56 | }
57 | }
58 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/composables/LabeledRadioButton.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.composables
2 |
3 | import android.content.res.Configuration
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.material.RadioButton
9 | import androidx.compose.material.Surface
10 | import androidx.compose.material.Text
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.Alignment
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.tooling.preview.Preview
15 | import androidx.compose.ui.unit.dp
16 | import com.florianwalther.incentivetimer.core.ui.theme.IncentiveTimerTheme
17 |
18 | @Composable
19 | fun LabeledRadioButton(
20 | text: String,
21 | selected: Boolean,
22 | onClick: () -> Unit,
23 | modifier: Modifier = Modifier,
24 | ) {
25 | Row(
26 | modifier
27 | .fillMaxWidth()
28 | .clickable(onClick = onClick),
29 | verticalAlignment = Alignment.CenterVertically
30 | ) {
31 | RadioButton(
32 | selected = selected,
33 | onClick = onClick,
34 | )
35 | Text(text, modifier = Modifier.padding(end = 16.dp))
36 | }
37 | }
38 |
39 | @Preview(
40 | name = "Light mode",
41 | uiMode = Configuration.UI_MODE_NIGHT_NO,
42 | showBackground = true,
43 | )
44 | @Preview(
45 | name = "Dark mode",
46 | uiMode = Configuration.UI_MODE_NIGHT_YES,
47 | showBackground = true,
48 | )
49 | @Composable
50 | private fun LabeledCheckboxPreview() {
51 | IncentiveTimerTheme() {
52 | Surface {
53 | LabeledRadioButton(text = "Label", selected = true, onClick = {})
54 | }
55 | }
56 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/composables/NumberPicker.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.composables
2 |
3 | import android.widget.NumberPicker
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.viewinterop.AndroidView
8 |
9 | @Composable
10 | fun NumberPicker(
11 | minValue: Int,
12 | maxValue: Int,
13 | value: Int,
14 | modifier: Modifier = Modifier,
15 | onValueChanged: (newVal: Int) -> Unit,
16 | ) {
17 | AndroidView(
18 | modifier = modifier,
19 | factory = { context ->
20 | NumberPicker(context).apply {
21 | this.minValue = minValue
22 | this.maxValue = maxValue
23 | this.value = value
24 | this.setOnValueChangedListener { _, _, newVal ->
25 | onValueChanged(newVal)
26 | }
27 | }
28 | }
29 | )
30 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/composables/RoundedCornerCircularProgressIndicator.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.composables
2 |
3 | import androidx.compose.foundation.Canvas
4 | import androidx.compose.foundation.focusable
5 | import androidx.compose.foundation.layout.size
6 | import androidx.compose.foundation.progressSemantics
7 | import androidx.compose.material.*
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.geometry.Offset
11 | import androidx.compose.ui.geometry.Size
12 | import androidx.compose.ui.graphics.Color
13 | import androidx.compose.ui.graphics.StrokeCap
14 | import androidx.compose.ui.graphics.drawscope.DrawScope
15 | import androidx.compose.ui.graphics.drawscope.Stroke
16 | import androidx.compose.ui.platform.LocalDensity
17 | import androidx.compose.ui.unit.Dp
18 | import androidx.compose.ui.unit.dp
19 |
20 | @Composable
21 | fun RoundedCornerCircularProgressIndicator(
22 | /*@FloatRange(from = 0.0, to = 1.0)*/
23 | progress: Float,
24 | modifier: Modifier = Modifier,
25 | color: Color = MaterialTheme.colors.primary,
26 | strokeWidth: Dp = ProgressIndicatorDefaults.StrokeWidth
27 | ) {
28 | val stroke = with(LocalDensity.current) {
29 | Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Round)
30 | }
31 | Canvas(
32 | modifier
33 | .progressSemantics(progress)
34 | .size(CircularIndicatorDiameter)
35 | .focusable()
36 | ) {
37 | // Start at 12 O'clock
38 | val startAngle = 270f
39 | val sweep = progress * 360f
40 | drawDeterminateCircularIndicator(startAngle, sweep, color, stroke)
41 | }
42 | }
43 |
44 | private fun DrawScope.drawDeterminateCircularIndicator(
45 | startAngle: Float,
46 | sweep: Float,
47 | color: Color,
48 | stroke: Stroke
49 | ) = drawCircularIndicator(startAngle, sweep, color, stroke)
50 |
51 | private fun DrawScope.drawCircularIndicator(
52 | startAngle: Float,
53 | sweep: Float,
54 | color: Color,
55 | stroke: Stroke
56 | ) {
57 | // To draw this circle we need a rect with edges that line up with the midpoint of the stroke.
58 | // To do this we need to remove half the stroke width from the total diameter for both sides.
59 | val diameterOffset = stroke.width / 2
60 | val arcDimen = size.width - 2 * diameterOffset
61 | drawArc(
62 | color = color,
63 | startAngle = startAngle,
64 | sweepAngle = sweep,
65 | useCenter = false,
66 | topLeft = Offset(diameterOffset, diameterOffset),
67 | size = Size(arcDimen, arcDimen),
68 | style = stroke
69 | )
70 | }
71 |
72 | private val CircularIndicatorDiameter = 40.dp
73 |
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/composables/RoundedCornerCircularProgressIndicatorWithBackground.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.composables
2 |
3 | import android.content.res.Configuration
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.material.MaterialTheme
7 | import androidx.compose.material.ProgressIndicatorDefaults
8 | import androidx.compose.material.Surface
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.graphics.Color
12 | import androidx.compose.ui.tooling.preview.Preview
13 | import androidx.compose.ui.unit.Dp
14 | import com.florianwalther.incentivetimer.core.ui.theme.IncentiveTimerTheme
15 | import com.florianwalther.incentivetimer.core.ui.theme.PrimaryLightAlpha
16 |
17 | @Composable
18 | fun RoundedCornerCircularProgressIndicatorWithBackground(
19 | /*@FloatRange(from = 0.0, to = 1.0)*/
20 | progress: Float,
21 | modifier: Modifier = Modifier,
22 | foregroundColor: Color = MaterialTheme.colors.primary,
23 | backgroundColor: Color = MaterialTheme.colors.primary.copy(alpha = PrimaryLightAlpha),
24 | strokeWidth: Dp = ProgressIndicatorDefaults.StrokeWidth
25 | ) {
26 | Box(modifier) {
27 | RoundedCornerCircularProgressIndicator(
28 | progress = progress,
29 | modifier = Modifier.fillMaxSize(),
30 | color = foregroundColor,
31 | strokeWidth = strokeWidth,
32 | )
33 | RoundedCornerCircularProgressIndicator(
34 | progress = 1f,
35 | modifier = Modifier.fillMaxSize(),
36 | color = backgroundColor,
37 | strokeWidth = strokeWidth,
38 | )
39 | }
40 | }
41 |
42 | @Preview(
43 | name = "Light mode",
44 | uiMode = Configuration.UI_MODE_NIGHT_NO
45 | )
46 | @Preview(
47 | name = "Dark mode",
48 | uiMode = Configuration.UI_MODE_NIGHT_YES,
49 | )
50 | @Composable
51 | private fun ScreenContentPreview() {
52 | IncentiveTimerTheme {
53 | Surface {
54 | RoundedCornerCircularProgressIndicatorWithBackground(progress = .6f)
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/composables/SimpleConfirmationDialog.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.composables
2 |
3 | import androidx.annotation.StringRes
4 | import androidx.compose.material.AlertDialog
5 | import androidx.compose.material.Button
6 | import androidx.compose.material.Text
7 | import androidx.compose.material.TextButton
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.res.stringResource
10 | import com.florianwalther.incentivetimer.R
11 |
12 | @Composable
13 | fun SimpleConfirmationDialog(
14 | @StringRes title: Int,
15 | @StringRes text: Int,
16 | dismissAction: () -> Unit,
17 | confirmAction: () -> Unit,
18 | @StringRes confirmButtonText: Int = R.string.confirm,
19 | ) {
20 | AlertDialog(
21 | onDismissRequest = dismissAction,
22 | text = { Text(stringResource(text)) },
23 | title = { Text(stringResource(title)) },
24 | dismissButton = {
25 | TextButton(onClick = dismissAction) {
26 | Text(stringResource(R.string.cancel))
27 | }
28 | },
29 | confirmButton = {
30 | TextButton(onClick = confirmAction) {
31 | Text(stringResource(confirmButtonText))
32 | }
33 | }
34 | )
35 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/screenspecs/AddEditRewardScreenSpec.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.screenspecs
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.LaunchedEffect
5 | import androidx.compose.runtime.getValue
6 | import androidx.compose.runtime.livedata.observeAsState
7 | import androidx.hilt.navigation.compose.hiltViewModel
8 | import androidx.lifecycle.SavedStateHandle
9 | import androidx.navigation.NamedNavArgument
10 | import androidx.navigation.NavBackStackEntry
11 | import androidx.navigation.NavController
12 | import androidx.navigation.navArgument
13 | import com.florianwalther.incentivetimer.application.ARG_HIDE_BOTTOM_BAR
14 | import com.florianwalther.incentivetimer.core.util.exhaustive
15 | import com.florianwalther.incentivetimer.features.rewards.addeditreward.*
16 | import com.florianwalther.incentivetimer.features.rewards.addeditreward.model.AddEditRewardScreenState
17 | import kotlinx.coroutines.flow.collect
18 |
19 | object AddEditRewardScreenSpec : ScreenSpec {
20 | override val navHostRoute: String = "add_edit_reward?$ARG_REWARD_ID={$ARG_REWARD_ID}"
21 |
22 | override val arguments: List
23 | get() = listOf(
24 | navArgument(ARG_REWARD_ID) {
25 | defaultValue = NO_REWARD_ID
26 | },
27 | navArgument(ARG_HIDE_BOTTOM_BAR) {
28 | defaultValue = true
29 | }
30 | )
31 |
32 | fun isEditMode(rewardId: Long?) = rewardId != null && rewardId != NO_REWARD_ID
33 |
34 | fun buildRoute(rewardId: Long = NO_REWARD_ID) = "add_edit_reward?$ARG_REWARD_ID=$rewardId"
35 |
36 | fun getRewardIdFromSavedStateHandle(savedStateHandle: SavedStateHandle) =
37 | savedStateHandle.get(ARG_REWARD_ID)
38 |
39 | @Composable
40 | override fun TopBar(navController: NavController, navBackStackEntry: NavBackStackEntry) {
41 | val viewModel: AddEditRewardViewModel = hiltViewModel(navBackStackEntry)
42 | val isEditMode = viewModel.isEditMode
43 |
44 | AddEditRewardScreenAppBar(
45 | isEditMode = isEditMode,
46 | onCloseClicked = {
47 | navController.popBackStack()
48 | },
49 | actions = viewModel
50 | )
51 | }
52 |
53 | @Composable
54 | override fun Content(navController: NavController, navBackStackEntry: NavBackStackEntry) {
55 | val viewModel: AddEditRewardViewModel = hiltViewModel()
56 | val screenState by viewModel.screenState.observeAsState(AddEditRewardScreenState.initialState)
57 | val isEditMode = viewModel.isEditMode
58 |
59 | LaunchedEffect(Unit) {
60 | viewModel.events.collect { event ->
61 | when (event) {
62 | AddEditRewardViewModel.AddEditRewardEvent.RewardCreated -> {
63 | navController.previousBackStackEntry?.savedStateHandle?.set(
64 | ADD_EDIT_REWARD_RESULT, RESULT_REWARD_ADDED
65 | )
66 | navController.popBackStack()
67 | }
68 | AddEditRewardViewModel.AddEditRewardEvent.RewardUpdated -> {
69 | navController.previousBackStackEntry?.savedStateHandle?.set(
70 | ADD_EDIT_REWARD_RESULT, RESULT_REWARD_UPDATED
71 | )
72 | navController.popBackStack()
73 | }
74 | AddEditRewardViewModel.AddEditRewardEvent.RewardDeleted -> {
75 | navController.previousBackStackEntry?.savedStateHandle?.set(
76 | ADD_EDIT_REWARD_RESULT, RESULT_REWARD_DELETE
77 | )
78 | navController.popBackStack()
79 | }
80 | }.exhaustive
81 | }
82 | }
83 |
84 | AddEditRewardScreenContent(
85 | screenState = screenState,
86 | isEditMode = isEditMode,
87 | actions = viewModel,
88 | )
89 | }
90 | }
91 |
92 | private const val ARG_REWARD_ID = "rewardId"
93 | private const val NO_REWARD_ID = -1L
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/screenspecs/BottomNavScreenSpec.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.screenspecs
2 |
3 | import androidx.annotation.StringRes
4 | import androidx.compose.ui.graphics.vector.ImageVector
5 |
6 | sealed interface BottomNavScreenSpec : ScreenSpec {
7 |
8 | companion object {
9 | val screens: List = ScreenSpec
10 | .allScreens
11 | .values
12 | .filterIsInstance()
13 | }
14 |
15 | val icon: ImageVector
16 |
17 | @get:StringRes
18 | val label: Int
19 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/screenspecs/RewardListScreenSpec.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.screenspecs
2 |
3 | import androidx.activity.compose.BackHandler
4 | import androidx.compose.material.SnackbarResult
5 | import androidx.compose.material.icons.Icons
6 | import androidx.compose.material.icons.outlined.Star
7 | import androidx.compose.material.rememberScaffoldState
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.runtime.LaunchedEffect
10 | import androidx.compose.runtime.getValue
11 | import androidx.compose.runtime.livedata.observeAsState
12 | import androidx.compose.ui.graphics.vector.ImageVector
13 | import androidx.compose.ui.platform.LocalContext
14 | import androidx.hilt.navigation.compose.hiltViewModel
15 | import androidx.navigation.NavBackStackEntry
16 | import androidx.navigation.NavController
17 | import androidx.navigation.NavDeepLink
18 | import androidx.navigation.navDeepLink
19 | import com.florianwalther.incentivetimer.R
20 | import com.florianwalther.incentivetimer.core.util.exhaustive
21 | import com.florianwalther.incentivetimer.features.rewards.addeditreward.ADD_EDIT_REWARD_RESULT
22 | import com.florianwalther.incentivetimer.features.rewards.addeditreward.RESULT_REWARD_ADDED
23 | import com.florianwalther.incentivetimer.features.rewards.addeditreward.RESULT_REWARD_DELETE
24 | import com.florianwalther.incentivetimer.features.rewards.addeditreward.RESULT_REWARD_UPDATED
25 | import com.florianwalther.incentivetimer.features.rewards.rewardlist.RewardListScreenAppBar
26 | import com.florianwalther.incentivetimer.features.rewards.rewardlist.RewardListScreenContent
27 | import com.florianwalther.incentivetimer.features.rewards.rewardlist.RewardListViewModel
28 | import com.florianwalther.incentivetimer.features.rewards.rewardlist.model.RewardListScreenState
29 | import kotlinx.coroutines.flow.collectLatest
30 |
31 | object RewardListScreenSpec : BottomNavScreenSpec {
32 | override val navHostRoute: String = "reward_list"
33 |
34 | override val deepLinks: List = listOf(
35 | navDeepLink {
36 | uriPattern = "https://www.incentivetimer.com/reward_list"
37 | }
38 | )
39 |
40 | override val icon: ImageVector = Icons.Outlined.Star
41 |
42 | override val label: Int = R.string.rewards
43 |
44 | @Composable
45 | override fun TopBar(navController: NavController, navBackStackEntry: NavBackStackEntry) {
46 | val viewModel: RewardListViewModel = hiltViewModel(navBackStackEntry)
47 | val screenState by viewModel.screenState.observeAsState(RewardListScreenState.initialState)
48 |
49 | RewardListScreenAppBar(
50 | screenState = screenState,
51 | actions = viewModel,
52 | )
53 | }
54 |
55 | @Composable
56 | override fun Content(navController: NavController, navBackStackEntry: NavBackStackEntry) {
57 | val viewModel: RewardListViewModel = hiltViewModel(navBackStackEntry)
58 | val screenState by viewModel.screenState.observeAsState(RewardListScreenState.initialState)
59 |
60 | val scaffoldState = rememberScaffoldState()
61 |
62 | val context = LocalContext.current
63 |
64 | BackHandler(enabled = screenState.multiSelectionModeActive) {
65 | viewModel.onCancelMultiSelectionModeClicked()
66 | }
67 |
68 | LaunchedEffect(Unit) {
69 | viewModel.events.collectLatest { event ->
70 | when (event) {
71 | is RewardListViewModel.RewardListEvent.ShowUndoRewardSnackbar -> {
72 | val snackbarResult = scaffoldState.snackbarHostState.showSnackbar(
73 | message = context.getString(R.string.reward_deleted),
74 | actionLabel = context.getString(R.string.undo),
75 | )
76 | if (snackbarResult == SnackbarResult.ActionPerformed) {
77 | viewModel.onUndoDeleteRewardConfirmed(event.reward)
78 | }
79 | Unit
80 | }
81 | is RewardListViewModel.RewardListEvent.NavigateToEditRewardScreen -> {
82 | navController.navigate(AddEditRewardScreenSpec.buildRoute(event.reward.id))
83 | }
84 | }.exhaustive
85 | }
86 | }
87 |
88 | val addEditRewardResult = navController.currentBackStackEntry
89 | ?.savedStateHandle?.getLiveData(ADD_EDIT_REWARD_RESULT)?.observeAsState()
90 |
91 | LaunchedEffect(key1 = addEditRewardResult) {
92 | navController.currentBackStackEntry?.savedStateHandle?.remove(
93 | ADD_EDIT_REWARD_RESULT
94 | )
95 | addEditRewardResult?.value?.let { addEditRewardResult ->
96 | when (addEditRewardResult) {
97 | RESULT_REWARD_ADDED -> {
98 | scaffoldState.snackbarHostState.showSnackbar(context.getString(R.string.reward_added))
99 | }
100 | RESULT_REWARD_UPDATED -> {
101 | scaffoldState.snackbarHostState.showSnackbar(context.getString(R.string.reward_updated))
102 | }
103 | RESULT_REWARD_DELETE -> {
104 | scaffoldState.snackbarHostState.showSnackbar(context.getString(R.string.reward_deleted))
105 | }
106 | }
107 | }
108 | }
109 |
110 | RewardListScreenContent(
111 | screenState = screenState,
112 | onAddNewRewardClicked = {
113 | navController.navigate(AddEditRewardScreenSpec.buildRoute())
114 | },
115 | scaffoldState = scaffoldState,
116 | actions = viewModel,
117 | )
118 | }
119 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/screenspecs/ScreenSpec.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.screenspecs
2 |
3 | import androidx.annotation.StringRes
4 | import androidx.compose.runtime.Composable
5 | import androidx.navigation.NamedNavArgument
6 | import androidx.navigation.NavBackStackEntry
7 | import androidx.navigation.NavController
8 | import androidx.navigation.NavDeepLink
9 | import com.florianwalther.incentivetimer.R
10 |
11 | sealed interface ScreenSpec {
12 |
13 | companion object {
14 | val allScreens = listOf(
15 | TimerScreenSpec,
16 | RewardListScreenSpec,
17 | StatisticsScreenSpec,
18 | SettingsScreenSpec,
19 | AddEditRewardScreenSpec,
20 | ).associateBy { it.navHostRoute }
21 | }
22 |
23 | val navHostRoute: String
24 |
25 | val arguments: List get() = emptyList()
26 |
27 | val deepLinks: List get() = emptyList()
28 |
29 | @Composable
30 | fun TopBar(navController: NavController, navBackStackEntry: NavBackStackEntry)
31 |
32 | @Composable
33 | fun Content(
34 | navController: NavController,
35 | navBackStackEntry: NavBackStackEntry,
36 | )
37 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/screenspecs/SettingsScreenSpec.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.screenspecs
2 |
3 | import androidx.compose.material.icons.Icons
4 | import androidx.compose.material.icons.outlined.Settings
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.getValue
7 | import androidx.compose.runtime.livedata.observeAsState
8 | import androidx.compose.ui.graphics.vector.ImageVector
9 | import androidx.hilt.navigation.compose.hiltViewModel
10 | import androidx.navigation.NavBackStackEntry
11 | import androidx.navigation.NavController
12 | import com.florianwalther.incentivetimer.R
13 | import com.florianwalther.incentivetimer.features.settings.SettingsScreenAppBar
14 | import com.florianwalther.incentivetimer.features.settings.SettingsScreenContent
15 | import com.florianwalther.incentivetimer.features.settings.SettingsViewModel
16 | import com.florianwalther.incentivetimer.features.settings.model.SettingsScreenState
17 |
18 | object SettingsScreenSpec : BottomNavScreenSpec {
19 |
20 | override val navHostRoute: String = "settings"
21 |
22 | override val icon: ImageVector = Icons.Outlined.Settings
23 |
24 | override val label: Int = R.string.settings
25 |
26 | @Composable
27 | override fun TopBar(navController: NavController, navBackStackEntry: NavBackStackEntry) {
28 | SettingsScreenAppBar()
29 | }
30 |
31 | @Composable
32 | override fun Content(navController: NavController, navBackStackEntry: NavBackStackEntry) {
33 | val viewModel: SettingsViewModel = hiltViewModel(navBackStackEntry)
34 | val screenState by viewModel.screenState.observeAsState(SettingsScreenState.initialState)
35 | SettingsScreenContent(
36 | actions = viewModel,
37 | screenState = screenState,
38 | )
39 | }
40 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/screenspecs/StatisticsScreenSpec.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.screenspecs
2 |
3 | import androidx.compose.material.icons.Icons
4 | import androidx.compose.material.icons.outlined.BarChart
5 | import androidx.compose.runtime.*
6 | import androidx.compose.runtime.livedata.observeAsState
7 | import androidx.compose.ui.graphics.vector.ImageVector
8 | import androidx.hilt.navigation.compose.hiltViewModel
9 | import androidx.navigation.NavBackStackEntry
10 | import androidx.navigation.NavController
11 | import com.florianwalther.incentivetimer.R
12 | import com.florianwalther.incentivetimer.core.util.exhaustive
13 | import com.florianwalther.incentivetimer.features.statistics.StatisticsScreenAppBar
14 | import com.florianwalther.incentivetimer.features.statistics.StatisticsScreenContent
15 | import com.florianwalther.incentivetimer.features.statistics.StatisticsViewModel
16 | import com.florianwalther.incentivetimer.features.statistics.model.StatisticsScreenState
17 | import kotlinx.coroutines.flow.collectLatest
18 |
19 | object StatisticsScreenSpec : BottomNavScreenSpec {
20 | override val navHostRoute: String = "statistics"
21 |
22 | override val icon: ImageVector = Icons.Outlined.BarChart
23 |
24 | override val label: Int = R.string.statistics
25 |
26 | @Composable
27 | override fun TopBar(navController: NavController, navBackStackEntry: NavBackStackEntry) {
28 | val viewModel: StatisticsViewModel = hiltViewModel(navBackStackEntry)
29 | StatisticsScreenAppBar(actions = viewModel)
30 | }
31 |
32 | @Composable
33 | override fun Content(navController: NavController, navBackStackEntry: NavBackStackEntry) {
34 | val viewModel: StatisticsViewModel = hiltViewModel(navBackStackEntry)
35 | val screenState by viewModel.screenState.observeAsState(StatisticsScreenState.initialState)
36 |
37 | var resetBarChartZoom by remember { mutableStateOf(false) }
38 |
39 | LaunchedEffect(Unit) {
40 | viewModel.events.collectLatest { event ->
41 | when (event) {
42 | StatisticsViewModel.StatisticsEvent.ResetChartZoom ->
43 | resetBarChartZoom = true
44 | }.exhaustive
45 | }
46 | }
47 |
48 | StatisticsScreenContent(
49 | screenState = screenState,
50 | actions = viewModel,
51 | resetBarChartZoom = resetBarChartZoom,
52 | onBarChartZoomReset = { resetBarChartZoom = false }
53 | )
54 | }
55 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/screenspecs/TimerScreenSpec.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.screenspecs
2 |
3 | import androidx.compose.material.icons.Icons
4 | import androidx.compose.material.icons.outlined.Timer
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.getValue
7 | import androidx.compose.runtime.livedata.observeAsState
8 | import androidx.compose.ui.graphics.vector.ImageVector
9 | import androidx.hilt.navigation.compose.hiltViewModel
10 | import androidx.navigation.NavBackStackEntry
11 | import androidx.navigation.NavController
12 | import androidx.navigation.NavDeepLink
13 | import androidx.navigation.navDeepLink
14 | import com.florianwalther.incentivetimer.R
15 | import com.florianwalther.incentivetimer.data.datastore.PomodoroTimerState
16 | import com.florianwalther.incentivetimer.features.timer.TimerScreenContent
17 | import com.florianwalther.incentivetimer.features.timer.TimerScreenAppBar
18 | import com.florianwalther.incentivetimer.features.timer.TimerViewModel
19 | import com.florianwalther.incentivetimer.features.timer.model.TimerScreenState
20 | import logcat.logcat
21 |
22 | object TimerScreenSpec : BottomNavScreenSpec {
23 | override val navHostRoute: String = "timer"
24 |
25 | override val deepLinks: List = listOf(
26 | navDeepLink {
27 | uriPattern = "https://www.incentivetimer.com/timer"
28 | }
29 | )
30 |
31 | override val icon: ImageVector = Icons.Outlined.Timer
32 |
33 | override val label: Int = R.string.timer
34 |
35 | @Composable
36 | override fun TopBar(navController: NavController, navBackStackEntry: NavBackStackEntry) {
37 | val viewModel: TimerViewModel = hiltViewModel(navBackStackEntry)
38 | val pomodoroTimerState by viewModel.pomodoroTimerState.observeAsState()
39 | TimerScreenAppBar(
40 | pomodoroTimerState = pomodoroTimerState,
41 | actions = viewModel,
42 | )
43 | }
44 |
45 | @Composable
46 | override fun Content(navController: NavController, navBackStackEntry: NavBackStackEntry) {
47 | val viewModel: TimerViewModel = hiltViewModel(navBackStackEntry)
48 | val pomodoroTimerState by viewModel.pomodoroTimerState.observeAsState(PomodoroTimerState.initialState)
49 | val screenState by viewModel.screenState.observeAsState(TimerScreenState.initialState)
50 | logcat { "timerRunning = ${pomodoroTimerState.timerRunning}" }
51 | TimerScreenContent(
52 | pomodoroTimerState = pomodoroTimerState,
53 | screenState = screenState,
54 | actions = viewModel,
55 | )
56 | }
57 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val ITBlue = Color(0xFF226CE0)
6 | val ITDarkBlue = Color(0xFF0042ad)
7 | val ITBackground = Color(0xFFF1F6FC)
8 |
9 | const val PrimaryLightAlpha = .25f
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/theme/Shape.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.theme
2 |
3 | import androidx.compose.foundation.shape.RoundedCornerShape
4 | import androidx.compose.material.Shapes
5 | import androidx.compose.ui.unit.dp
6 |
7 | val Shapes = Shapes(
8 | small = RoundedCornerShape(4.dp),
9 | medium = RoundedCornerShape(4.dp),
10 | large = RoundedCornerShape(0.dp)
11 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.theme
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.material.MaterialTheme
5 | import androidx.compose.material.darkColors
6 | import androidx.compose.material.lightColors
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.runtime.SideEffect
9 | import androidx.compose.ui.graphics.Color
10 | import com.google.accompanist.systemuicontroller.rememberSystemUiController
11 |
12 | private val DarkColorPalette = darkColors(
13 | primary = ITBlue,
14 | primaryVariant = ITDarkBlue,
15 | secondary = ITBlue
16 | )
17 |
18 | private val LightColorPalette = lightColors(
19 | primary = ITBlue,
20 | primaryVariant = ITDarkBlue,
21 | secondary = ITBlue,
22 | background = ITBackground
23 |
24 | /* Other default colors to override
25 | background = Color.White,
26 | surface = Color.White,
27 | onPrimary = Color.White,
28 | onSecondary = Color.Black,
29 | onBackground = Color.Black,
30 | onSurface = Color.Black,
31 | */
32 | )
33 |
34 | @Composable
35 | fun IncentiveTimerTheme(
36 | darkTheme: Boolean = isSystemInDarkTheme(),
37 | content: @Composable() () -> Unit
38 | ) {
39 | val systemUiController = rememberSystemUiController()
40 | val statusBarColor = if (darkTheme) Color.Black else ITDarkBlue
41 |
42 | SideEffect {
43 | systemUiController.setStatusBarColor(color = statusBarColor)
44 | }
45 |
46 | val colors = if (darkTheme) {
47 | DarkColorPalette
48 | } else {
49 | LightColorPalette
50 | }
51 |
52 | MaterialTheme(
53 | colors = colors,
54 | typography = Typography,
55 | shapes = Shapes,
56 | content = content
57 | )
58 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.ui.theme
2 |
3 | import androidx.compose.material.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | body1 = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp
15 | )
16 | /* Other default text styles to override
17 | button = TextStyle(
18 | fontFamily = FontFamily.Default,
19 | fontWeight = FontWeight.W500,
20 | fontSize = 14.sp
21 | ),
22 | caption = TextStyle(
23 | fontFamily = FontFamily.Default,
24 | fontWeight = FontWeight.Normal,
25 | fontSize = 12.sp
26 | )
27 | */
28 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/util/ComposeUtils.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.util
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.platform.LocalDensity
5 | import androidx.compose.ui.unit.TextUnit
6 |
7 | @Composable
8 | fun TextUnit.toDp() = with (LocalDensity.current){
9 | toDp()
10 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/util/DateTimeFormatUtils.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.util
2 |
3 | fun formatMillisecondsToTimeString(
4 | milliseconds: Long,
5 | showHoursWithoutSeconds: Boolean = false
6 | ): String {
7 | val secondsAdjusted = (milliseconds + 999) / 1000
8 | val s = secondsAdjusted % 60
9 | val m = (secondsAdjusted / 60) % 60
10 | val h = secondsAdjusted / (60 * 60)
11 | return if (h < 1) {
12 | if (!showHoursWithoutSeconds) {
13 | String.format("%d:%02d", m, s)
14 | } else {
15 | String.format("%d:%02d", 0, m)
16 | }
17 | } else {
18 | if (!showHoursWithoutSeconds) {
19 | String.format("%d:%02d:%02d", h, m, s)
20 | } else {
21 | String.format("%d:%02d", h, m)
22 | }
23 | }
24 | }
25 |
26 | fun formatMinutesToTimeString(minutes: Int, showHoursWithoutSeconds: Boolean = false): String =
27 | formatMillisecondsToTimeString(minutes.minutesToMilliseconds(), showHoursWithoutSeconds)
28 |
29 |
30 | fun Int.minutesToMilliseconds(): Long = this * 60_000L
31 |
32 | fun Long.millisecondsToMinutes(): Int = (this / 60_000L).toInt()
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/util/DateUtils.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.util
2 |
3 | import java.util.*
4 |
5 | fun Date.withOutTime(): Date {
6 | val calendar = Calendar.getInstance().apply {
7 | time = this@withOutTime
8 | set(Calendar.HOUR_OF_DAY, 0)
9 | set(Calendar.MINUTE, 0)
10 | set(Calendar.SECOND, 0)
11 | set(Calendar.MILLISECOND, 0)
12 | }
13 | return Date(calendar.timeInMillis)
14 | }
15 |
16 | fun Date.dayAfter(): Date {
17 | val calendar = Calendar.getInstance().apply {
18 | time = this@dayAfter
19 | add(Calendar.DATE, 1)
20 | }
21 | return Date(calendar.timeInMillis)
22 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/core/util/Utils.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.util
2 |
3 | val T.exhaustive: T
4 | get() = this
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/data/datastore/DefaultPomodoroTimerStateManager.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.data.datastore
2 |
3 | import android.content.Context
4 | import androidx.annotation.StringRes
5 | import androidx.datastore.core.DataStore
6 | import androidx.datastore.preferences.core.*
7 | import androidx.datastore.preferences.preferencesDataStore
8 | import com.florianwalther.incentivetimer.R
9 | import dagger.hilt.android.qualifiers.ApplicationContext
10 | import kotlinx.coroutines.flow.Flow
11 | import kotlinx.coroutines.flow.catch
12 | import kotlinx.coroutines.flow.map
13 | import kotlinx.coroutines.flow.onEach
14 | import logcat.asLog
15 | import logcat.logcat
16 | import java.io.IOException
17 | import javax.inject.Inject
18 | import javax.inject.Singleton
19 |
20 | data class PomodoroTimerState(
21 | val timerRunning: Boolean,
22 | val currentPhase: PomodoroPhase,
23 | val timeLeftInMillis: Long,
24 | val timeTargetInMillis: Long,
25 | val pomodorosCompletedInSet: Int,
26 | val pomodorosPerSetTarget: Int,
27 | val pomodorosCompletedTotal: Int,
28 | ) {
29 | companion object {
30 | val initialState = PomodoroTimerState(
31 | timerRunning = false,
32 | currentPhase = PomodoroPhase.POMODORO,
33 | timeLeftInMillis = 0L,
34 | timeTargetInMillis = 0L,
35 | pomodorosCompletedInSet = 0,
36 | pomodorosPerSetTarget = 0,
37 | pomodorosCompletedTotal = 0,
38 | )
39 | }
40 | }
41 |
42 | @Singleton
43 | class DefaultPomodoroTimerStateManager @Inject constructor(
44 | @ApplicationContext private val context: Context,
45 | ) : PomodoroTimerStateManager {
46 | private val Context.dataStore: DataStore by preferencesDataStore(name = "timer_state")
47 |
48 | override val timerState: Flow =
49 | context.dataStore.data
50 | .catch { exception ->
51 | if (exception is IOException) {
52 | logcat { exception.asLog() }
53 | emit(emptyPreferences())
54 | } else {
55 | throw exception
56 | }
57 | }
58 | .map { preferences ->
59 | val timerRunning = preferences[PreferencesKeys.TIMER_RUNNING] ?: false
60 | logcat { "map with timerRunning = $timerRunning" }
61 | val currentPhase = PomodoroPhase.valueOf(
62 | preferences[PreferencesKeys.CURRENT_PHASE] ?: PomodoroPhase.POMODORO.name
63 | )
64 | val timeLeftInMillis = preferences[PreferencesKeys.TIME_LEFT_IN_MILLIS]
65 | ?: PomodoroTimerState.initialState.timeLeftInMillis
66 | val timeTargetInMillis = preferences[PreferencesKeys.TIME_TARGET_IN_MILLIS]
67 | ?: PomodoroTimerState.initialState.timeTargetInMillis
68 | val pomodorosPerSetTarget =
69 | preferences[PreferencesKeys.POMODOROS_PER_SET_TARGET]
70 | ?: PomodoroTimerState.initialState.pomodorosPerSetTarget
71 | val pomodorosCompletedInSet =
72 | preferences[PreferencesKeys.POMODOROS_COMPLETED_IN_SET]
73 | ?: PomodoroTimerState.initialState.pomodorosCompletedInSet
74 | val pomodorosCompletedTotal =
75 | preferences[PreferencesKeys.POMODOROS_COMPLETED_TOTAL]
76 | ?: PomodoroTimerState.initialState.pomodorosCompletedTotal
77 | PomodoroTimerState(
78 | timerRunning = timerRunning,
79 | currentPhase = currentPhase,
80 | timeLeftInMillis = timeLeftInMillis,
81 | timeTargetInMillis = timeTargetInMillis,
82 | pomodorosPerSetTarget = pomodorosPerSetTarget,
83 | pomodorosCompletedInSet = pomodorosCompletedInSet,
84 | pomodorosCompletedTotal = pomodorosCompletedTotal,
85 | )
86 | }
87 |
88 | override suspend fun updateTimerRunning(timerRunning: Boolean) {
89 | context.dataStore.edit { preferences ->
90 | preferences[PreferencesKeys.TIMER_RUNNING] = timerRunning
91 | }
92 | }
93 |
94 | override suspend fun updateCurrentPhase(phase: PomodoroPhase) {
95 | context.dataStore.edit { preferences ->
96 | preferences[PreferencesKeys.CURRENT_PHASE] = phase.name
97 | }
98 | }
99 |
100 | override suspend fun updateTimeLeftInMillis(timeLeftInMillis: Long) {
101 | context.dataStore.edit { preferences ->
102 | preferences[PreferencesKeys.TIME_LEFT_IN_MILLIS] = timeLeftInMillis
103 | }
104 | }
105 |
106 | override suspend fun updateTimeTargetInMillis(timeTargetInMillis: Long) {
107 | context.dataStore.edit { preferences ->
108 | preferences[PreferencesKeys.TIME_TARGET_IN_MILLIS] = timeTargetInMillis
109 | }
110 | }
111 |
112 | override suspend fun updatePomodorosPerSetTarget(pomodorosPerSetTarget: Int) {
113 | context.dataStore.edit { preferences ->
114 | preferences[PreferencesKeys.POMODOROS_PER_SET_TARGET] = pomodorosPerSetTarget
115 | }
116 | }
117 |
118 | override suspend fun updatePomodorosCompletedInSet(pomodorosCompletedInSet: Int) {
119 | context.dataStore.edit { preferences ->
120 | preferences[PreferencesKeys.POMODOROS_COMPLETED_IN_SET] = pomodorosCompletedInSet
121 | }
122 | }
123 |
124 | override suspend fun updatePomodorosCompletedTotal(pomodorosCompletedTotal: Int) {
125 | context.dataStore.edit { preferences ->
126 | preferences[PreferencesKeys.POMODOROS_COMPLETED_TOTAL] = pomodorosCompletedTotal
127 | }
128 | }
129 |
130 | private object PreferencesKeys {
131 | val TIMER_RUNNING = booleanPreferencesKey("TIMER_RUNNING")
132 | val CURRENT_PHASE = stringPreferencesKey("CURRENT_PHASE")
133 | val TIME_LEFT_IN_MILLIS = longPreferencesKey("TIME_LEFT_IN_MILLIS")
134 | val TIME_TARGET_IN_MILLIS = longPreferencesKey("TIME_TARGET_IN_MILLIS")
135 | val POMODOROS_PER_SET_TARGET = intPreferencesKey("POMODOROS_PER_SET_TARGET")
136 | val POMODOROS_COMPLETED_IN_SET = intPreferencesKey("POMODOROS_COMPLETED_IN_SET")
137 | val POMODOROS_COMPLETED_TOTAL = intPreferencesKey("POMODOROS_COMPLETED_TOTAL")
138 | }
139 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/data/datastore/DefaultPreferencesManager.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.data.datastore
2 |
3 | import android.content.Context
4 | import androidx.annotation.StringRes
5 | import androidx.datastore.core.DataStore
6 | import androidx.datastore.preferences.core.*
7 | import androidx.datastore.preferences.preferencesDataStore
8 | import com.florianwalther.incentivetimer.R
9 | import dagger.hilt.android.qualifiers.ApplicationContext
10 | import kotlinx.coroutines.flow.Flow
11 | import kotlinx.coroutines.flow.catch
12 | import kotlinx.coroutines.flow.map
13 | import logcat.asLog
14 | import logcat.logcat
15 | import java.io.IOException
16 | import javax.inject.Inject
17 | import javax.inject.Singleton
18 |
19 | enum class ThemeSelection(@StringRes val readableName: Int) {
20 | SYSTEM(R.string.auto_system_default), LIGHT(R.string.light), DARK(R.string.dark)
21 | }
22 |
23 | data class AppPreferences(
24 | val selectedTheme: ThemeSelection,
25 | val appInstructionsDialogShown: Boolean,
26 | )
27 |
28 | enum class PomodoroPhase(@StringRes val readableName: Int) {
29 | POMODORO(R.string.pomodoro), SHORT_BREAK(R.string.short_break), LONG_BREAK(R.string.long_break);
30 |
31 | val isBreak get() = this == SHORT_BREAK || this == LONG_BREAK
32 | }
33 |
34 | data class TimerPreferences(
35 | val pomodoroLengthInMinutes: Int,
36 | val shortBreakLengthInMinutes: Int,
37 | val longBreakLengthInMinutes: Int,
38 | val pomodorosPerSet: Int,
39 | val autoStartNextTimer: Boolean,
40 | ) {
41 | fun lengthInMinutesForPhase(phase: PomodoroPhase) = when (phase) {
42 | PomodoroPhase.POMODORO -> pomodoroLengthInMinutes
43 | PomodoroPhase.SHORT_BREAK -> shortBreakLengthInMinutes
44 | PomodoroPhase.LONG_BREAK -> longBreakLengthInMinutes
45 | }
46 | }
47 |
48 | @Singleton
49 | class DefaultPreferencesManager @Inject constructor(
50 | @ApplicationContext private val context: Context,
51 | ) : PreferencesManager {
52 | private val Context.dataStore: DataStore by preferencesDataStore(name = "preferences")
53 |
54 | override val appPreferences: Flow = context.dataStore.data
55 | .catch { exception ->
56 | if (exception is IOException) {
57 | logcat { exception.asLog() }
58 | emit(emptyPreferences())
59 | } else {
60 | throw exception
61 | }
62 | }
63 | .map { preferences ->
64 | val appInstructionsDialogShown =
65 | preferences[PreferencesKeys.APP_INSTRUCTIONS_DIALOG_SHOWN] ?: false
66 | val selectedTheme = ThemeSelection.valueOf(
67 | preferences[PreferencesKeys.SELECTED_THEME] ?: ThemeSelection.SYSTEM.name
68 | )
69 | AppPreferences(
70 | appInstructionsDialogShown = appInstructionsDialogShown,
71 | selectedTheme = selectedTheme,
72 | )
73 | }
74 |
75 | override val timerPreferences: Flow = context.dataStore.data
76 | .catch { exception ->
77 | if (exception is IOException) {
78 | logcat { exception.asLog() }
79 | emit(emptyPreferences())
80 | } else {
81 | throw exception
82 | }
83 | }
84 | .map { preferences ->
85 | val pomodoroLengthInMinutes = preferences[PreferencesKeys.POMODORO_LENGTH_IN_MINUTES]
86 | ?: POMODORO_LENGTH_IN_MINUTES_DEFAULT
87 | val shortBreakLengthInMinutes =
88 | preferences[PreferencesKeys.SHORT_BREAK_LENGTH_IN_MINUTES]
89 | ?: SHORT_BREAK_LENGTH_IN_MINUTES_DEFAULT
90 | val longBreakLengthInMinutes =
91 | preferences[PreferencesKeys.LONG_BREAK_LENGTH_IN_MINUTES]
92 | ?: LONG_BREAK_LENGTH_IN_MINUTES_DEFAULT
93 | val pomodorosPerSet =
94 | preferences[PreferencesKeys.POMODOROS_PER_SET] ?: POMODOROS_PER_SET_DEFAULT
95 | val timerBehaviour = preferences[PreferencesKeys.AUTO_START_NEXT_TIMER] ?: true
96 | TimerPreferences(
97 | pomodoroLengthInMinutes = pomodoroLengthInMinutes,
98 | shortBreakLengthInMinutes = shortBreakLengthInMinutes,
99 | longBreakLengthInMinutes = longBreakLengthInMinutes,
100 | pomodorosPerSet = pomodorosPerSet,
101 | autoStartNextTimer = timerBehaviour,
102 | )
103 | }
104 |
105 | override suspend fun updatePomodoroLength(lengthInMinutes: Int) {
106 | context.dataStore.edit { preferences ->
107 | preferences[PreferencesKeys.POMODORO_LENGTH_IN_MINUTES] = lengthInMinutes
108 | }
109 | }
110 |
111 | override suspend fun updateShortBreakLength(lengthInMinutes: Int) {
112 | context.dataStore.edit { preferences ->
113 | preferences[PreferencesKeys.SHORT_BREAK_LENGTH_IN_MINUTES] = lengthInMinutes
114 | }
115 | }
116 |
117 | override suspend fun updateLongBreakLength(lengthInMinutes: Int) {
118 | context.dataStore.edit { preferences ->
119 | preferences[PreferencesKeys.LONG_BREAK_LENGTH_IN_MINUTES] = lengthInMinutes
120 | }
121 | }
122 |
123 | override suspend fun updatePomodorosPerSet(amount: Int) {
124 | context.dataStore.edit { preferences ->
125 | preferences[PreferencesKeys.POMODOROS_PER_SET] = amount
126 | }
127 | }
128 |
129 | override suspend fun updateAutoStartNextTimer(autoStartNextTimer: Boolean) {
130 | context.dataStore.edit { preferences ->
131 | preferences[PreferencesKeys.AUTO_START_NEXT_TIMER] = autoStartNextTimer
132 | }
133 | }
134 |
135 | override suspend fun updateSelectedTheme(theme: ThemeSelection) {
136 | context.dataStore.edit { preferences ->
137 | preferences[PreferencesKeys.SELECTED_THEME] = theme.name
138 | }
139 | }
140 |
141 | override suspend fun updateAppInstructionsDialogShown(shown: Boolean) {
142 | context.dataStore.edit { preferences ->
143 | preferences[PreferencesKeys.APP_INSTRUCTIONS_DIALOG_SHOWN] = shown
144 | }
145 | }
146 |
147 | private object PreferencesKeys {
148 | val POMODORO_LENGTH_IN_MINUTES = intPreferencesKey("POMODORO_LENGTH_IN_MINUTES")
149 | val SHORT_BREAK_LENGTH_IN_MINUTES = intPreferencesKey("SHORT_BREAK_LENGTH_IN_MINUTES")
150 | val LONG_BREAK_LENGTH_IN_MINUTES = intPreferencesKey("LONG_BREAK_LENGTH_IN_MINUTES")
151 | val POMODOROS_PER_SET = intPreferencesKey("POMODOROS_PER_SET")
152 | val AUTO_START_NEXT_TIMER = booleanPreferencesKey("AUTO_START_NEXT_TIMER")
153 | val SELECTED_THEME = stringPreferencesKey("SELECTED_THEME")
154 | val APP_INSTRUCTIONS_DIALOG_SHOWN = booleanPreferencesKey("APP_INSTRUCTIONS_DIALOG_SHOWN")
155 | }
156 | }
157 |
158 | const val POMODORO_LENGTH_IN_MINUTES_DEFAULT = 25
159 | const val SHORT_BREAK_LENGTH_IN_MINUTES_DEFAULT = 5
160 | const val LONG_BREAK_LENGTH_IN_MINUTES_DEFAULT = 15
161 | const val POMODOROS_PER_SET_DEFAULT = 4
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/data/datastore/PomodoroTimerStateManager.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.data.datastore
2 |
3 | import kotlinx.coroutines.flow.Flow
4 |
5 | interface PomodoroTimerStateManager {
6 | val timerState: Flow
7 |
8 | suspend fun updateTimerRunning(timerRunning: Boolean)
9 |
10 | suspend fun updateCurrentPhase(phase: PomodoroPhase)
11 |
12 | suspend fun updateTimeLeftInMillis(timeLeftInMillis: Long)
13 |
14 | suspend fun updateTimeTargetInMillis(timeTargetInMillis: Long)
15 |
16 | suspend fun updatePomodorosPerSetTarget(pomodorosPerSetTarget: Int)
17 |
18 | suspend fun updatePomodorosCompletedInSet(pomodorosCompletedInSet: Int)
19 |
20 | suspend fun updatePomodorosCompletedTotal(pomodorosCompletedTotal: Int)
21 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/data/datastore/PreferencesManager.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.data.datastore
2 |
3 | import kotlinx.coroutines.flow.Flow
4 |
5 | interface PreferencesManager {
6 | val appPreferences: Flow
7 |
8 | val timerPreferences: Flow
9 |
10 | suspend fun updatePomodoroLength(lengthInMinutes: Int)
11 |
12 | suspend fun updateShortBreakLength(lengthInMinutes: Int)
13 |
14 | suspend fun updateLongBreakLength(lengthInMinutes: Int)
15 |
16 | suspend fun updatePomodorosPerSet(amount: Int)
17 |
18 | suspend fun updateAutoStartNextTimer(autoStartNextTimer: Boolean)
19 |
20 | suspend fun updateSelectedTheme(theme: ThemeSelection)
21 |
22 | suspend fun updateAppInstructionsDialogShown(shown: Boolean)
23 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/data/db/ITDatabase.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.data.db
2 |
3 | import androidx.room.Database
4 | import androidx.room.RoomDatabase
5 | import androidx.sqlite.db.SupportSQLiteDatabase
6 | import com.florianwalther.incentivetimer.di.ApplicationScope
7 | import com.florianwalther.incentivetimer.core.ui.IconKey
8 | import com.florianwalther.incentivetimer.core.util.dayAfter
9 | import kotlinx.coroutines.CoroutineScope
10 | import kotlinx.coroutines.launch
11 | import java.util.*
12 | import javax.inject.Inject
13 | import javax.inject.Provider
14 |
15 | @Database(entities = [Reward::class, PomodoroStatistic::class], version = 1)
16 | abstract class ITDatabase : RoomDatabase() {
17 |
18 | abstract fun rewardDao(): RewardDao
19 |
20 | abstract fun pomodoroStatisticDao(): PomodoroStatisticDao
21 |
22 | class Callback @Inject constructor(
23 | private val database: Provider,
24 | @ApplicationScope private val applicationScope: CoroutineScope,
25 | ) : RoomDatabase.Callback() {
26 |
27 | override fun onCreate(db: SupportSQLiteDatabase) {
28 | super.onCreate(db)
29 |
30 | val rewardDao = database.get().rewardDao()
31 |
32 | applicationScope.launch {
33 | rewardDao.insertReward(
34 | Reward(
35 | iconKey = IconKey.CAKE,
36 | name = "1 piece of cake",
37 | chanceInPercent = 5
38 | )
39 | )
40 | rewardDao.insertReward(
41 | Reward(
42 | iconKey = IconKey.BATH_TUB,
43 | name = "Take a bath",
44 | chanceInPercent = 7
45 | )
46 | )
47 | rewardDao.insertReward(
48 | Reward(
49 | iconKey = IconKey.TV,
50 | name = "Watch 1 episode of my favorite show",
51 | chanceInPercent = 10
52 | )
53 | )
54 | }
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/data/db/PomodoroStatistic.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.data.db
2 |
3 | import androidx.room.Entity
4 | import androidx.room.PrimaryKey
5 |
6 | @Entity(tableName = "pomodoro_statistics")
7 | data class PomodoroStatistic(
8 | val pomodoroDurationInMinutes: Int,
9 | val timestampInMilliseconds: Long,
10 | @PrimaryKey(autoGenerate = true) val id: Long = 0,
11 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/data/db/PomodoroStatisticDao.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.data.db
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Insert
5 | import androidx.room.OnConflictStrategy
6 | import androidx.room.Query
7 | import kotlinx.coroutines.flow.Flow
8 |
9 | @Dao
10 | interface PomodoroStatisticDao {
11 |
12 | @Query("SELECT * FROM pomodoro_statistics ORDER BY timestampInMilliseconds")
13 | fun getAllPomodoroStatistics(): Flow>
14 |
15 | @Insert(onConflict = OnConflictStrategy.REPLACE)
16 | suspend fun insertPomodoroStatistic(pomodoroStatistic: PomodoroStatistic)
17 |
18 | @Query("DELETE FROM pomodoro_statistics")
19 | suspend fun deleteAllPomodoroStatistics()
20 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/data/db/Reward.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.data.db
2 |
3 | import android.os.Parcelable
4 | import androidx.room.Entity
5 | import androidx.room.PrimaryKey
6 | import com.florianwalther.incentivetimer.core.ui.IconKey
7 | import com.florianwalther.incentivetimer.core.ui.defaultRewardIconKey
8 | import kotlinx.parcelize.Parcelize
9 |
10 | @Entity(tableName = "rewards")
11 | @Parcelize
12 | data class Reward(
13 | val name: String,
14 | val chanceInPercent: Int,
15 | val iconKey: IconKey,
16 | val isUnlocked: Boolean = false,
17 | @PrimaryKey(autoGenerate = true) val id: Long = 0,
18 | ) : Parcelable {
19 |
20 | companion object {
21 | val DEFAULT = Reward(
22 | name = "",
23 | chanceInPercent = 10,
24 | iconKey = defaultRewardIconKey
25 | )
26 | }
27 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/data/db/RewardDao.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.data.db
2 |
3 | import androidx.room.*
4 | import com.florianwalther.incentivetimer.data.db.Reward
5 | import kotlinx.coroutines.flow.Flow
6 |
7 | @Dao
8 | interface RewardDao {
9 |
10 | @Query("SELECT * FROM rewards ORDER BY isUnlocked DESC")
11 | fun getAllRewardsSortedByIsUnlockedDesc(): Flow>
12 |
13 | @Query("SELECT * FROM rewards WHERE isUnlocked = 0")
14 | fun getAllNotUnlockedRewards(): Flow>
15 |
16 | @Query("SELECT * FROM rewards WHERE id = :rewardId")
17 | fun getRewardById(rewardId: Long): Flow
18 |
19 | @Insert(onConflict = OnConflictStrategy.REPLACE)
20 | suspend fun insertReward(reward: Reward)
21 |
22 | @Update
23 | suspend fun updateReward(reward: Reward)
24 |
25 | @Delete
26 | suspend fun deleteReward(reward: Reward)
27 |
28 | @Delete
29 | suspend fun deleteRewards(rewards: List)
30 |
31 | @Query("DELETE FROM rewards WHERE isUnlocked = 1")
32 | suspend fun deleteAllUnlockedRewards()
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/di/AppModule.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.di
2 |
3 | import android.app.Application
4 | import androidx.room.Room
5 | import com.florianwalther.incentivetimer.core.notification.DefaultNotificationHelper
6 | import com.florianwalther.incentivetimer.core.notification.NotificationHelper
7 | import com.florianwalther.incentivetimer.data.datastore.DefaultPomodoroTimerStateManager
8 | import com.florianwalther.incentivetimer.data.db.ITDatabase
9 | import com.florianwalther.incentivetimer.data.db.PomodoroStatisticDao
10 | import com.florianwalther.incentivetimer.data.db.RewardDao
11 | import com.florianwalther.incentivetimer.data.datastore.DefaultPreferencesManager
12 | import com.florianwalther.incentivetimer.data.datastore.PomodoroTimerStateManager
13 | import com.florianwalther.incentivetimer.data.datastore.PreferencesManager
14 | import com.florianwalther.incentivetimer.features.timer.*
15 | import dagger.Binds
16 | import dagger.Module
17 | import dagger.Provides
18 | import dagger.hilt.InstallIn
19 | import dagger.hilt.components.SingletonComponent
20 | import kotlinx.coroutines.CoroutineDispatcher
21 | import kotlinx.coroutines.CoroutineScope
22 | import kotlinx.coroutines.Dispatchers
23 | import kotlinx.coroutines.SupervisorJob
24 | import javax.inject.Qualifier
25 | import javax.inject.Singleton
26 |
27 | @Module
28 | @InstallIn(SingletonComponent::class)
29 | abstract class AppModule {
30 | companion object {
31 | @Provides
32 | fun provideRewardDao(db: ITDatabase): RewardDao = db.rewardDao()
33 |
34 | @Provides
35 | fun providePomodoroStatisticDao(db: ITDatabase): PomodoroStatisticDao =
36 | db.pomodoroStatisticDao()
37 |
38 | @Singleton
39 | @Provides
40 | fun provideDatabase(
41 | app: Application,
42 | callback: ITDatabase.Callback,
43 | ): ITDatabase = Room.databaseBuilder(app, ITDatabase::class.java, "it_database")
44 | .addCallback(callback)
45 | .build()
46 |
47 | @ApplicationScope
48 | @Singleton
49 | @Provides
50 | fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob())
51 |
52 | @MainDispatcher
53 | @Singleton
54 | @Provides
55 | fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
56 | }
57 |
58 | @Binds
59 | abstract fun bindTimeSource(timeSource: DefaultTimeSource): TimeSource
60 |
61 | @Binds
62 | abstract fun bindPreferencesManager(preferencesManager: DefaultPreferencesManager): PreferencesManager
63 |
64 | @Binds
65 | abstract fun bindPomodoroTimerStateManager(pomodoroTimerManager: DefaultPomodoroTimerStateManager): PomodoroTimerStateManager
66 |
67 | @Binds
68 | abstract fun bindTimerServiceManager(timerServiceManager: DefaultTimerServiceManager): TimerServiceManager
69 |
70 | @Binds
71 | abstract fun bindNotificationHelper(notificationHelper: DefaultNotificationHelper): NotificationHelper
72 | }
73 |
74 |
75 | @Retention(AnnotationRetention.RUNTIME)
76 | @Qualifier
77 | annotation class ApplicationScope
78 |
79 | @Retention(AnnotationRetention.RUNTIME)
80 | @Qualifier
81 | annotation class MainDispatcher
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/rewards/RewardUnlockManager.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.rewards
2 |
3 | import com.florianwalther.incentivetimer.core.notification.NotificationHelper
4 | import com.florianwalther.incentivetimer.data.db.RewardDao
5 | import com.florianwalther.incentivetimer.di.ApplicationScope
6 | import kotlinx.coroutines.CoroutineScope
7 | import kotlinx.coroutines.flow.first
8 | import kotlinx.coroutines.launch
9 | import logcat.logcat
10 | import javax.inject.Inject
11 | import kotlin.random.Random
12 |
13 | class RewardUnlockManager @Inject constructor(
14 | private val rewardDao: RewardDao,
15 | @ApplicationScope private val applicationScope: CoroutineScope,
16 | private val notificationHelper: NotificationHelper,
17 | ) {
18 | fun rollAllRewards() {
19 | applicationScope.launch {
20 | val allNotUnlockedRewards = rewardDao.getAllNotUnlockedRewards().first()
21 | allNotUnlockedRewards.forEach { reward ->
22 | val chanceInPercent = reward.chanceInPercent
23 | val randomNumber = Random.nextInt(from = 1, until = 100)
24 | val unlocked = chanceInPercent >= randomNumber
25 | if (unlocked) {
26 | val rewardUpdate = reward.copy(isUnlocked = unlocked)
27 | logcat { "Name: ${reward.name}, chance: $chanceInPercent, rn: $randomNumber" }
28 | rewardDao.updateReward(rewardUpdate)
29 | notificationHelper.showRewardUnlockedNotification(reward)
30 | }
31 | }
32 | }
33 | }
34 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/rewards/addeditreward/AddEditRewardScreenActions.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.rewards.addeditreward
2 |
3 | import com.florianwalther.incentivetimer.core.ui.IconKey
4 |
5 | interface AddEditRewardScreenActions {
6 | fun onRewardNameInputChanged(input: String)
7 | fun onChanceInPercentInputChanged(input: Int)
8 | fun onRewardIconButtonClicked()
9 | fun onRewardIconSelected(iconKey: IconKey)
10 | fun onRewardIconDialogDismissed()
11 | fun onSaveClicked()
12 | fun onRewardUnlockedCheckedChanged(unlocked: Boolean)
13 | fun onDeleteRewardClicked()
14 | fun onDeleteRewardConfirmed()
15 | fun onDeleteRewardDialogDismissed()
16 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/rewards/addeditreward/AddEditRewardViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.rewards.addeditreward
2 |
3 | import androidx.lifecycle.*
4 | import com.florianwalther.incentivetimer.data.db.Reward
5 | import com.florianwalther.incentivetimer.data.db.RewardDao
6 | import com.florianwalther.incentivetimer.core.ui.IconKey
7 | import com.florianwalther.incentivetimer.core.ui.screenspecs.AddEditRewardScreenSpec
8 | import com.florianwalther.incentivetimer.features.rewards.addeditreward.model.AddEditRewardScreenState
9 | import com.zhuinden.flowcombinetuplekt.combineTuple
10 | import dagger.hilt.android.lifecycle.HiltViewModel
11 | import kotlinx.coroutines.channels.Channel
12 | import kotlinx.coroutines.flow.*
13 | import kotlinx.coroutines.launch
14 | import javax.inject.Inject
15 |
16 | @HiltViewModel
17 | class AddEditRewardViewModel @Inject constructor(
18 | private val rewardDao: RewardDao,
19 | savedStateHandle: SavedStateHandle,
20 | ) : ViewModel(), AddEditRewardScreenActions {
21 |
22 | private val rewardId = AddEditRewardScreenSpec.getRewardIdFromSavedStateHandle(savedStateHandle)
23 | private val rewardInput = savedStateHandle.getLiveData(
24 | KEY_REWARD_LIVE_DATA,
25 | Reward.DEFAULT
26 | )
27 |
28 | val isEditMode = AddEditRewardScreenSpec.isEditMode(rewardId)
29 |
30 | private val unlockedStateCheckboxVisible =
31 | savedStateHandle.getLiveData("unlockedStateCheckboxVisible", false)
32 |
33 | private val showRewardIconSelectionDialog =
34 | savedStateHandle.getLiveData("showRewardIconSelectionDialog", false)
35 |
36 | private val showDeleteRewardConfirmationDialog =
37 | savedStateHandle.getLiveData("showDeleteRewardConfirmationDialog", false)
38 |
39 | private val rewardNameInputIsError =
40 | savedStateHandle.getLiveData("rewardNameInputIsError", false)
41 |
42 | val screenState = combineTuple(
43 | rewardInput.asFlow(),
44 | unlockedStateCheckboxVisible.asFlow(),
45 | showRewardIconSelectionDialog.asFlow(),
46 | showDeleteRewardConfirmationDialog.asFlow(),
47 | rewardNameInputIsError.asFlow(),
48 | ).map { (
49 | rewardInput,
50 | unlockedStateCheckboxVisible,
51 | showRewardIconSelectionDialog,
52 | showDeleteRewardConfirmationDialog,
53 | rewardNameInputIsError,
54 | ) ->
55 | AddEditRewardScreenState(
56 | rewardInput = rewardInput,
57 | unlockedStateCheckboxVisible = unlockedStateCheckboxVisible,
58 | showRewardIconSelectionDialog = showRewardIconSelectionDialog,
59 | showDeleteRewardConfirmationDialog = showDeleteRewardConfirmationDialog,
60 | rewardNameInputIsError = rewardNameInputIsError,
61 | )
62 | }.asLiveData()
63 |
64 | private val eventChannel = Channel()
65 | val events: Flow = eventChannel.receiveAsFlow()
66 |
67 | sealed class AddEditRewardEvent {
68 | object RewardCreated : AddEditRewardEvent()
69 | object RewardUpdated : AddEditRewardEvent()
70 | object RewardDeleted : AddEditRewardEvent()
71 | }
72 |
73 | init {
74 | if (!savedStateHandle.contains(KEY_REWARD_LIVE_DATA)) {
75 | if (rewardId != null && isEditMode) {
76 | viewModelScope.launch {
77 | rewardInput.value = rewardDao.getRewardById(rewardId).firstOrNull()
78 | }
79 | } else {
80 | rewardInput.value = Reward.DEFAULT
81 | }
82 | }
83 | if (rewardId != null) {
84 | viewModelScope.launch {
85 | rewardDao.getRewardById(rewardId)
86 | .distinctUntilChangedBy { it?.isUnlocked }
87 | .filter { it?.isUnlocked == true }
88 | .collectLatest { reward ->
89 | if (reward != null) {
90 | unlockedStateCheckboxVisible.value = reward.isUnlocked
91 | rewardInput.value =
92 | rewardInput.value?.copy(isUnlocked = reward.isUnlocked)
93 | }
94 | }
95 | }
96 | }
97 | }
98 |
99 | override fun onRewardNameInputChanged(input: String) {
100 | rewardInput.value = rewardInput.value?.copy(name = input)
101 | }
102 |
103 | override fun onChanceInPercentInputChanged(input: Int) {
104 | rewardInput.value = rewardInput.value?.copy(chanceInPercent = input)
105 | }
106 |
107 | override fun onRewardIconSelected(iconKey: IconKey) {
108 | rewardInput.value = rewardInput.value?.copy(iconKey = iconKey)
109 | showRewardIconSelectionDialog.value = false
110 | }
111 |
112 | override fun onRewardIconButtonClicked() {
113 | showRewardIconSelectionDialog.value = true
114 | }
115 |
116 | override fun onRewardIconDialogDismissed() {
117 | showRewardIconSelectionDialog.value = false
118 | }
119 |
120 | override fun onSaveClicked() {
121 | val reward = rewardInput.value ?: return
122 | rewardNameInputIsError.value = false
123 |
124 | viewModelScope.launch {
125 | if (reward.name.isNotBlank()) {
126 | if (isEditMode) {
127 | updateReward(reward)
128 | } else {
129 | createReward(reward)
130 | }
131 | } else {
132 | rewardNameInputIsError.value = true
133 | }
134 | }
135 | }
136 |
137 | private suspend fun updateReward(reward: Reward) {
138 | rewardDao.updateReward(reward)
139 | eventChannel.send(AddEditRewardEvent.RewardUpdated)
140 | }
141 |
142 | private suspend fun createReward(reward: Reward) {
143 | rewardDao.insertReward(reward)
144 | eventChannel.send(AddEditRewardEvent.RewardCreated)
145 | }
146 |
147 | override fun onRewardUnlockedCheckedChanged(unlocked: Boolean) {
148 | rewardInput.value = rewardInput.value?.copy(isUnlocked = unlocked)
149 | }
150 |
151 | override fun onDeleteRewardClicked() {
152 | showDeleteRewardConfirmationDialog.value = true
153 | }
154 |
155 | override fun onDeleteRewardConfirmed() {
156 | showDeleteRewardConfirmationDialog.value = false
157 | viewModelScope.launch {
158 | val reward = rewardInput.value
159 | if (reward != null) {
160 | rewardDao.deleteReward(reward)
161 | eventChannel.send(AddEditRewardEvent.RewardDeleted)
162 | }
163 | }
164 | }
165 |
166 | override fun onDeleteRewardDialogDismissed() {
167 | showDeleteRewardConfirmationDialog.value = false
168 | }
169 | }
170 |
171 | private const val KEY_REWARD_LIVE_DATA = "KEY_REWARD_LIVE_DATA"
172 |
173 | const val ADD_EDIT_REWARD_RESULT = "ADD_EDIT_REWARD_RESULT"
174 | const val RESULT_REWARD_ADDED = "RESULT_REWARD_ADDED"
175 | const val RESULT_REWARD_UPDATED = "RESULT_REWARD_UPDATED"
176 | const val RESULT_REWARD_DELETE = "RESULT_REWARD_DELETED"
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/rewards/addeditreward/model/AddEditRewardScreenState.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.rewards.addeditreward.model
2 |
3 | import com.florianwalther.incentivetimer.data.db.Reward
4 |
5 | data class AddEditRewardScreenState(
6 | val rewardInput: Reward,
7 | val unlockedStateCheckboxVisible: Boolean,
8 | val showRewardIconSelectionDialog: Boolean,
9 | val showDeleteRewardConfirmationDialog: Boolean,
10 | val rewardNameInputIsError: Boolean,
11 | ) {
12 | companion object {
13 | val initialState = AddEditRewardScreenState(
14 | rewardInput = Reward.DEFAULT,
15 | unlockedStateCheckboxVisible = false,
16 | showRewardIconSelectionDialog = false,
17 | showDeleteRewardConfirmationDialog = false,
18 | rewardNameInputIsError = false,
19 | )
20 | }
21 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/rewards/rewardlist/RewardListActions.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.rewards.rewardlist
2 |
3 | import com.florianwalther.incentivetimer.data.db.Reward
4 |
5 | interface RewardListActions {
6 | fun onDeleteAllUnlockedRewardsClicked()
7 | fun onDeleteAllUnlockedRewardsConfirmed()
8 | fun onDeleteAllUnlockedRewardsDialogDismissed()
9 | fun onDeleteAllSelectedRewardsConfirmed()
10 | fun onDeleteAllSelectedRewardsDialogDismissed()
11 | fun onRewardSwiped(reward: Reward)
12 | fun onUndoDeleteRewardConfirmed(reward: Reward)
13 | fun onRewardClicked(reward: Reward)
14 | fun onRewardLongClicked(reward: Reward)
15 | fun onCancelMultiSelectionModeClicked()
16 | fun onDeleteAllSelectedItemsClicked()
17 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/rewards/rewardlist/RewardListViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.rewards.rewardlist
2 |
3 | import androidx.lifecycle.*
4 | import com.florianwalther.incentivetimer.data.db.Reward
5 | import com.florianwalther.incentivetimer.data.db.RewardDao
6 | import com.florianwalther.incentivetimer.features.rewards.rewardlist.model.RewardListScreenState
7 | import com.zhuinden.flowcombinetuplekt.combineTuple
8 | import dagger.hilt.android.lifecycle.HiltViewModel
9 | import kotlinx.coroutines.channels.Channel
10 | import kotlinx.coroutines.flow.*
11 | import kotlinx.coroutines.launch
12 | import javax.inject.Inject
13 |
14 | @HiltViewModel
15 | class RewardListViewModel @Inject constructor(
16 | private val rewardDao: RewardDao,
17 | savedStateHandle: SavedStateHandle,
18 | ) : ViewModel(), RewardListActions {
19 |
20 | private val rewards = rewardDao.getAllRewardsSortedByIsUnlockedDesc()
21 |
22 | private val selectedRewardIds =
23 | savedStateHandle.getLiveData>("selectedRewardIds", emptyList())
24 |
25 | private val selectedItemCount = selectedRewardIds.map { it.size }
26 |
27 | private val multiSelectionModeActive =
28 | savedStateHandle.getLiveData("multiSelectionModeActiveLiveData", false)
29 |
30 | private val showDeleteAllSelectedRewardsDialog =
31 | savedStateHandle.getLiveData("showDeleteAllSelectedRewardsDialog", false)
32 |
33 | private val showDeleteAllUnlockedRewardsDialog =
34 | savedStateHandle.getLiveData("showDeleteAllUnlockedRewardsDialog", false)
35 |
36 | val screenState = combineTuple(
37 | rewards,
38 | selectedRewardIds.asFlow(),
39 | selectedItemCount.asFlow(),
40 | multiSelectionModeActive.asFlow(),
41 | showDeleteAllSelectedRewardsDialog.asFlow(),
42 | showDeleteAllUnlockedRewardsDialog.asFlow()
43 | ).map { (
44 | rewards,
45 | selectedRewardIds,
46 | selectedItemCount,
47 | multiSelectionModeActive,
48 | showDeleteAllSelectedRewardsDialogLiveData,
49 | showDeleteAllUnlockedRewardsDialogLiveData
50 | ) ->
51 | RewardListScreenState(
52 | rewards = rewards,
53 | selectedRewardIds = selectedRewardIds,
54 | selectedItemCount = selectedItemCount,
55 | multiSelectionModeActive = multiSelectionModeActive,
56 | showDeleteAllSelectedRewardsDialog = showDeleteAllSelectedRewardsDialogLiveData,
57 | showDeleteAllUnlockedRewardsDialog = showDeleteAllUnlockedRewardsDialogLiveData,
58 | )
59 | }.asLiveData()
60 |
61 | private val eventChannel = Channel()
62 | val events: Flow = eventChannel.receiveAsFlow()
63 |
64 | sealed class RewardListEvent {
65 | data class ShowUndoRewardSnackbar(val reward: Reward) : RewardListEvent()
66 | data class NavigateToEditRewardScreen(val reward: Reward) : RewardListEvent()
67 | }
68 |
69 | init {
70 | viewModelScope.launch {
71 | rewards.collectLatest { rewards ->
72 | val rewardIds = rewards.map { it.id }
73 | selectedRewardIds.value = selectedRewardIds.value?.filter { rewardIds.contains(it) }
74 | if (selectedRewardIds.value.isNullOrEmpty()) {
75 | cancelMultiSelectionMode()
76 | }
77 | }
78 | }
79 | }
80 |
81 | override fun onDeleteAllSelectedItemsClicked() {
82 | showDeleteAllSelectedRewardsDialog.value = true
83 | }
84 |
85 | override fun onDeleteAllSelectedRewardsConfirmed() {
86 | showDeleteAllSelectedRewardsDialog.value = false
87 | viewModelScope.launch {
88 | val rewards = rewards.first()
89 | val selectedRewardIds = selectedRewardIds.value ?: emptyList()
90 | val selectedRewards =
91 | rewards.filter { selectedRewardIds.contains(it.id) }
92 | rewardDao.deleteRewards(selectedRewards)
93 | cancelMultiSelectionMode()
94 | }
95 | }
96 |
97 | override fun onDeleteAllSelectedRewardsDialogDismissed() {
98 | showDeleteAllSelectedRewardsDialog.value = false
99 | }
100 |
101 | override fun onDeleteAllUnlockedRewardsClicked() {
102 | showDeleteAllUnlockedRewardsDialog.value = true
103 | }
104 |
105 | override fun onDeleteAllUnlockedRewardsConfirmed() {
106 | showDeleteAllUnlockedRewardsDialog.value = false
107 | viewModelScope.launch {
108 | rewardDao.deleteAllUnlockedRewards()
109 | }
110 | }
111 |
112 | override fun onDeleteAllUnlockedRewardsDialogDismissed() {
113 | showDeleteAllUnlockedRewardsDialog.value = false
114 | }
115 |
116 | override fun onRewardClicked(reward: Reward) {
117 | val multiSelectionModeActive = multiSelectionModeActive.value
118 | if (multiSelectionModeActive == false) {
119 | viewModelScope.launch {
120 | eventChannel.send(RewardListEvent.NavigateToEditRewardScreen(reward))
121 | }
122 | } else {
123 | val selectedRewardIds = selectedRewardIds.value
124 | if (selectedRewardIds != null) {
125 | addOrRemoveSelectedReward(reward)
126 | }
127 | }
128 | }
129 |
130 | override fun onRewardLongClicked(reward: Reward) {
131 | if (multiSelectionModeActive.value == false) {
132 | multiSelectionModeActive.value = true
133 | }
134 | addOrRemoveSelectedReward(reward)
135 | }
136 |
137 | override fun onCancelMultiSelectionModeClicked() {
138 | cancelMultiSelectionMode()
139 | }
140 |
141 | private fun cancelMultiSelectionMode() {
142 | if (multiSelectionModeActive.value == false) return
143 | selectedRewardIds.value = emptyList()
144 | multiSelectionModeActive.value = false
145 | }
146 |
147 | private fun addOrRemoveSelectedReward(reward: Reward) {
148 | val selectedRewardIds = selectedRewardIds.value
149 | if (selectedRewardIds != null) {
150 | if (selectedRewardIds.contains(reward.id)) {
151 | val selectedRewardsUpdate = selectedRewardIds.toMutableList().apply {
152 | remove(reward.id)
153 | if (this.isEmpty()) {
154 | multiSelectionModeActive.value = false
155 | }
156 | }
157 | this.selectedRewardIds.value = selectedRewardsUpdate
158 | } else {
159 | val selectedRewardsUpdate = selectedRewardIds.toMutableList().apply {
160 | add(reward.id)
161 | }
162 | this.selectedRewardIds.value = selectedRewardsUpdate
163 | }
164 | }
165 | }
166 |
167 | override fun onRewardSwiped(reward: Reward) {
168 | viewModelScope.launch {
169 | rewardDao.deleteReward(reward)
170 | eventChannel.send(RewardListEvent.ShowUndoRewardSnackbar(reward))
171 | }
172 | }
173 |
174 | override fun onUndoDeleteRewardConfirmed(reward: Reward) {
175 | viewModelScope.launch {
176 | rewardDao.insertReward(reward)
177 | }
178 | }
179 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/rewards/rewardlist/model/RewardListScreenState.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.rewards.rewardlist.model
2 |
3 | import com.florianwalther.incentivetimer.data.db.Reward
4 |
5 | data class RewardListScreenState(
6 | val rewards: List,
7 | val selectedRewardIds: List,
8 | val selectedItemCount: Int,
9 | val multiSelectionModeActive: Boolean,
10 | val showDeleteAllSelectedRewardsDialog: Boolean,
11 | val showDeleteAllUnlockedRewardsDialog: Boolean,
12 | ) {
13 | companion object {
14 | val initialState = RewardListScreenState(
15 | rewards = emptyList(),
16 | selectedRewardIds = emptyList(),
17 | selectedItemCount = 0,
18 | multiSelectionModeActive = false,
19 | showDeleteAllSelectedRewardsDialog = false,
20 | showDeleteAllUnlockedRewardsDialog = false,
21 | )
22 | }
23 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/settings/SettingsScreenActions.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.settings
2 |
3 | import com.florianwalther.incentivetimer.data.datastore.ThemeSelection
4 |
5 | interface SettingsScreenActions {
6 | fun onPomodoroLengthPreferenceClicked()
7 | fun onPomodoroLengthSet(lengthInMinutes: Int)
8 | fun onPomodoroLengthDialogDismissed()
9 | fun onShortBreakLengthPreferenceClicked()
10 | fun onShortBreakLengthSet(lengthInMinutes: Int)
11 | fun onShortBreakLengthDialogDismissed()
12 | fun onLongBreakLengthPreferenceClicked()
13 | fun onLongBreakLengthSet(lengthInMinutes: Int)
14 | fun onLongBreakLengthDialogDismissed()
15 | fun onPomodorosPerSetPreferenceClicked()
16 | fun onPomodorosPerSetSet(amount: Int)
17 | fun onPomodorosPerSetDialogDismissed()
18 | fun onAutoStartNextTimerCheckedChanged(checked: Boolean)
19 | fun onShowAppInstructionsClicked()
20 | fun onAppInstructionsDialogDismissed()
21 | fun onThemePreferenceClicked()
22 | fun onThemeSelected(theme: ThemeSelection)
23 | fun onThemeDialogDismissed()
24 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/settings/SettingsViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.settings
2 |
3 | import androidx.lifecycle.*
4 | import com.florianwalther.incentivetimer.data.datastore.DefaultPreferencesManager
5 | import com.florianwalther.incentivetimer.data.datastore.ThemeSelection
6 | import com.florianwalther.incentivetimer.features.settings.model.SettingsScreenState
7 | import com.zhuinden.flowcombinetuplekt.combineTuple
8 | import dagger.hilt.android.lifecycle.HiltViewModel
9 | import kotlinx.coroutines.flow.map
10 | import kotlinx.coroutines.launch
11 | import javax.inject.Inject
12 |
13 | @HiltViewModel
14 | class SettingsViewModel @Inject constructor(
15 | private val preferencesManager: DefaultPreferencesManager,
16 | savedStateHandle: SavedStateHandle,
17 | ) : ViewModel(), SettingsScreenActions {
18 |
19 | private val appPreferences = preferencesManager.appPreferences
20 |
21 | private val timerPreferences = preferencesManager.timerPreferences
22 |
23 | private val showThemeDialog =
24 | savedStateHandle.getLiveData("showThemeDialog", false)
25 |
26 | private val showPomodoroLengthDialog =
27 | savedStateHandle.getLiveData("showPomodoroLengthDialog", false)
28 |
29 | private val showShortBreakLengthDialog =
30 | savedStateHandle.getLiveData("showShortBreakLengthDialog", false)
31 |
32 | private val showLongBreakLengthDialog =
33 | savedStateHandle.getLiveData("showLongBreakLengthDialog", false)
34 |
35 | private val showPomodorosPerSetDialog =
36 | savedStateHandle.getLiveData("showPomodorosPerSetDialog", false)
37 |
38 | private val showAppInstructionsDialog =
39 | savedStateHandle.getLiveData("showAppInstructionsDialog", false)
40 |
41 | val screenState = combineTuple(
42 | appPreferences,
43 | timerPreferences,
44 | showThemeDialog.asFlow(),
45 | showPomodoroLengthDialog.asFlow(),
46 | showShortBreakLengthDialog.asFlow(),
47 | showLongBreakLengthDialog.asFlow(),
48 | showPomodorosPerSetDialog.asFlow(),
49 | showAppInstructionsDialog.asFlow(),
50 | ).map { (
51 | appPreferences,
52 | timerPreferences,
53 | showThemeDialog,
54 | showPomodoroLengthDialog,
55 | showShortBreakLengthDialog,
56 | showLongBreakLengthDialog,
57 | showPomodorosPerSetDialog,
58 | showAppInstructionsDialog,
59 | ) ->
60 | SettingsScreenState(
61 | appPreferences = appPreferences,
62 | timerPreferences = timerPreferences,
63 | showThemeDialog = showThemeDialog,
64 | showPomodoroLengthDialog = showPomodoroLengthDialog,
65 | showShortBreakLengthDialog = showShortBreakLengthDialog,
66 | showLongBreakLengthDialog = showLongBreakLengthDialog,
67 | showPomodorosPerSetDialog = showPomodorosPerSetDialog,
68 | showAppInstructionsDialog = showAppInstructionsDialog,
69 | )
70 | }.asLiveData()
71 |
72 | override fun onThemePreferenceClicked() {
73 | showThemeDialog.value = true
74 | }
75 |
76 | override fun onThemeSelected(theme: ThemeSelection) {
77 | showThemeDialog.value = false
78 | viewModelScope.launch {
79 | preferencesManager.updateSelectedTheme(theme)
80 | }
81 | }
82 |
83 | override fun onThemeDialogDismissed() {
84 | showThemeDialog.value = false
85 | }
86 |
87 | override fun onPomodoroLengthPreferenceClicked() {
88 | showPomodoroLengthDialog.value = true
89 | }
90 |
91 | override fun onPomodoroLengthSet(lengthInMinutes: Int) {
92 | viewModelScope.launch {
93 | preferencesManager.updatePomodoroLength(lengthInMinutes)
94 | showPomodoroLengthDialog.value = false
95 | }
96 | }
97 |
98 | override fun onPomodoroLengthDialogDismissed() {
99 | showPomodoroLengthDialog.value = false
100 | }
101 |
102 | override fun onShortBreakLengthPreferenceClicked() {
103 | showShortBreakLengthDialog.value = true
104 | }
105 |
106 | override fun onShortBreakLengthSet(lengthInMinutes: Int) {
107 | viewModelScope.launch {
108 | preferencesManager.updateShortBreakLength(lengthInMinutes)
109 | showShortBreakLengthDialog.value = false
110 | }
111 | }
112 |
113 | override fun onShortBreakLengthDialogDismissed() {
114 | showShortBreakLengthDialog.value = false
115 | }
116 |
117 | override fun onLongBreakLengthPreferenceClicked() {
118 | showLongBreakLengthDialog.value = true
119 | }
120 |
121 | override fun onLongBreakLengthSet(lengthInMinutes: Int) {
122 | viewModelScope.launch {
123 | preferencesManager.updateLongBreakLength(lengthInMinutes)
124 | showLongBreakLengthDialog.value = false
125 | }
126 | }
127 |
128 | override fun onLongBreakLengthDialogDismissed() {
129 | showLongBreakLengthDialog.value = false
130 | }
131 |
132 | override fun onPomodorosPerSetPreferenceClicked() {
133 | showPomodorosPerSetDialog.value = true
134 | }
135 |
136 | override fun onPomodorosPerSetSet(amount: Int) {
137 | viewModelScope.launch {
138 | preferencesManager.updatePomodorosPerSet(amount)
139 | showPomodorosPerSetDialog.value = false
140 | }
141 | }
142 |
143 | override fun onAutoStartNextTimerCheckedChanged(checked: Boolean) {
144 | viewModelScope.launch {
145 | preferencesManager.updateAutoStartNextTimer(autoStartNextTimer = checked)
146 | }
147 | }
148 |
149 | override fun onPomodorosPerSetDialogDismissed() {
150 | showPomodorosPerSetDialog.value = false
151 | }
152 |
153 | override fun onShowAppInstructionsClicked() {
154 | showAppInstructionsDialog.value = true
155 | }
156 |
157 | override fun onAppInstructionsDialogDismissed() {
158 | showAppInstructionsDialog.value = false
159 | }
160 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/settings/model/SettingsScreenState.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.settings.model
2 |
3 | import com.florianwalther.incentivetimer.data.datastore.AppPreferences
4 | import com.florianwalther.incentivetimer.data.datastore.ThemeSelection
5 | import com.florianwalther.incentivetimer.data.datastore.TimerPreferences
6 |
7 | data class SettingsScreenState(
8 | val appPreferences: AppPreferences,
9 | val timerPreferences: TimerPreferences,
10 | val showThemeDialog: Boolean,
11 | val showPomodoroLengthDialog: Boolean,
12 | val showShortBreakLengthDialog: Boolean,
13 | val showLongBreakLengthDialog: Boolean,
14 | val showPomodorosPerSetDialog: Boolean,
15 | val showAppInstructionsDialog: Boolean,
16 | ) {
17 | companion object {
18 | val initialState = SettingsScreenState(
19 | appPreferences = AppPreferences(
20 | selectedTheme = ThemeSelection.SYSTEM,
21 | appInstructionsDialogShown = false,
22 | ),
23 | timerPreferences = TimerPreferences(
24 | pomodoroLengthInMinutes = 0,
25 | shortBreakLengthInMinutes = 0,
26 | longBreakLengthInMinutes = 0,
27 | pomodorosPerSet = 0,
28 | autoStartNextTimer = false,
29 | ),
30 | showThemeDialog = false,
31 | showPomodoroLengthDialog = false,
32 | showShortBreakLengthDialog = false,
33 | showLongBreakLengthDialog = false,
34 | showPomodorosPerSetDialog = false,
35 | showAppInstructionsDialog = false,
36 | )
37 | }
38 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/statistics/StatisticsActions.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.statistics
2 |
3 | interface StatisticsActions {
4 |
5 | fun onPomodoroMinutesCompletedGranularitySelected(granularity: StatisticsGranularity)
6 | fun onResetPomodoroStatisticsClicked()
7 | fun onResetPomodoroStatisticsConfirmed()
8 | fun onResetPomodoroStatisticsDialogDismissed()
9 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/statistics/StatisticsViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.statistics
2 |
3 | import androidx.annotation.StringRes
4 | import androidx.lifecycle.*
5 | import com.florianwalther.incentivetimer.R
6 | import com.florianwalther.incentivetimer.core.util.dayAfter
7 | import com.florianwalther.incentivetimer.core.util.withOutTime
8 | import com.florianwalther.incentivetimer.data.db.PomodoroStatisticDao
9 | import com.florianwalther.incentivetimer.features.statistics.model.DailyPomodoroStatistic
10 | import com.florianwalther.incentivetimer.features.statistics.model.StatisticsScreenState
11 | import com.zhuinden.flowcombinetuplekt.combineTuple
12 | import dagger.hilt.android.lifecycle.HiltViewModel
13 | import kotlinx.coroutines.channels.Channel
14 | import kotlinx.coroutines.flow.Flow
15 | import kotlinx.coroutines.flow.map
16 | import kotlinx.coroutines.flow.receiveAsFlow
17 | import kotlinx.coroutines.launch
18 | import java.util.*
19 | import javax.inject.Inject
20 |
21 | enum class StatisticsGranularity(@StringRes val readableName: Int, val days: Int?) {
22 | LAST_7_DAYS(R.string.last_7_days, 7),
23 | LAST_14_DAYS(R.string.last_14_days, 14),
24 | LAST_30_DAYS(R.string.last_30_days, 30),
25 | ALL_TIME(R.string.all_time, null),
26 | }
27 |
28 | @HiltViewModel
29 | class StatisticsViewModel @Inject constructor(
30 | private val pomodoroStatisticDao: PomodoroStatisticDao,
31 | savedStateHandle: SavedStateHandle,
32 | ) : ViewModel(), StatisticsActions {
33 |
34 | private val statisticsGranularity =
35 | savedStateHandle.getLiveData(
36 | "statisticsGranularity",
37 | StatisticsGranularity.LAST_7_DAYS
38 | )
39 |
40 | private val selectedStatisticsGranularityIndex = statisticsGranularity.map { granularity ->
41 | StatisticsGranularity.values().indexOf(granularity)
42 | }
43 |
44 | private val allDailyPomodoroStatistics: Flow> =
45 | pomodoroStatisticDao.getAllPomodoroStatistics().map { pomodoroStatistics ->
46 | pomodoroStatistics
47 | .groupBy { pomodoroStatistic ->
48 | Date(pomodoroStatistic.timestampInMilliseconds).withOutTime()
49 | }
50 | .toMutableMap()
51 | .apply {
52 | // generate statistics for zero minute days
53 | if (isNotEmpty()) {
54 | val firstDate = keys.first()
55 | val lastDate = keys.last()
56 | var currentDate = firstDate
57 |
58 | while (currentDate.time < lastDate.time && currentDate.dayAfter().time < lastDate.time) {
59 | if (!keys.contains(currentDate.dayAfter())) {
60 | this[currentDate.dayAfter()] = emptyList()
61 | }
62 | currentDate = currentDate.dayAfter()
63 | }
64 | }
65 | }
66 | .toSortedMap()
67 | .map { (date, pomodoroStatistics) ->
68 | DailyPomodoroStatistic(
69 | date,
70 | pomodoroStatistics.sumOf { it.pomodoroDurationInMinutes }
71 | )
72 | }
73 | }
74 |
75 | private val dailyPomodoroStatisticsInTimeframe = combineTuple(
76 | statisticsGranularity.asFlow(),
77 | allDailyPomodoroStatistics
78 | ).map { (statisticsGranularity, allDailyStatistics) ->
79 | statisticsGranularity.days?.let { days ->
80 | allDailyStatistics.takeLast(days)
81 | } ?: allDailyStatistics
82 | }
83 |
84 | private val pomodoroMinutesCompletedInTimeframe: Flow =
85 | dailyPomodoroStatisticsInTimeframe.map { dailyPomodoroStatistics ->
86 | dailyPomodoroStatistics.sumOf { it.totalPomodoroDurationInMinutes }
87 | }
88 |
89 | private val showResetPomodoroStatisticsDialog =
90 | savedStateHandle.getLiveData(
91 | "showResetPomodoroStatisticsDialog",
92 | false
93 | )
94 |
95 | val screenState = combineTuple(
96 | dailyPomodoroStatisticsInTimeframe,
97 | pomodoroMinutesCompletedInTimeframe,
98 | selectedStatisticsGranularityIndex.asFlow(),
99 | showResetPomodoroStatisticsDialog.asFlow(),
100 | ).map { (
101 | dailyPomodoroStatistics,
102 | pomodoroMinutesCompletedInTimeframe,
103 | selectedStatisticsGranularityIndex,
104 | showResetPomodoroStatisticsDialog,
105 | ) ->
106 | StatisticsScreenState(
107 | dailyPomodoroStatisticsInTimeframe = dailyPomodoroStatistics,
108 | pomodoroMinutesCompletedInTimeframe = pomodoroMinutesCompletedInTimeframe,
109 | selectedStatisticsGranularityIndex = selectedStatisticsGranularityIndex,
110 | showResetPomodoroStatisticsDialog = showResetPomodoroStatisticsDialog,
111 | )
112 | }.asLiveData()
113 |
114 | private val eventChannel = Channel()
115 | val events: Flow = eventChannel.receiveAsFlow()
116 |
117 | sealed class StatisticsEvent {
118 | object ResetChartZoom : StatisticsEvent()
119 | }
120 |
121 | override fun onPomodoroMinutesCompletedGranularitySelected(granularity: StatisticsGranularity) {
122 | statisticsGranularity.value = granularity
123 | viewModelScope.launch {
124 | eventChannel.send(StatisticsEvent.ResetChartZoom)
125 | }
126 | }
127 |
128 | override fun onResetPomodoroStatisticsClicked() {
129 | showResetPomodoroStatisticsDialog.value = true
130 | }
131 |
132 | override fun onResetPomodoroStatisticsConfirmed() {
133 | showResetPomodoroStatisticsDialog.value = false
134 | viewModelScope.launch {
135 | pomodoroStatisticDao.deleteAllPomodoroStatistics()
136 | }
137 | }
138 |
139 | override fun onResetPomodoroStatisticsDialogDismissed() {
140 | showResetPomodoroStatisticsDialog.value = false
141 | }
142 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/statistics/model/DailyPomodoroStatistic.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.statistics.model
2 |
3 | import java.util.*
4 |
5 | data class DailyPomodoroStatistic(
6 | val dateWithoutTime: Date,
7 | val totalPomodoroDurationInMinutes: Int,
8 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/statistics/model/StatisticsScreenState.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.statistics.model
2 |
3 | data class StatisticsScreenState(
4 | val dailyPomodoroStatisticsInTimeframe: List,
5 | val pomodoroMinutesCompletedInTimeframe: Int,
6 | val selectedStatisticsGranularityIndex: Int,
7 | val showResetPomodoroStatisticsDialog: Boolean,
8 | ) {
9 | companion object {
10 | val initialState = StatisticsScreenState(
11 | dailyPomodoroStatisticsInTimeframe = emptyList(),
12 | pomodoroMinutesCompletedInTimeframe = 0,
13 | selectedStatisticsGranularityIndex = 0,
14 | showResetPomodoroStatisticsDialog = false,
15 | )
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/timer/CountDownTimer.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.timer
2 |
3 | import com.florianwalther.incentivetimer.di.ApplicationScope
4 | import kotlinx.coroutines.CoroutineScope
5 | import kotlinx.coroutines.Job
6 | import kotlinx.coroutines.channels.Channel
7 | import kotlinx.coroutines.delay
8 | import kotlinx.coroutines.flow.receiveAsFlow
9 | import kotlinx.coroutines.launch
10 | import javax.inject.Inject
11 | import javax.inject.Singleton
12 |
13 | @Singleton
14 | class CountDownTimer @Inject constructor(
15 | @ApplicationScope private val scope: CoroutineScope,
16 | private val timeSource: TimeSource,
17 | ) {
18 | private var millisUntilFinished = 0L
19 |
20 | private var timerJob: Job? = null
21 |
22 | fun startTimer(
23 | durationMillis: Long,
24 | countDownInterval: Long,
25 | onTick: suspend (millisUntilFinished: Long) -> Unit,
26 | onFinish: () -> Unit,
27 | ) {
28 | millisUntilFinished = durationMillis
29 | val startTime = timeSource.elapsedRealTime
30 | val targetTime = startTime + durationMillis
31 | timerJob = scope.launch {
32 | while (true) {
33 | if (timeSource.elapsedRealTime < targetTime) {
34 | delay(minOf(countDownInterval, durationMillis))
35 | millisUntilFinished = targetTime - timeSource.elapsedRealTime
36 | println("elapsedRealtime = ${timeSource.elapsedRealTime}")
37 | println("targetTime = ${targetTime}")
38 | onTick(millisUntilFinished)
39 | } else {
40 | println("onFinish targetTime = $targetTime")
41 | onFinish()
42 | break
43 | }
44 | }
45 | }
46 | }
47 |
48 | fun cancelTimer() {
49 | timerJob?.cancel()
50 | timerJob = null
51 | }
52 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/timer/DailyResetManager.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.timer
2 |
3 | import android.app.AlarmManager
4 | import android.app.PendingIntent
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.os.Build
8 | import com.florianwalther.incentivetimer.core.DailyResetBroadcastReceiver
9 | import com.florianwalther.incentivetimer.core.util.dayAfter
10 | import com.florianwalther.incentivetimer.core.util.withOutTime
11 | import dagger.hilt.android.qualifiers.ApplicationContext
12 | import logcat.logcat
13 | import java.util.*
14 | import javax.inject.Inject
15 |
16 | class DailyResetManager @Inject constructor(@ApplicationContext context: Context) {
17 | private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
18 |
19 | private val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
20 | PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
21 | } else {
22 | PendingIntent.FLAG_UPDATE_CURRENT
23 | }
24 |
25 | private val intent = Intent(context, DailyResetBroadcastReceiver::class.java)
26 | private val broadcastIntent = PendingIntent.getBroadcast(context, 0, intent, pendingIntentFlags)
27 |
28 | fun scheduleDailyReset() {
29 | val tomorrowMidnight = Date().withOutTime().dayAfter().time
30 | alarmManager.set(AlarmManager.RTC, tomorrowMidnight, broadcastIntent)
31 | }
32 |
33 | fun stopDailyReset() {
34 | alarmManager.cancel(broadcastIntent)
35 | }
36 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/timer/DefaultTimeSource.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.timer
2 |
3 | import android.os.SystemClock
4 | import javax.inject.Inject
5 |
6 | class DefaultTimeSource @Inject constructor() : TimeSource {
7 | override val elapsedRealTime: Long
8 | get() = SystemClock.elapsedRealtime()
9 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/timer/DefaultTimerServiceManager.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.timer
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import androidx.core.content.ContextCompat
6 | import dagger.hilt.android.qualifiers.ApplicationContext
7 | import javax.inject.Inject
8 |
9 | class DefaultTimerServiceManager @Inject constructor(
10 | @ApplicationContext private val applicationContext: Context,
11 | ) : TimerServiceManager {
12 | override fun startTimerService() {
13 | val serviceIntent = Intent(applicationContext, TimerService::class.java)
14 | ContextCompat.startForegroundService(applicationContext, serviceIntent)
15 | }
16 |
17 | override fun stopTimerService() {
18 | val serviceIntent = Intent(applicationContext, TimerService::class.java)
19 | applicationContext.stopService(serviceIntent)
20 | }
21 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/timer/TimeSource.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.timer
2 |
3 | interface TimeSource {
4 | val elapsedRealTime: Long
5 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/timer/TimerScreenActions.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.timer
2 |
3 | interface TimerScreenActions {
4 | fun onStartStopTimerClicked()
5 | fun onResetTimerClicked()
6 | fun onResetTimerConfirmed()
7 | fun onResetTimerDialogDismissed()
8 | fun onSkipBreakClicked()
9 | fun onSkipBreakConfirmed()
10 | fun onSkipBreakDialogDismissed()
11 | fun onResetPomodoroSetClicked()
12 | fun onResetPomodoroSetConfirmed()
13 | fun onResetPomodoroSetDialogDismissed()
14 | fun onResetPomodoroCountClicked()
15 | fun onResetPomodoroCountConfirmed()
16 | fun onResetPomodoroCountDialogDismissed()
17 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/timer/TimerService.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.timer
2 |
3 | import android.app.Service
4 | import android.content.Intent
5 | import android.os.IBinder
6 | import com.florianwalther.incentivetimer.core.notification.DefaultNotificationHelper
7 | import com.florianwalther.incentivetimer.core.notification.TIMER_SERVICE_NOTIFICATION_ID
8 | import dagger.hilt.android.AndroidEntryPoint
9 | import kotlinx.coroutines.CoroutineScope
10 | import kotlinx.coroutines.SupervisorJob
11 | import kotlinx.coroutines.cancel
12 | import kotlinx.coroutines.flow.collectLatest
13 | import kotlinx.coroutines.launch
14 | import javax.inject.Inject
15 |
16 | @AndroidEntryPoint
17 | class TimerService : Service() {
18 |
19 | private val serviceScope = CoroutineScope(SupervisorJob())
20 |
21 | @Inject
22 | lateinit var pomodoroTimerManager: PomodoroTimerManager
23 |
24 | @Inject
25 | lateinit var notificationHelper: DefaultNotificationHelper
26 |
27 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
28 | startForeground(
29 | TIMER_SERVICE_NOTIFICATION_ID,
30 | notificationHelper.getBaseTimerServiceNotification().build()
31 | )
32 |
33 | serviceScope.launch {
34 | pomodoroTimerManager.pomodoroTimerState.collectLatest { timerState ->
35 | notificationHelper.updateTimerServiceNotification(
36 | currentPhase = timerState.currentPhase,
37 | timeLeftInMillis = timerState.timeLeftInMillis,
38 | timerRunning = timerState.timerRunning
39 | )
40 | }
41 | }
42 | return START_STICKY
43 | }
44 |
45 | override fun onDestroy() {
46 | super.onDestroy()
47 | serviceScope.cancel()
48 | notificationHelper.removeTimerServiceNotification()
49 | }
50 |
51 | override fun onBind(p0: Intent?): IBinder? = null
52 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/timer/TimerServiceManager.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.timer
2 |
3 | interface TimerServiceManager {
4 | fun startTimerService()
5 | fun stopTimerService()
6 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/timer/TimerViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.timer
2 |
3 | import androidx.lifecycle.*
4 | import com.florianwalther.incentivetimer.features.timer.model.TimerScreenState
5 | import com.zhuinden.flowcombinetuplekt.combineTuple
6 | import dagger.hilt.android.lifecycle.HiltViewModel
7 | import kotlinx.coroutines.flow.map
8 | import kotlinx.coroutines.flow.onEach
9 | import kotlinx.coroutines.launch
10 | import logcat.logcat
11 | import javax.inject.Inject
12 |
13 | @HiltViewModel
14 | class TimerViewModel @Inject constructor(
15 | private val pomodoroTimerManager: PomodoroTimerManager,
16 | savedStateHandle: SavedStateHandle,
17 | ) : ViewModel(), TimerScreenActions {
18 |
19 | val pomodoroTimerState = pomodoroTimerManager.pomodoroTimerState
20 | .onEach {
21 | logcat { "timerRunning = ${it.timerRunning}" }
22 | }
23 | .asLiveData()
24 |
25 | private val showResetTimerConfirmationDialog =
26 | savedStateHandle.getLiveData("showResetTimerConfirmationDialog", false)
27 |
28 | private val showSkipBreakConfirmationDialog =
29 | savedStateHandle.getLiveData("showSkipBreakConfirmationDialog", false)
30 |
31 | private val showResetPomodoroSetConfirmationDialog =
32 | savedStateHandle.getLiveData(
33 | "showResetPomodoroSetConfirmationDialog",
34 | false
35 | )
36 |
37 | private val showResetPomodoroCountConfirmationDialog =
38 | savedStateHandle.getLiveData(
39 | "showResetPomodoroCountConfirmationDialog",
40 | false
41 | )
42 |
43 | val screenState = combineTuple(
44 | showResetTimerConfirmationDialog.asFlow(),
45 | showSkipBreakConfirmationDialog.asFlow(),
46 | showResetPomodoroSetConfirmationDialog.asFlow(),
47 | showResetPomodoroCountConfirmationDialog.asFlow(),
48 | ).map { (
49 | showResetTimerConfirmationDialogLiveData,
50 | showSkipBreakConfirmationDialogLivedata,
51 | showResetPomodoroSetConfirmationDialogLiveData,
52 | showResetPomodoroCountConfirmationDialogLiveData
53 | ) ->
54 | TimerScreenState(
55 | showResetTimerConfirmationDialog = showResetTimerConfirmationDialogLiveData,
56 | showSkipBreakConfirmationDialog = showSkipBreakConfirmationDialogLivedata,
57 | showResetPomodoroSetConfirmationDialog = showResetPomodoroSetConfirmationDialogLiveData,
58 | showResetPomodoroCountConfirmationDialog = showResetPomodoroCountConfirmationDialogLiveData,
59 | )
60 | }.asLiveData()
61 |
62 | override fun onStartStopTimerClicked() {
63 | pomodoroTimerManager.startStopTimer()
64 | }
65 |
66 | override fun onResetTimerClicked() {
67 | showResetTimerConfirmationDialog.value = true
68 | }
69 |
70 | override fun onResetTimerConfirmed() {
71 | showResetTimerConfirmationDialog.value = false
72 | viewModelScope.launch {
73 | pomodoroTimerManager.stopAndResetTimer()
74 | }
75 | }
76 |
77 | override fun onResetTimerDialogDismissed() {
78 | showResetTimerConfirmationDialog.value = false
79 | }
80 |
81 | override fun onSkipBreakClicked() {
82 | showSkipBreakConfirmationDialog.value = true
83 | }
84 |
85 | override fun onSkipBreakConfirmed() {
86 | showSkipBreakConfirmationDialog.value = false
87 | pomodoroTimerManager.skipBreak()
88 | }
89 |
90 | override fun onSkipBreakDialogDismissed() {
91 | showSkipBreakConfirmationDialog.value = false
92 | }
93 |
94 | override fun onResetPomodoroSetClicked() {
95 | showResetPomodoroSetConfirmationDialog.value = true
96 | }
97 |
98 | override fun onResetPomodoroSetConfirmed() {
99 | showResetPomodoroSetConfirmationDialog.value = false
100 | viewModelScope.launch {
101 | pomodoroTimerManager.resetPomodoroSet()
102 | }
103 | }
104 |
105 | override fun onResetPomodoroSetDialogDismissed() {
106 | showResetPomodoroSetConfirmationDialog.value = false
107 | }
108 |
109 | override fun onResetPomodoroCountClicked() {
110 | showResetPomodoroCountConfirmationDialog.value = true
111 | }
112 |
113 | override fun onResetPomodoroCountConfirmed() {
114 | showResetPomodoroCountConfirmationDialog.value = false
115 | pomodoroTimerManager.resetPomodoroCount()
116 | }
117 |
118 | override fun onResetPomodoroCountDialogDismissed() {
119 | showResetPomodoroCountConfirmationDialog.value = false
120 | }
121 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/florianwalther/incentivetimer/features/timer/model/TimerScreenState.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.features.timer.model
2 |
3 | data class TimerScreenState(
4 | val showResetTimerConfirmationDialog: Boolean,
5 | val showSkipBreakConfirmationDialog: Boolean,
6 | val showResetPomodoroSetConfirmationDialog: Boolean,
7 | val showResetPomodoroCountConfirmationDialog: Boolean,
8 | ) {
9 | companion object {
10 | val initialState = TimerScreenState(
11 | showResetTimerConfirmationDialog = false,
12 | showSkipBreakConfirmationDialog = false,
13 | showResetPomodoroSetConfirmationDialog = false,
14 | showResetPomodoroCountConfirmationDialog = false,
15 | )
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
13 |
16 |
19 |
22 |
25 |
28 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_play.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_star.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_stop.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_timer.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianwalther/IncentiveTimer/f8a750f1fdcf0dc17e606dd902803fdb52fab1bc/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
16 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FF226CE0
4 | #FF0042ad
5 | #FFF1F6FC
6 |
7 | #FF000000
8 | #FFFFFFFF
9 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #226CE0
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | IncentiveTimer
3 | Timer
4 | Reward list
5 | Add new reward
6 | Scroll to top
7 | Chance
8 | Edit reward
9 | Add reward
10 | Save reward
11 | Reward name
12 | Close
13 | Select icon
14 | Cancel
15 | This field can\'t be blank
16 | Reward added
17 | Reward updated
18 | Open menu
19 | Delete reward
20 | Confirm deletion
21 | Do you really want to delete this reward? This can not be undone!
22 | Delete
23 | Reward deleted
24 | Start timer
25 | Stop timer
26 | Rewards
27 | Reset timer
28 | Reset pomodoro set
29 | Pomodoro
30 | Short break
31 | Long break
32 | Total
33 | Reset pomodoro count
34 | Timer service channel
35 | Pomodoro completed!
36 | Time for a break
37 | Break is over!
38 | Time to get back to work!
39 | Shows a persistent notification when the pomodoro timer is running
40 | Timer completed notifications
41 | Shows a notification when a countdown timer finishes
42 | Do you really want to reset the timer?
43 | Do you really want to reset the whole pomodoro set?
44 | Do you really want to reset your completed pomodoros to 0?
45 | Confirm
46 | Reward unlocked
47 | Unlocked
48 | Delete all unlocked rewards
49 | Do you really want to delete all unlocked rewards?
50 | Skip break
51 | Do you want to skip this break?
52 | Reward unlocked notifications
53 | Shows a notification when a reward was unlocked
54 | Undo
55 | %d selected
56 | Delete all selected items
57 | Delete rewards
58 | Do you really want to delete all selected rewards?
59 | Pause
60 | Resume
61 | Paused
62 | Settings
63 | Ok
64 | Pomodoro length
65 | Short break length
66 | Long break length
67 | min
68 | Reset
69 | %d min
70 | # of Pomodoros before long break
71 | Statistics
72 | Pomodoro minutes completed
73 | No data yet. Your pomodoro statistics will show up here.
74 | min
75 | No options available
76 | Last 7 days
77 | All time
78 | Last 14 days
79 | Last 30 days
80 | Reset statistics
81 | Do you really want to delete all previous Pomodoro statistics?
82 | No rewards found. Click the + button to add a new reward.
83 | h
84 | Pomodoro time completed
85 | Support
86 | Report a bug or request a feature
87 | Info
88 | App version
89 | "Welcome to IncentiveTimer!\nThis app combines the Pomodoro Technique with the power of variable rewards. Create rewards, set a chance on them, and unlock them by finishing your work with the Pomodoro timer."
90 | Instructions
91 | Contact support
92 | Links
93 | Show app instructions
94 | Show privacy policy
95 | General
96 | Theme
97 | Light
98 | Dark
99 | Auto (System Default)
100 | Automatically start next timer when the previous timer has finished
101 | Auto start timer
102 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
16 |
17 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/app/src/test/java/com/florianwalther/incentivetimer/LiveDataTestUtil.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer
2 |
3 | import androidx.annotation.VisibleForTesting
4 | import androidx.lifecycle.LiveData
5 | import androidx.lifecycle.Observer
6 | import java.util.concurrent.CountDownLatch
7 | import java.util.concurrent.TimeUnit
8 | import java.util.concurrent.TimeoutException
9 |
10 |
11 | @VisibleForTesting(otherwise = VisibleForTesting.NONE)
12 | fun LiveData.getOrAwaitValue(
13 | time: Long = 2,
14 | timeUnit: TimeUnit = TimeUnit.SECONDS,
15 | afterObserve: () -> Unit = {}
16 | ): T {
17 | var data: T? = null
18 | val latch = CountDownLatch(1)
19 | val observer = object : Observer {
20 | override fun onChanged(o: T?) {
21 | data = o
22 | latch.countDown()
23 | this@getOrAwaitValue.removeObserver(this)
24 | }
25 | }
26 | this.observeForever(observer)
27 |
28 | try {
29 | afterObserve.invoke()
30 |
31 | // Don't wait indefinitely if the LiveData is not set.
32 | if (!latch.await(time, timeUnit)) {
33 | throw TimeoutException("LiveData value was never set.")
34 | }
35 |
36 | } finally {
37 | this.removeObserver(observer)
38 | }
39 |
40 | @Suppress("UNCHECKED_CAST")
41 | return data as T
42 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/florianwalther/incentivetimer/core/notification/FakeNotificationHelper.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.notification
2 |
3 | import androidx.core.app.NotificationCompat
4 | import com.florianwalther.incentivetimer.data.datastore.PomodoroPhase
5 | import com.florianwalther.incentivetimer.data.db.Reward
6 |
7 | sealed class TimerCompletedNotificationState {
8 | data class Shown(val pomodoroPhase: PomodoroPhase) : TimerCompletedNotificationState()
9 | object NotShown : TimerCompletedNotificationState()
10 | }
11 |
12 | sealed class ResumeTimerNotificationState{
13 | data class Shown(
14 | val currentPhase: PomodoroPhase,
15 | val timeLeftInMillis: Long,
16 | ) : ResumeTimerNotificationState()
17 |
18 | object NotShown : ResumeTimerNotificationState()
19 | }
20 |
21 | class FakeNotificationHelper : NotificationHelper {
22 |
23 | var timerCompletedNotification: TimerCompletedNotificationState =
24 | TimerCompletedNotificationState.NotShown
25 |
26 | var resumeTimerNotification: ResumeTimerNotificationState =
27 | ResumeTimerNotificationState.NotShown
28 |
29 | override fun getBaseTimerServiceNotification(): NotificationCompat.Builder {
30 | TODO("Not yet implemented")
31 | }
32 |
33 | override fun updateTimerServiceNotification(
34 | currentPhase: PomodoroPhase,
35 | timeLeftInMillis: Long,
36 | timerRunning: Boolean
37 | ) {
38 | TODO("Not yet implemented")
39 | }
40 |
41 | override fun showResumeTimerNotification(
42 | currentPhase: PomodoroPhase,
43 | timeLeftInMillis: Long,
44 | ) {
45 | resumeTimerNotification = ResumeTimerNotificationState.Shown(
46 | currentPhase,
47 | timeLeftInMillis,
48 | )
49 | }
50 |
51 | override fun removeResumeTimerNotification() {
52 | resumeTimerNotification = ResumeTimerNotificationState.NotShown
53 | }
54 |
55 | override fun showTimerCompletedNotification(finishedPhase: PomodoroPhase) {
56 | timerCompletedNotification =
57 | TimerCompletedNotificationState.Shown(finishedPhase)
58 | }
59 |
60 | override fun removeTimerCompletedNotification() {
61 | timerCompletedNotification = TimerCompletedNotificationState.NotShown
62 | }
63 |
64 | override fun showRewardUnlockedNotification(reward: Reward) {
65 | TODO("Not yet implemented")
66 | }
67 |
68 | override fun removeTimerServiceNotification() {
69 | TODO("Not yet implemented")
70 | }
71 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/florianwalther/incentivetimer/core/util/DateUtilsTest.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.util
2 |
3 | import org.junit.Assert.*
4 | import org.junit.Test
5 | import java.util.*
6 | import com.google.common.truth.Truth.assertThat
7 |
8 | class DateUtilsTest {
9 |
10 | private val dateTimestamp = 1644583178942L // Fri Feb 11 13:39:38 GMT+01:00 2022
11 |
12 | @Test
13 | fun dateWithoutTime_returnsCorrectValue() {
14 | val dateWithoutTime = Date(dateTimestamp).withOutTime()
15 |
16 | assertThat(dateWithoutTime.time).isEqualTo(1644534000000L)
17 | }
18 |
19 | @Test
20 | fun dayAfter_returnsCorrectValue() {
21 | val dayAfter = Date(dateTimestamp).dayAfter()
22 |
23 | assertThat(dayAfter.time).isEqualTo(dateTimestamp + 24 * 60 * 60 * 1000L)
24 | }
25 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/florianwalther/incentivetimer/core/util/TimeFormatTest.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.core.util
2 |
3 | import org.junit.Assert.*
4 | import org.junit.Test
5 | import com.google.common.truth.Truth.assertThat
6 | import com.google.common.truth.Truth.assertWithMessage
7 |
8 |
9 | class TimeFormatTest {
10 |
11 | @Test
12 | fun formatMillisecondsToTimeString_oneHour_returnsCorrectString() {
13 | val milliseconds = 60 * 60 * 1_000L
14 |
15 | val formattedTimeString = formatMillisecondsToTimeString(milliseconds)
16 |
17 | assertThat(formattedTimeString).isEqualTo("1:00:00")
18 | }
19 |
20 | @Test
21 | fun formatMillisecondsToTimeString_oneMinute_returnsCorrectString() {
22 | val milliseconds = 60 * 1_000L
23 |
24 | val formattedTimeString = formatMillisecondsToTimeString(milliseconds)
25 |
26 | assertThat(formattedTimeString).isEqualTo("1:00")
27 | }
28 |
29 | @Test
30 | fun formatMillisecondsToTimeString_roundsUp() {
31 | val milliseconds = 1L
32 |
33 | val formattedTimeString = formatMillisecondsToTimeString(milliseconds)
34 |
35 | assertThat(formattedTimeString).isEqualTo("0:01")
36 | }
37 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/florianwalther/incentivetimer/data/datastore/FakePomodoroTimerStateManager.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.data.datastore
2 |
3 | import androidx.compose.runtime.MutableState
4 | import com.zhuinden.flowcombinetuplekt.combineTuple
5 | import kotlinx.coroutines.flow.Flow
6 | import kotlinx.coroutines.flow.MutableStateFlow
7 | import kotlinx.coroutines.flow.map
8 |
9 | class FakePomodoroTimerStateManager : PomodoroTimerStateManager {
10 |
11 | private val timerRunning = MutableStateFlow(false)
12 | private val currentPhase = MutableStateFlow(PomodoroPhase.POMODORO)
13 | private val timeLeftInMillis = MutableStateFlow(0L)
14 | private val timeTargetInMillis = MutableStateFlow(0L)
15 | private val pomodorosPerSetTarget = MutableStateFlow(0)
16 | private val pomodorosCompletedInSet = MutableStateFlow(0)
17 | private val pomodorosCompletedTotal = MutableStateFlow(0)
18 |
19 | override val timerState: Flow = combineTuple(
20 | timerRunning,
21 | currentPhase,
22 | timeLeftInMillis,
23 | timeTargetInMillis,
24 | pomodorosPerSetTarget,
25 | pomodorosCompletedInSet,
26 | pomodorosCompletedTotal,
27 | ).map { (
28 | timerRunning,
29 | currentPhase,
30 | timeLeftInMillis,
31 | timeTargetInMillis,
32 | pomodorosPerSetTarget,
33 | pomodorosCompletedInSet,
34 | pomodorosCompletedTotal,
35 | ) ->
36 | PomodoroTimerState(
37 | timerRunning = timerRunning,
38 | currentPhase = currentPhase,
39 | timeLeftInMillis = timeLeftInMillis,
40 | timeTargetInMillis = timeTargetInMillis,
41 | pomodorosPerSetTarget = pomodorosPerSetTarget,
42 | pomodorosCompletedInSet = pomodorosCompletedInSet,
43 | pomodorosCompletedTotal = pomodorosCompletedTotal,
44 | )
45 | }
46 |
47 | override suspend fun updateTimerRunning(timerRunning: Boolean) {
48 | this.timerRunning.value = timerRunning
49 | }
50 |
51 | override suspend fun updateCurrentPhase(phase: PomodoroPhase) {
52 | this.currentPhase.value = phase
53 | }
54 |
55 | override suspend fun updateTimeLeftInMillis(timeLeftInMillis: Long) {
56 | this.timeLeftInMillis.value = timeLeftInMillis
57 | }
58 |
59 | override suspend fun updateTimeTargetInMillis(timeTargetInMillis: Long) {
60 | this.timeTargetInMillis.value = timeTargetInMillis
61 | }
62 |
63 | override suspend fun updatePomodorosPerSetTarget(pomodorosPerSetTarget: Int) {
64 | this.pomodorosPerSetTarget.value = pomodorosPerSetTarget
65 | }
66 |
67 | override suspend fun updatePomodorosCompletedInSet(pomodorosCompletedInSet: Int) {
68 | this.pomodorosCompletedInSet.value = pomodorosCompletedInSet
69 | }
70 |
71 | override suspend fun updatePomodorosCompletedTotal(pomodorosCompletedTotal: Int) {
72 | this.pomodorosCompletedTotal.value = pomodorosCompletedTotal
73 | }
74 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/florianwalther/incentivetimer/data/datastore/FakePreferencesManager.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.data.datastore
2 |
3 | import com.zhuinden.flowcombinetuplekt.combineTuple
4 | import kotlinx.coroutines.flow.Flow
5 | import kotlinx.coroutines.flow.MutableStateFlow
6 | import kotlinx.coroutines.flow.map
7 |
8 | class FakePreferencesManager(
9 | initialPomodoroLengthInMinutes: Int,
10 | initialShortBreakLengthInMinutes: Int,
11 | initialLongBreakLengthInMinutes: Int,
12 | initialPomodorosPerSet: Int,
13 | initialAutoStartNextTimer: Boolean,
14 | ) : PreferencesManager {
15 |
16 | private val pomodoroLengthInMinutes = MutableStateFlow(initialPomodoroLengthInMinutes)
17 | private val shortBreakLengthInMinutes = MutableStateFlow(initialShortBreakLengthInMinutes)
18 | private val longBreakLengthInMinutes = MutableStateFlow(initialLongBreakLengthInMinutes)
19 | private val pomodorosPerSet = MutableStateFlow(initialPomodorosPerSet)
20 | private val autoStartNextTimer = MutableStateFlow(initialAutoStartNextTimer)
21 |
22 | override val appPreferences: Flow
23 | get() = TODO("Not yet implemented")
24 |
25 | override val timerPreferences: Flow = combineTuple(
26 | pomodoroLengthInMinutes,
27 | shortBreakLengthInMinutes,
28 | longBreakLengthInMinutes,
29 | pomodorosPerSet,
30 | autoStartNextTimer,
31 | ).map { (
32 | pomodoroLengthInMinutes,
33 | shortBreakLengthInMinutes,
34 | longBreakLengthInMinutes,
35 | pomodorosPerSet,
36 | autoStartNextTimer,
37 | ) ->
38 | TimerPreferences(
39 | pomodoroLengthInMinutes = pomodoroLengthInMinutes,
40 | shortBreakLengthInMinutes = shortBreakLengthInMinutes,
41 | longBreakLengthInMinutes = longBreakLengthInMinutes,
42 | pomodorosPerSet = pomodorosPerSet,
43 | autoStartNextTimer = autoStartNextTimer,
44 | )
45 | }
46 |
47 | override suspend fun updatePomodoroLength(lengthInMinutes: Int) {
48 | pomodoroLengthInMinutes.value = lengthInMinutes
49 | }
50 |
51 | override suspend fun updateShortBreakLength(lengthInMinutes: Int) {
52 | shortBreakLengthInMinutes.value = lengthInMinutes
53 | }
54 |
55 | override suspend fun updateLongBreakLength(lengthInMinutes: Int) {
56 | longBreakLengthInMinutes.value = lengthInMinutes
57 | }
58 |
59 | override suspend fun updatePomodorosPerSet(amount: Int) {
60 | pomodorosPerSet.value = amount
61 | }
62 |
63 | override suspend fun updateAutoStartNextTimer(autostart: Boolean) {
64 | autoStartNextTimer.value = autostart
65 | }
66 |
67 | override suspend fun updateSelectedTheme(theme: ThemeSelection) {
68 | TODO("Not yet implemented")
69 | }
70 |
71 | override suspend fun updateAppInstructionsDialogShown(shown: Boolean) {
72 | TODO("Not yet implemented")
73 | }
74 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/florianwalther/incentivetimer/data/db/FakePomodoroStatisticDao.kt:
--------------------------------------------------------------------------------
1 | package com.florianwalther.incentivetimer.data.db
2 |
3 | import kotlinx.coroutines.flow.Flow
4 | import kotlinx.coroutines.flow.MutableStateFlow
5 | import kotlinx.coroutines.flow.map
6 |
7 | class FakePomodoroStatisticDao(
8 | pomodoroStatistics: LinkedHashMap = LinkedHashMap()
9 | ) : PomodoroStatisticDao {
10 |
11 | private val pomodoroStatistics =
12 | MutableStateFlow