├── .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 | 20 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 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 |