├── metadata ├── de │ ├── title.txt │ ├── short_description.txt │ └── full_description.txt ├── es │ ├── title.txt │ ├── short_description.txt │ └── full_description.txt ├── fr │ ├── title.txt │ ├── short_description.txt │ └── full_description.txt ├── ru │ ├── title.txt │ ├── short_description.txt │ └── full_description.txt └── en-US │ ├── title.txt │ ├── changelogs │ ├── 1007021.txt │ ├── 1008010.txt │ ├── 1008020.txt │ ├── 1007040.txt │ ├── 1008031.txt │ └── 1007000.txt │ ├── short_description.txt │ ├── images │ ├── icon.png │ └── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── 4.png │ │ └── 5.png │ └── full_description.txt ├── .gitignore ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── app ├── src │ └── main │ │ ├── ic_launcher-playstore.png │ │ ├── res │ │ ├── mipmap-hdpi │ │ │ └── ic_launcher.webp │ │ ├── mipmap-mdpi │ │ │ └── ic_launcher.webp │ │ ├── mipmap-xhdpi │ │ │ └── ic_launcher.webp │ │ ├── mipmap-xxhdpi │ │ │ └── ic_launcher.webp │ │ ├── mipmap-xxxhdpi │ │ │ └── ic_launcher.webp │ │ ├── values │ │ │ ├── ic_launcher_background.xml │ │ │ ├── themes.xml │ │ │ └── strings.xml │ │ ├── xml │ │ │ └── locale_config.xml │ │ ├── mipmap-anydpi │ │ │ └── ic_launcher.xml │ │ ├── drawable │ │ │ ├── round_stop_24.xml │ │ │ ├── round_play_arrow_24.xml │ │ │ ├── round_pause_24.xml │ │ │ ├── round_add_circle_24.xml │ │ │ ├── round_lock_24.xml │ │ │ ├── round_invert_colors_24.xml │ │ │ ├── round_notifications_24.xml │ │ │ ├── round_notifications_none_24.xml │ │ │ ├── round_lock_outline_24.xml │ │ │ ├── round_grid_view_24.xml │ │ │ ├── round_help_outline_24.xml │ │ │ ├── round_settings_24.xml │ │ │ └── ic_launcher_foreground.xml │ │ ├── values-ru │ │ │ └── strings.xml │ │ ├── values-de │ │ │ └── strings.xml │ │ ├── values-es │ │ │ └── strings.xml │ │ └── values-fr │ │ │ └── strings.xml │ │ ├── java │ │ └── com │ │ │ └── justdeax │ │ │ └── composeStopwatch │ │ │ ├── stopwatch │ │ │ ├── StopwatchViewModelFactory.kt │ │ │ ├── StopwatchViewModel.kt │ │ │ └── StopwatchService.kt │ │ │ ├── ui │ │ │ ├── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ ├── dialog │ │ │ │ ├── OkayDialog.kt │ │ │ │ ├── SimpleDialog.kt │ │ │ │ ├── EasyBottomSheet.kt │ │ │ │ ├── DisplayAutoStartDialog.kt │ │ │ │ └── RadioDialog.kt │ │ │ ├── CustomButtons.kt │ │ │ ├── DisplayButton.kt │ │ │ ├── DisplayLaps.kt │ │ │ ├── DisplayButtonInLandscape.kt │ │ │ ├── DisplayAppName.kt │ │ │ ├── DisplayTime.kt │ │ │ └── DisplayActions.kt │ │ │ ├── util │ │ │ ├── Util.kt │ │ │ ├── FormatTime.kt │ │ │ └── DataStoreManager.kt │ │ │ └── AppActivity.kt │ │ └── AndroidManifest.xml ├── proguard-rules.pro └── build.gradle.kts ├── gradle.properties ├── settings.gradle.kts ├── key-rotation.md ├── gradlew.bat ├── README.md ├── gradlew └── LICENSE /metadata/de/title.txt: -------------------------------------------------------------------------------- 1 | Compose Stopwatch -------------------------------------------------------------------------------- /metadata/es/title.txt: -------------------------------------------------------------------------------- 1 | Compose Stopwatch -------------------------------------------------------------------------------- /metadata/fr/title.txt: -------------------------------------------------------------------------------- 1 | Compose Stopwatch -------------------------------------------------------------------------------- /metadata/ru/title.txt: -------------------------------------------------------------------------------- 1 | Compose Stopwatch -------------------------------------------------------------------------------- /metadata/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Compose Stopwatch -------------------------------------------------------------------------------- /metadata/de/short_description.txt: -------------------------------------------------------------------------------- 1 | Stoppuhr im MaterialYou Design, entwickelt für einfache Bedienung und beste Funk -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1007021.txt: -------------------------------------------------------------------------------- 1 | - Improved code 2 | - "Lock Awake" auto close notification after 2 seconds -------------------------------------------------------------------------------- /metadata/es/short_description.txt: -------------------------------------------------------------------------------- 1 | Cronómetro con el tema MaterialYou, diseñado para facilidad de uso y las mejores -------------------------------------------------------------------------------- /metadata/fr/short_description.txt: -------------------------------------------------------------------------------- 1 | Chronomètre au design MaterialYou, conçu pour une utilisation facile et les meil -------------------------------------------------------------------------------- /metadata/ru/short_description.txt: -------------------------------------------------------------------------------- 1 | Секундомер в стиле Material You, разработанный для удобства и лучших функций -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.jks 2 | *.keystore 3 | .gradle/ 4 | .idea/ 5 | .kotlin/ 6 | build/ 7 | local.properties 8 | app/release -------------------------------------------------------------------------------- /metadata/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Stopwatch in the Material You theme, designed for ease of use and best features -------------------------------------------------------------------------------- /metadata/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustDeax/ComposeStopwatch/HEAD/metadata/en-US/images/icon.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustDeax/ComposeStopwatch/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustDeax/ComposeStopwatch/HEAD/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustDeax/ComposeStopwatch/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustDeax/ComposeStopwatch/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustDeax/ComposeStopwatch/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustDeax/ComposeStopwatch/HEAD/metadata/en-US/images/phoneScreenshots/1.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustDeax/ComposeStopwatch/HEAD/metadata/en-US/images/phoneScreenshots/2.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustDeax/ComposeStopwatch/HEAD/metadata/en-US/images/phoneScreenshots/3.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustDeax/ComposeStopwatch/HEAD/metadata/en-US/images/phoneScreenshots/4.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustDeax/ComposeStopwatch/HEAD/metadata/en-US/images/phoneScreenshots/5.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustDeax/ComposeStopwatch/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustDeax/ComposeStopwatch/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1008010.txt: -------------------------------------------------------------------------------- 1 | - Bug fixes 2 | - Improved and faster stopwatch saving 3 | - Add themed icon 4 | - Delete unused icons 5 | - Update metadata -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 2 | android.useAndroidX=true 3 | kotlin.code.style=official 4 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1008020.txt: -------------------------------------------------------------------------------- 1 | - Added settings dialog, more settings! 2 | - Added vibration enable 3 | - Added autostart enable 4 | - Changed minimum android -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #232327 4 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1007040.txt: -------------------------------------------------------------------------------- 1 | - Improved code 2 | - Reduced text display time 3 | - Improved notification text 4 | - Added lap progress for lap analyses 5 | - Optimized text size -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1008031.txt: -------------------------------------------------------------------------------- 1 | 1.8: 2 | - Added settings dialog, more settings! 3 | - Added vibration enable 4 | - Added autostart enable 5 | - Added themed icon 6 | - Changed minimum android 7 | - Improved and faster stopwatch saving -------------------------------------------------------------------------------- /app/src/main/res/xml/locale_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1007000.txt: -------------------------------------------------------------------------------- 1 | - Updated metadata 2 | - Metadata localised into 5 languages 3 | - Added Screen Awake mode 4 | - Changed version naming type (1.1.7 -> 1.7.0) 5 | - Removed multi stopwatch button (was unavailable) and replaced with new mode 6 | - Improved app translation 7 | - Fixed bugs and improved code -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri May 16 2025 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionSha256Sum=61ad310d3c7d3e5da131b76bbf22b5a4c0786e9d892dae8c1658d4b484de3caa 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip 6 | networkTimeout=10000 7 | validateDistributionUrl=true 8 | zipStoreBase=GRADLE_USER_HOME 9 | zipStorePath=wrapper/dists -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_stop_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_play_arrow_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_pause_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_add_circle_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_lock_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_invert_colors_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_notifications_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_notifications_none_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_lock_outline_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | pluginManagement { 3 | repositories { 4 | google { 5 | content { 6 | includeGroupByRegex("com\\.android.*") 7 | includeGroupByRegex("com\\.google.*") 8 | includeGroupByRegex("androidx.*") 9 | } 10 | } 11 | mavenCentral() 12 | gradlePluginPortal() 13 | } 14 | } 15 | dependencyResolutionManagement { 16 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 17 | repositories { 18 | google() 19 | mavenCentral() 20 | } 21 | } 22 | 23 | rootProject.name = "Compose Stopwatch" 24 | include(":app") -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/stopwatch/StopwatchViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.stopwatch 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.justdeax.composeStopwatch.util.DataStoreManager 6 | 7 | class StopwatchViewModelFactory( 8 | private val dataStoreManager: DataStoreManager 9 | ) : ViewModelProvider.Factory { 10 | override fun create(modelClass: Class): T { 11 | if (modelClass.isAssignableFrom(StopwatchViewModel::class.java)) 12 | @Suppress("UNCHECKED_CAST") 13 | return StopwatchViewModel(dataStoreManager) as T 14 | throw IllegalArgumentException("Unknown ViewModel class") 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | //EXTRA DARK THEME 6 | val White0 = Color(0xFFFFFFFF) 7 | val Black0 = Color(0xFF000000) 8 | 9 | //DARK THEME 10 | val Purple10 = Color(0xFFD0BCFF) 11 | val PurpleGrey10 = Color(0xFFCCC2DC) 12 | val Pink10 = Color(0xFFEFB8C8) 13 | val GrayLight = Color(0xFFFCFCFC) 14 | 15 | //LIGHT THEME 16 | val Purple40 = Color(0xFF6750A4) 17 | val PurpleGrey40 = Color(0xFF625B71) 18 | val Pink40 = Color(0xFF7D5260) 19 | val GrayDark = Color(0xFF505050) 20 | 21 | //FOR RECORD 22 | val Gold = Color(0xFFFFB300) 23 | val Silver = Color(0xFFB0B0B0) 24 | val Copper = Color(0xFFFF9100) 25 | val Iron = Color(0xFF444444) 26 | 27 | //HYPERTEXT 28 | val Hypertext = Color(0xFF974FDA) -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.ui.theme 2 | 3 | import androidx.compose.material3.darkColorScheme 4 | import androidx.compose.material3.lightColorScheme 5 | 6 | val ExtraDarkColorScheme = darkColorScheme( 7 | primary = White0, 8 | onPrimary = Black0, 9 | secondary = White0, 10 | tertiary = White0, 11 | onSecondaryContainer = GrayLight, 12 | background = Black0, 13 | surface = Black0 14 | ) 15 | val DarkColorScheme = darkColorScheme( 16 | primary = Purple10, 17 | secondary = PurpleGrey10, 18 | tertiary = Pink10, 19 | onSecondaryContainer = GrayLight 20 | ) 21 | val LightColorScheme = lightColorScheme( 22 | primary = Purple40, 23 | secondary = PurpleGrey40, 24 | tertiary = Pink40, 25 | onSecondaryContainer = GrayDark 26 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/util/Util.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.util 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import androidx.compose.runtime.Immutable 6 | import com.justdeax.composeStopwatch.stopwatch.StopwatchService 7 | import kotlinx.serialization.Serializable 8 | 9 | @Immutable 10 | @Serializable 11 | data class Lap( 12 | val index: Int, 13 | val elapsedTime: Long, 14 | val deltaLap: String 15 | ) 16 | 17 | data class StopwatchState( 18 | val elapsedMsBeforePause: Long, 19 | val startTime: Long, 20 | val isRunning: Boolean 21 | ) 22 | 23 | fun Context.commandService(serviceState: StopwatchAction) { 24 | val intent = Intent(this, StopwatchService::class.java) 25 | intent.action = serviceState.name 26 | this.startService(intent) 27 | } 28 | 29 | enum class StopwatchAction { 30 | START_RESUME, PAUSE, RESET, HARD_RESET, ADD_LAP 31 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_grid_view_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.ui.theme 2 | 3 | import androidx.compose.material3.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 | val Typography = Typography( 10 | bodyLarge = TextStyle( 11 | //RADIO ITEM 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp, 17 | ), 18 | titleLarge = TextStyle( 19 | //APP NAME 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Medium, 22 | fontSize = 23.sp, 23 | ), 24 | titleMedium = TextStyle( 25 | //MEDIUM SIZE TEXT IN SCREEN AWAKE DIALOG AND BOTTOM SHEET 26 | fontFamily = FontFamily.Default, 27 | fontWeight = FontWeight.Normal, 28 | fontSize = 17.sp, 29 | lineHeight = 24.sp, 30 | ) 31 | ) -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_help_outline_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_settings_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /key-rotation.md: -------------------------------------------------------------------------------- 1 | ## 🔑 APK Signature Key Change Notification 2 | This notice is also available at: https://justdeax.github.io/ComposeStopwatch/key-rotation 3 | 4 | Hello, I am the developer of this application. 5 | 6 | Unfortunately, I **lost the signing key** used in previous versions of the app. 7 | As a result, updates signed with the new key **cannot be installed over existing installs**. 8 | To continue publishing updates, I have generated a **new signing key**. 9 | 10 | **Application ID:** `com.justdeax.composeStopwatch` 11 | 12 | **Old key (before 2025-05-01):** 13 | ``` 14 | SHA-256: a8:18:9a:88:76:f7:7c:c7:c1:c4:e9:1d:0f:75:30:5a:ba:36:98:8d:9a:48:91:f5:63:c4:a5:dd:a2:2b:70:33 15 | ``` 16 | 17 | **New key (since 2025-05-01):** 18 | ``` 19 | SHA-256: 6b:2a:b5:9a:56:7e:5e:05:d5:a3:d5:63:66:bd:5a:e0:d1:2a:11:ee:2e:10:46:d5:4d:14:9b:fa:53:43:d2:e0 20 | ``` 21 | 22 | I understand that losing a signing key compromises the trust chain. 23 | To address this: 24 | - I remain in control of this repository and GitHub account 25 | - I am open to further verification steps if required 26 | - I have set up a persistent verification channel to avoid such issues in the future 27 | 28 | Thank you for your understanding and continued support. 29 | -------------------------------------------------------------------------------- /metadata/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Android stopwatch app in the Material You theme, designed for ease of use and best features 2 | 3 | ► New Stopwatch Settings 4 | • Enable Auto Start 3 seconds after app launch with override option 5 | • Enable vibration for better tactile sensation 6 | • Select action when you tap on the clock 7 | 8 | ► Circular progress 9 | • Displays the progress of the current lap based on the very first one 10 | • A dash on the lap progress shows the previous lap 11 | 12 | ► Change theme, new themes are available 13 | • Dynamic theme is only available with android 12+ 14 | • Extra Dark theme saves charging for Amoled screen 15 | 16 | ► Change orientation 17 | • Portrait mode has more space for laps 18 | • In Landscape mode, time text is larger 19 | 20 | ► Select an action when you tap on the clock that shows the time 21 | • Resume or pause 22 | • Resume or add lap 23 | • Resume or pause 24 | • If you don't want to do this, you can remove the action 25 | 26 | ► Switching notifications on/off 27 | • Stopwatch control in notifications 28 | • When switched off, the stopwatch works on ViewModel and DataStore 29 | • When enabled, the stopwatch works on LifecycleService and ForegroundService 30 | 31 | ► Screen Awake mode 32 | • Ability to watch the stopwatch without the screen falling asleep -------------------------------------------------------------------------------- /metadata/ru/full_description.txt: -------------------------------------------------------------------------------- 1 | Приложение-секундомер для Android в стиле Material You, разработанное для удобства использования и лучших функций 2 | 3 | ► Новые настройки секундомера 4 | • Включить автозапуск через 3 секунды после запуска приложения с возможностью отмены 5 | • Включить вибрацию для лучшей тактильной отдачи 6 | • Выбрать действие при нажатии на часы 7 | 8 | ► Круговой прогресс 9 | • Отображает прогресс текущего круга относительно самого первого 10 | • Черточка на прогрессе круга показывает предыдущий круг 11 | 12 | ► Смена темы, доступны новые темы 13 | • Динамическая тема доступна только на Android 12+ 14 | • Экстра-тёмная тема экономит заряд на AMOLED экранах 15 | 16 | ► Изменение ориентации 17 | • В портретном режиме больше места для кругов 18 | • В альбомном режиме текст времени крупнее 19 | 20 | ► Выберите действие при нажатии на часы, показывающие время 21 | • Возобновить или пауза 22 | • Возобновить или добавить круг 23 | • Возобновить или стоп 24 | • Если вы не хотите этого, можете убрать действие 25 | 26 | ► Включение/выключение уведомлений 27 | • Управление секундомером в уведомлениях 28 | • При выключении секундомер работает на ViewModel и DataStore 29 | • При включении секундомер работает на LifecycleService и ForegroundService 30 | 31 | ► Режим активного экрана 32 | • Возможность следить за секундомером без отключения экрана -------------------------------------------------------------------------------- /metadata/de/full_description.txt: -------------------------------------------------------------------------------- 1 | Android-Stoppuhr-App im Material You-Design, entwickelt für einfache Bedienung und beste Funktionen 2 | 3 | ► Neue Stoppuhr-Einstellungen 4 | • Automatischen Start 3 Sekunden nach App-Start mit Übersteuerungsoption aktivieren 5 | • Vibration für besseres taktiles Feedback aktivieren 6 | • Aktion bei Tippen auf die Uhr auswählen 7 | 8 | ► Kreisförtiger Fortschritt 9 | • Zeigt den Fortschritt der aktuellen Runde basierend auf der allerersten an 10 | • Ein Strich auf dem Rundenfortschritt zeigt die vorherige Runde an 11 | 12 | ► Thema ändern, neue Themen verfügbar 13 | • Dynamisches Thema ist nur ab Android 12+ verfügbar 14 | • Extra Dunkel-Thema spart Akku bei AMOLED-Bildschirmen 15 | 16 | ► Ausrichtung ändern 17 | • Porträtmodus bietet mehr Platz für Runden 18 | • Im Querformat ist der Zeittext größer 19 | 20 | ► Wählen Sie eine Aktion aus, wenn Sie auf die Zeit anzeigende Uhr tippen 21 | • Fortsetzen oder Pause 22 | • Fortsetzen oder Runde hinzufügen 23 | • Fortsetzen oder Stopp 24 | • Wenn Sie dies nicht möchten, können Sie die Aktion entfernen 25 | 26 | ► Benachrichtigungen ein-/ausschalten 27 | • Stoppuhr-Steuerung in Benachrichtigungen 28 | • Bei Deaktivierung arbeitet die Stoppuhr mit ViewModel und DataStore 29 | • Bei Aktivierung arbeitet die Stoppuhr mit LifecycleService und ForegroundService 30 | 31 | ► Bildschirm-Wachmodus 32 | • Möglichkeit, die Stoppuhr zu beobachten, ohne dass der Bildschirm in den Ruhezustand geht -------------------------------------------------------------------------------- /metadata/es/full_description.txt: -------------------------------------------------------------------------------- 1 | Aplicación de cronómetro para Android con el tema Material You, diseñada para facilidad de uso y las mejores características 2 | 3 | ► Nuevos ajustes del cronómetro 4 | • Habilitar inicio automático 3 segundos después de iniciar la app con opción de anular 5 | • Activar vibración para mejor sensación táctil 6 | • Seleccionar acción al tocar el reloj 7 | 8 | ► Progreso circular 9 | • Muestra el progreso de la vuelta actual basado en la primera vuelta 10 | • Un guión en el progreso de la vuelta muestra la vuelta anterior 11 | 12 | ► Cambiar tema, nuevos temas disponibles 13 | • El tema dinámico solo está disponible en Android 12+ 14 | • El tema Extra Oscuro ahorra batería en pantallas AMOLED 15 | 16 | ► Cambiar orientación 17 | • El modo retrato tiene más espacio para las vueltas 18 | • En modo paisaje, el texto del tiempo es más grande 19 | 20 | ► Selecciona una acción cuando toques el reloj que muestra el tiempo 21 | • Reanudar o pausar 22 | • Reanudar o añadir vuelta 23 | • Reanudar o detener 24 | • Si no quieres hacer esto, puedes eliminar la acción 25 | 26 | ► Activar/desactivar notificaciones 27 | • Control del cronómetro en las notificaciones 28 | • Cuando está desactivado, el cronómetro funciona con ViewModel y DataStore 29 | • Cuando está activado, el cronómetro funciona con LifecycleService y ForegroundService 30 | 31 | ► Modo de pantalla siempre activa 32 | • Posibilidad de ver el cronómetro sin que la pantalla se apague -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 20 | 21 | 22 | 23 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /metadata/fr/full_description.txt: -------------------------------------------------------------------------------- 1 | Application chronomètre Android au design Material You, conçue pour une utilisation facile et les meilleures fonctionnalités 2 | 3 | ► Nouveaux paramètres du chronomètre 4 | • Activer le démarrage automatique 3 secondes après le lancement avec option de modification 5 | • Activer la vibration pour une meilleure sensation tactile 6 | • Sélectionner l'action lors d'un appui sur l'horloge 7 | 8 | ► Progression circulaire 9 | • Affiche la progression du tour actuel par rapport au tout premier 10 | • Un tiret sur la progression du tour indique le tour précédent 11 | 12 | ► Changez de thème, de nouveaux thèmes sont disponibles 13 | • Le thème dynamique n'est disponible qu'à partir d'Android 12+ 14 | • Le thème Extra Sombre économise la batterie pour les écrans AMOLED 15 | 16 | ► Changez l'orientation 17 | • Le mode portrait offre plus d'espace pour les tours 18 | • En mode paysage, le texte de l'heure est plus grand 19 | 20 | ► Sélectionnez une action lorsque vous appuyez sur l'horloge qui affiche le temps 21 | • Reprendre ou mettre en pause 22 | • Reprendre ou ajouter un tour 23 | • Reprendre ou arrêter 24 | • Si vous ne voulez pas faire cela, vous pouvez supprimer l'action 25 | 26 | ► Activation/désactivation des notifications 27 | • Contrôle du chronomètre dans les notifications 28 | • Lorsqu'elles sont désactivées, le chronomètre fonctionne sur ViewModel et DataStore 29 | • Lorsqu'elles sont activées, le chronomètre fonctionne sur LifecycleService et ForegroundService 30 | 31 | ► Mode écran toujours actif 32 | • Possibilité de regarder le chronomètre sans que l'écran ne s'éteigne -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/ui/dialog/OkayDialog.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.ui.dialog 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.rememberScrollState 6 | import androidx.compose.foundation.verticalScroll 7 | import androidx.compose.material3.AlertDialog 8 | import androidx.compose.material3.Button 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.window.DialogProperties 13 | 14 | @Composable 15 | fun OkayDialog( 16 | title: String, 17 | content: @Composable () -> Unit, 18 | isPortrait: Boolean, 19 | confirmText: String, 20 | onConfirm: () -> Unit 21 | ) { 22 | AlertDialog( 23 | modifier = if (isPortrait) 24 | Modifier 25 | else 26 | Modifier.fillMaxWidth(0.6f), 27 | properties = if (isPortrait) 28 | DialogProperties() 29 | else 30 | DialogProperties(usePlatformDefaultWidth = false), 31 | title = { Text(title) }, 32 | text = { 33 | val scrollState = rememberScrollState() 34 | Column( 35 | modifier = Modifier 36 | .fillMaxWidth() 37 | .verticalScroll(scrollState) 38 | ) { 39 | content() 40 | } 41 | }, 42 | confirmButton = { 43 | Button(onClick = onConfirm) { 44 | Text(confirmText) 45 | } 46 | }, 47 | onDismissRequest = onConfirm 48 | ) 49 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | -keep class kotlinx.coroutines.CoroutineExceptionHandler 2 | -keep class kotlinx.coroutines.internal.MainDispatcherFactory 3 | 4 | # Keep `Companion` object fields of serializable classes. 5 | # This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. 6 | -if @kotlinx.serialization.Serializable class ** 7 | -keepclassmembers class <1> { 8 | static <1>$Companion Companion; 9 | } 10 | 11 | # Keep `serializer()` on companion objects (both default and named) of serializable classes. 12 | -if @kotlinx.serialization.Serializable class ** { 13 | static **$* *; 14 | } 15 | -keepclassmembers class <2>$<3> { 16 | kotlinx.serialization.KSerializer serializer(...); 17 | } 18 | 19 | # Keep `INSTANCE.serializer()` of serializable objects. 20 | -if @kotlinx.serialization.Serializable class ** { 21 | public static ** INSTANCE; 22 | } 23 | -keepclassmembers class <1> { 24 | public static <1> INSTANCE; 25 | kotlinx.serialization.KSerializer serializer(...); 26 | } 27 | 28 | # @Serializable and @Polymorphic are used at runtime for polymorphic serialization. 29 | -keepattributes RuntimeVisibleAnnotations,AnnotationDefault 30 | 31 | # Don't print notes about potential mistakes or omissions in the configuration for kotlinx-serialization classes 32 | # See also https://github.com/Kotlin/kotlinx.serialization/issues/1900 33 | -dontnote kotlinx.serialization.** 34 | 35 | # Serialization core uses `java.lang.ClassValue` for caching inside these specified classes. 36 | # If there is no `java.lang.ClassValue` (for example, in Android), then R8/ProGuard will print a warning. 37 | # However, since in this case they will not be used, we can disable these warnings 38 | -dontwarn kotlinx.serialization.internal.ClassValueReferences -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/util/FormatTime.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.util 2 | 3 | import java.util.Locale 4 | 5 | //GENERAL ------------------------------------ 6 | 7 | fun Long.cutToMs() = String.format(Locale.US, "%02d", (this % 1000)).substring(0, 2) 8 | 9 | //PORTRAIT ----------------------------------- 10 | 11 | fun Long.formatSeconds(): String { 12 | val seconds = this % 60 13 | val minutes = this / 60 % 60 14 | val result = if (this == 3600L) 15 | String.format(Locale.US, "%02d:%02d", minutes, seconds) 16 | else if (minutes != 0L) 17 | String.format(Locale.US, "%01d:%02d", minutes, seconds) 18 | else 19 | String.format(Locale.US, "%01d", seconds) 20 | return "$result." 21 | } 22 | 23 | fun Long.getHours() = "${this / 60 / 60}" 24 | 25 | //LANDSCAPE ---------------------------------- 26 | 27 | fun Long.formatSecondsWithHours(): String { 28 | val seconds = this % 60 29 | val minutes = this / 60 % 60 30 | val hours = this / 60 / 60 31 | val result = if (hours != 0L) 32 | String.format(Locale.US, "%01d:%02d:%02d", hours, minutes, seconds) 33 | else if (minutes != 0L) 34 | String.format(Locale.US, "%01d:%02d", minutes, seconds) 35 | else 36 | String.format(Locale.US, "%01d", seconds) 37 | return "$result." 38 | } 39 | 40 | //FOR LAPS AND NOTIFICATION ------------------ 41 | 42 | fun Long.fullFormatSeconds(): String { 43 | val seconds = this % 60 44 | val minutes = this / 60 % 60 45 | val hours = this / 60 / 60 46 | return if (hours != 0L) 47 | String.format(Locale.US, "%01d:%02d:%02d", hours, minutes, seconds) 48 | else 49 | String.format(Locale.US, "%02d:%02d", minutes, seconds) 50 | } 51 | 52 | fun Long.toFormatString() = (this / 1000).fullFormatSeconds() + "." + this.cutToMs() 53 | -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/ui/dialog/SimpleDialog.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.ui.dialog 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.rememberScrollState 6 | import androidx.compose.foundation.verticalScroll 7 | import androidx.compose.material3.AlertDialog 8 | import androidx.compose.material3.Button 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.window.DialogProperties 13 | 14 | @Composable 15 | fun SimpleDialog( 16 | title: String, 17 | desc: String, 18 | isPortrait: Boolean, 19 | confirmText: String, 20 | onConfirm: () -> Unit, 21 | dismissText: String, 22 | onDismiss: () -> Unit 23 | ) { 24 | AlertDialog( 25 | modifier = if (isPortrait) 26 | Modifier 27 | else 28 | Modifier.fillMaxWidth(0.6f), 29 | properties = if (isPortrait) 30 | DialogProperties() 31 | else 32 | DialogProperties(usePlatformDefaultWidth = false), 33 | title = { Text(title) }, 34 | text = { 35 | val scrollState = rememberScrollState() 36 | Column( 37 | modifier = Modifier 38 | .fillMaxWidth() 39 | .verticalScroll(scrollState) 40 | ) { 41 | Text(desc) 42 | } 43 | }, 44 | confirmButton = { 45 | Button(onClick = onConfirm) { 46 | Text(confirmText) 47 | } 48 | }, 49 | dismissButton = { 50 | Button(onClick = onDismiss) { 51 | Text(dismissText) 52 | } 53 | }, 54 | onDismissRequest = onDismiss 55 | ) 56 | } -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/ui/CustomButtons.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.ui 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.height 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.width 7 | import androidx.compose.material3.Button 8 | import androidx.compose.material3.Icon 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.OutlinedButton 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.painter.Painter 14 | import androidx.compose.ui.unit.dp 15 | 16 | @Composable 17 | fun OutlineIconButton(modifier: Modifier, painter: Painter, contentDesc: String, onClick: () -> Unit) { 18 | Box { 19 | OutlinedButton( 20 | modifier = modifier 21 | .width(90.dp) 22 | .height(60.dp) 23 | .padding(5.dp), 24 | onClick = onClick, 25 | ) { 26 | Icon( 27 | painter = painter, 28 | contentDescription = contentDesc, 29 | tint = MaterialTheme.colorScheme.outline 30 | ) 31 | } 32 | } 33 | } 34 | 35 | @Composable 36 | fun IconButton(width: Int = 70, painter: Painter, contentDesc: String, onClick: () -> Unit) { 37 | Button( 38 | modifier = Modifier 39 | .width(width.dp) 40 | .height(70.dp), 41 | onClick = onClick 42 | ) { 43 | Icon( 44 | painter = painter, 45 | contentDescription = contentDesc, 46 | tint = MaterialTheme.colorScheme.onPrimary 47 | ) 48 | } 49 | } 50 | 51 | @Composable 52 | fun IconButtonInLandscape(height: Int, onClick: () -> Unit, painter: Painter, contentDesc: String) { 53 | Button( 54 | modifier = Modifier 55 | .height(height.dp) 56 | .width(70.dp), 57 | onClick = onClick 58 | ) { 59 | Icon( 60 | painter = painter, 61 | contentDescription = contentDesc, 62 | tint = MaterialTheme.colorScheme.onPrimary 63 | ) 64 | } 65 | } -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/ui/dialog/EasyBottomSheet.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.ui.dialog 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.rememberScrollState 7 | import androidx.compose.foundation.verticalScroll 8 | import androidx.compose.material3.Button 9 | import androidx.compose.material3.ExperimentalMaterial3Api 10 | import androidx.compose.material3.ModalBottomSheet 11 | import androidx.compose.material3.SheetState 12 | import androidx.compose.material3.Text 13 | import androidx.compose.material3.rememberModalBottomSheetState 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.rememberCoroutineScope 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.unit.dp 18 | import kotlinx.coroutines.launch 19 | 20 | @OptIn(ExperimentalMaterial3Api::class) 21 | @Composable 22 | fun EasyBottomSheet( 23 | modifier: Modifier = Modifier, 24 | sheetState: SheetState = rememberModalBottomSheetState(), 25 | show: Boolean, 26 | onDismissRequest: () -> Unit, 27 | onButtonClick: () -> Unit, 28 | sheetContent: @Composable () -> Unit 29 | ) { 30 | val scope = rememberCoroutineScope() 31 | if (show) { 32 | ModalBottomSheet( 33 | onDismissRequest = onDismissRequest, 34 | sheetState = sheetState, 35 | modifier = modifier 36 | ) { 37 | Column( 38 | Modifier 39 | .fillMaxWidth() 40 | .padding(12.dp, 2.dp) 41 | ) { 42 | val scrollState = rememberScrollState() 43 | Column( 44 | Modifier 45 | .weight(1f) 46 | .verticalScroll(scrollState) 47 | ) { sheetContent() } 48 | Button( 49 | modifier = Modifier 50 | .fillMaxWidth() 51 | .padding(12.dp), 52 | onClick = { 53 | scope 54 | .launch { 55 | sheetState.hide() 56 | } 57 | .invokeOnCompletion { 58 | if (!sheetState.isVisible) onButtonClick() 59 | } 60 | } 61 | ) { 62 | Text("OK") 63 | } 64 | } 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | agp = "8.10.0" 3 | kotlin = "2.1.21" 4 | coreKtx = "1.16.0" 5 | junit = "4.13.2" 6 | junitVersion = "1.2.1" 7 | espressoCore = "3.6.1" 8 | lifecycleRuntimeKtx = "2.9.0" 9 | activityCompose = "1.10.1" 10 | composeBom = "2025.05.00" 11 | 12 | lifecycleService = "2.9.0" 13 | lifecycleViewmodelCompose = "2.9.0" 14 | datastorePreferences = "1.1.6" 15 | runtimeLivedata = "1.8.1" 16 | kotlinxSerializationJson = "1.8.1" 17 | 18 | [libraries] 19 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 20 | junit = { group = "junit", name = "junit", version.ref = "junit" } 21 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 22 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } 23 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } 24 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } 25 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } 26 | androidx-ui = { group = "androidx.compose.ui", name = "ui" } 27 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } 28 | androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } 29 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } 30 | androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } 31 | androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } 32 | androidx-material3 = { group = "androidx.compose.material3", name = "material3" } 33 | 34 | androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycleService" } 35 | androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } 36 | androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } 37 | androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" } 38 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } 39 | 40 | [plugins] 41 | android-application = { id = "com.android.application", version.ref = "agp" } 42 | jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 43 | kotlinCompose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.jetbrains.kotlin.android) 4 | alias(libs.plugins.kotlinCompose) 5 | kotlin("plugin.serialization") version "1.9.22" 6 | } 7 | 8 | android { 9 | namespace = "com.justdeax.composeStopwatch" 10 | compileSdk = 35 11 | 12 | defaultConfig { 13 | applicationId = "com.justdeax.composeStopwatch" 14 | minSdk = 26 15 | targetSdk = 35 16 | versionCode = 1008031 17 | versionName = "1.8.3" 18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 19 | vectorDrawables { useSupportLibrary = true } 20 | } 21 | 22 | buildTypes { 23 | release { 24 | isMinifyEnabled = true 25 | isShrinkResources = true 26 | proguardFiles( 27 | getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" 28 | ) 29 | 30 | isDebuggable = false 31 | isJniDebuggable = false 32 | isPseudoLocalesEnabled = false 33 | } 34 | } 35 | dependenciesInfo { 36 | includeInApk = false 37 | includeInBundle = false 38 | } 39 | compileOptions { 40 | sourceCompatibility = JavaVersion.VERSION_11 41 | targetCompatibility = JavaVersion.VERSION_11 42 | } 43 | kotlinOptions { 44 | jvmTarget = "11" 45 | } 46 | buildFeatures { 47 | compose = true 48 | buildConfig = true 49 | } 50 | composeOptions { 51 | kotlinCompilerExtensionVersion = "1.5.1" 52 | } 53 | packaging { 54 | resources { 55 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 56 | } 57 | jniLibs { 58 | useLegacyPackaging = true 59 | } 60 | } 61 | } 62 | 63 | dependencies { 64 | implementation(libs.androidx.lifecycle.viewmodel.compose) 65 | implementation(libs.androidx.lifecycle.service) 66 | implementation(libs.androidx.datastore.preferences) 67 | implementation(libs.androidx.runtime.livedata) 68 | implementation(libs.kotlinx.serialization.json) 69 | 70 | implementation(libs.androidx.core.ktx) 71 | implementation(libs.androidx.lifecycle.runtime.ktx) 72 | implementation(libs.androidx.activity.compose) 73 | implementation(platform(libs.androidx.compose.bom)) 74 | implementation(libs.androidx.ui) 75 | implementation(libs.androidx.ui.graphics) 76 | implementation(libs.androidx.ui.tooling.preview) 77 | implementation(libs.androidx.material3) 78 | testImplementation(libs.junit) 79 | androidTestImplementation(libs.androidx.junit) 80 | androidTestImplementation(libs.androidx.espresso.core) 81 | androidTestImplementation(platform(libs.androidx.compose.bom)) 82 | androidTestImplementation(libs.androidx.ui.test.junit4) 83 | debugImplementation(libs.androidx.ui.tooling) 84 | debugImplementation(libs.androidx.ui.test.manifest) 85 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/ui/dialog/DisplayAutoStartDialog.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.ui.dialog 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.height 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.rememberScrollState 8 | import androidx.compose.foundation.verticalScroll 9 | import androidx.compose.material3.AlertDialog 10 | import androidx.compose.material3.Button 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.LaunchedEffect 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.runtime.mutableIntStateOf 16 | import androidx.compose.runtime.mutableStateOf 17 | import androidx.compose.runtime.remember 18 | import androidx.compose.runtime.setValue 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.platform.LocalContext 21 | import androidx.compose.ui.text.font.FontWeight 22 | import androidx.compose.ui.text.style.TextAlign 23 | import androidx.compose.ui.unit.dp 24 | import androidx.compose.ui.unit.sp 25 | import androidx.compose.ui.window.DialogProperties 26 | import com.justdeax.composeStopwatch.R 27 | 28 | @Composable 29 | fun DisplayAutoStartDialog( 30 | isPortrait: Boolean, 31 | onDismiss: () -> Unit, 32 | startStopwatch: () -> Unit 33 | ) { 34 | val context = LocalContext.current 35 | var timeLeft by remember { mutableIntStateOf(3) } 36 | var isFinished by remember { mutableStateOf(false) } 37 | 38 | LaunchedEffect(timeLeft) { 39 | if (timeLeft > 0) { 40 | kotlinx.coroutines.delay(1000L) 41 | timeLeft-- 42 | } else if (!isFinished) { 43 | isFinished = true 44 | startStopwatch() 45 | onDismiss() 46 | } 47 | } 48 | 49 | AlertDialog( 50 | modifier = if (isPortrait) 51 | Modifier 52 | else 53 | Modifier.fillMaxWidth(0.6f), 54 | properties = if (isPortrait) 55 | DialogProperties() 56 | else 57 | DialogProperties(usePlatformDefaultWidth = false), 58 | title = { Text(context.getString(R.string.auto_start_sw_title)) }, 59 | text = { 60 | val scrollState = rememberScrollState() 61 | Column( 62 | modifier = Modifier 63 | .fillMaxWidth() 64 | .verticalScroll(scrollState) 65 | ) { 66 | Text(context.getString(R.string.auto_start_sw_desc_1)) 67 | Text( 68 | text = timeLeft.toString(), 69 | fontSize = 58.sp, 70 | fontWeight = FontWeight.Bold, 71 | textAlign = TextAlign.Center, 72 | modifier = Modifier.fillMaxWidth() 73 | ) 74 | Text(context.getString(R.string.auto_start_sw_desc_2)) 75 | } 76 | }, 77 | confirmButton = { 78 | Button( 79 | onClick = { onDismiss() }, 80 | modifier = Modifier 81 | .fillMaxWidth() 82 | .padding(8.dp) 83 | .height(60.dp) 84 | ) { 85 | Text(text = context.getString(R.string.cancel), fontSize = 20.sp) 86 | } 87 | }, 88 | onDismissRequest = onDismiss 89 | ) 90 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |  3 |  4 |  5 | 6 | 7 | 8 | Compose Stopwatch 9 | Android stopwatch in the Material You theme, designed for ease of use and best feature 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ### Compose Stopwatch 29 | ### Notice! if your app version is 1.8.2 or lower, you need to uninstall the old version to update it 30 | 31 | 🔑 APK Signature Key Change Notification (click) 32 | 33 | --- 34 | 35 | This notice is also available at: https://justdeax.github.io/ComposeStopwatch/key-rotation 36 | 37 | Hello, I am the developer of this application. 38 | 39 | Unfortunately, I **lost the signing key** used in previous versions of the app. 40 | As a result, updates signed with the new key **cannot be installed over existing installs**. 41 | To continue publishing updates, I have generated a **new signing key**. 42 | 43 | **Application ID:** `com.justdeax.composeStopwatch` 44 | 45 | **New key (since 2025-05-01):** 46 | ``` 47 | SHA-256: 6b:2a:b5:9a:56:7e:5e:05:d5:a3:d5:63:66:bd:5a:e0:d1:2a:11:ee:2e:10:46:d5:4d:14:9b:fa:53:43:d2:e0 48 | ``` 49 | 50 | **Old key (before 2025-05-01):** 51 | ``` 52 | SHA-256: a8:18:9a:88:76:f7:7c:c7:c1:c4:e9:1d:0f:75:30:5a:ba:36:98:8d:9a:48:91:f5:63:c4:a5:dd:a2:2b:70:33 53 | ``` 54 | 55 | I understand that losing a signing key compromises the trust chain. 56 | To address this: 57 | - I remain in control of this repository and GitHub account 58 | - I am open to further verification steps if required 59 | - I have set up a persistent verification channel to avoid such issues in the future 60 | 61 | Thank you for your understanding and continued support. 62 | 63 | --- 64 | 65 | 66 | 67 | Android stopwatch app in the Material You theme, designed for ease of use and best features 68 | 69 | - Stopwatch Settings 70 | - Enable Auto Start 3 seconds after app launch with override option 71 | - Enable vibration for better tactile sensation 72 | - Select action when you tap on the clock (was before) 73 | 74 | - Circular progress 75 | - Displays the progress of the current lap based on the very first one 76 | - A dash on the lap progress shows the previous lap 77 | 78 | - Change theme, new themes are available 79 | - Dynamic theme is only available with android 12+ 80 | - Extra Dark theme saves charging for Amoled screen 81 | 82 | - Change orientation 83 | - Portrait mode has more space for laps 84 | - In Landscape mode, time text is larger 85 | 86 | - Select an action when you tap on the clock that shows the time 87 | - Resume or pause 88 | - Resume or add lap 89 | - Resume or pause 90 | - If you don't want to do this, you can remove the action 91 | 92 | - Switching notifications on/off 93 | - Stopwatch control in notifications 94 | - When switched off, the stopwatch works on ViewModel and DataStore 95 | - When enabled, the stopwatch works on LifecycleService and ForegroundService 96 | 97 | - Screen Awake mode 98 | - Ability to watch the stopwatch without the screen falling asleep 99 | -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/ui/dialog/RadioDialog.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.ui.dialog 2 | 3 | import androidx.compose.foundation.layout.Row 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.height 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.lazy.LazyColumn 8 | import androidx.compose.foundation.lazy.itemsIndexed 9 | import androidx.compose.foundation.selection.selectable 10 | import androidx.compose.foundation.selection.selectableGroup 11 | import androidx.compose.material3.AlertDialog 12 | import androidx.compose.material3.Button 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.RadioButton 15 | import androidx.compose.material3.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.LaunchedEffect 18 | import androidx.compose.runtime.getValue 19 | import androidx.compose.runtime.mutableIntStateOf 20 | import androidx.compose.runtime.mutableStateOf 21 | import androidx.compose.runtime.remember 22 | import androidx.compose.runtime.setValue 23 | import androidx.compose.ui.Alignment 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.semantics.Role 26 | import androidx.compose.ui.unit.dp 27 | import androidx.compose.ui.unit.sp 28 | import androidx.compose.ui.window.DialogProperties 29 | 30 | @Composable 31 | fun RadioDialog( 32 | title: String, 33 | desc: String, 34 | isPortrait: Boolean, 35 | defaultIndex: Int, 36 | setSelectedIndex: (Int) -> Unit, 37 | options: Array, 38 | onDismiss: () -> Unit, 39 | confirmText: String, 40 | onConfirm: () -> Unit 41 | ) { 42 | var selectedIndex by remember { mutableIntStateOf(-1) } 43 | 44 | AlertDialog( 45 | modifier = if (isPortrait) 46 | Modifier 47 | else 48 | Modifier.fillMaxWidth(0.6f), 49 | properties = if (isPortrait) 50 | DialogProperties() 51 | else 52 | DialogProperties(usePlatformDefaultWidth = false), 53 | title = { Text(title) }, 54 | text = { 55 | val (currentOption, onOptionSelected) = remember { 56 | mutableStateOf(options[defaultIndex]) 57 | } 58 | 59 | LaunchedEffect(Unit) { 60 | selectedIndex = defaultIndex 61 | } 62 | 63 | LazyColumn(Modifier.selectableGroup()) { 64 | item { 65 | Text( 66 | modifier = Modifier.padding(bottom = 4.dp), 67 | text = desc, 68 | fontSize = 14.sp 69 | ) 70 | } 71 | itemsIndexed(options) { index, text -> 72 | Row( 73 | Modifier 74 | .fillMaxWidth() 75 | .height(56.dp) 76 | .padding(horizontal = 4.dp) 77 | .selectable( 78 | selected = (text == currentOption), 79 | onClick = { 80 | onOptionSelected(text) 81 | selectedIndex = index 82 | }, 83 | role = Role.RadioButton 84 | ), 85 | verticalAlignment = Alignment.CenterVertically 86 | ) { 87 | RadioButton( 88 | modifier = Modifier.padding(16.dp, 0.dp), 89 | selected = (text == currentOption), 90 | onClick = null 91 | ) 92 | Text( 93 | text = text, 94 | style = MaterialTheme.typography.bodyLarge 95 | ) 96 | } 97 | } 98 | } 99 | }, 100 | confirmButton = { 101 | Button({ 102 | onConfirm() 103 | setSelectedIndex(selectedIndex) 104 | }) { 105 | Text(confirmText) 106 | } 107 | }, 108 | onDismissRequest = onDismiss 109 | ) 110 | } -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/util/DataStoreManager.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.util 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.core.booleanPreferencesKey 7 | import androidx.datastore.preferences.core.edit 8 | import androidx.datastore.preferences.core.intPreferencesKey 9 | import androidx.datastore.preferences.core.longPreferencesKey 10 | import androidx.datastore.preferences.core.stringPreferencesKey 11 | import androidx.datastore.preferences.preferencesDataStore 12 | import kotlinx.coroutines.flow.map 13 | 14 | private val Context.dataStore: DataStore by preferencesDataStore("preference") 15 | 16 | class DataStoreManager(private val context: Context) { 17 | companion object { //SW => STOPWATCH 18 | private val SW_ELAPSED_MS_BEFORE_PAUSE = longPreferencesKey("SW_ELAPSED_MS_BEFORE_PAUSE") 19 | private val SW_START_TIME = longPreferencesKey("SW_START_TIME") 20 | private val SW_IS_RUNNING = booleanPreferencesKey("SW_IS_RUNNING") 21 | private val SW_LAPS = stringPreferencesKey("SW_LAPS") 22 | private val SW_NOTIFICATION_ENABLED = booleanPreferencesKey("SW_NOTIFICATION_ENABLED") 23 | private val SW_TAP_ON_CLOCK = intPreferencesKey("SW_TAP_ON_CLOCK") 24 | private val SW_VIBRATION_ENABLED = booleanPreferencesKey("SW_VIBRATION_ENABLED") 25 | private val SW_AUTOSTART_ENABLED = booleanPreferencesKey("SW_AUTOSTART_ENABLES") 26 | private val LOCK_AWAKE = booleanPreferencesKey("LOCK_AWAKE") 27 | private val APP_THEME = intPreferencesKey("APP_THEME_CODE") 28 | } 29 | 30 | suspend fun changeTheme(themeCode: Int) { 31 | context.dataStore.edit { set -> set[APP_THEME] = themeCode } 32 | } 33 | 34 | fun getTheme() = context.dataStore.data.map { get -> 35 | get[APP_THEME] ?: 0 36 | } 37 | 38 | suspend fun changeTapOnClock(tapType: Int) { 39 | context.dataStore.edit { set -> set[SW_TAP_ON_CLOCK] = tapType } 40 | } 41 | 42 | fun getTapOnClock() = context.dataStore.data.map { get -> 43 | get[SW_TAP_ON_CLOCK] ?: 1 44 | } 45 | 46 | suspend fun changeNotificationEnabled(enabled: Boolean) { 47 | context.dataStore.edit { set -> set[SW_NOTIFICATION_ENABLED] = enabled } 48 | } 49 | 50 | fun notificationEnabled() = context.dataStore.data.map { get -> 51 | get[SW_NOTIFICATION_ENABLED] ?: true 52 | } 53 | 54 | suspend fun changeLockAwakeEnabled(enabled: Boolean) { 55 | context.dataStore.edit { set -> set[LOCK_AWAKE] = enabled } 56 | } 57 | 58 | fun lockAwakeEnabled() = context.dataStore.data.map { get -> 59 | get[LOCK_AWAKE] ?: false 60 | } 61 | 62 | suspend fun changeVibrationEnabled(enabled: Boolean) { 63 | context.dataStore.edit { set -> set[SW_VIBRATION_ENABLED] = enabled } 64 | } 65 | 66 | fun vibrationEnabled() = context.dataStore.data.map { get -> 67 | get[SW_VIBRATION_ENABLED] ?: false 68 | } 69 | 70 | suspend fun changeAutoStartEnabled(enabled: Boolean) { 71 | context.dataStore.edit { set -> set[SW_AUTOSTART_ENABLED] = enabled } 72 | } 73 | 74 | fun autoStartEnabled() = context.dataStore.data.map { get -> 75 | get[SW_AUTOSTART_ENABLED] ?: false 76 | } 77 | 78 | suspend fun saveStopwatch(stopwatchState: StopwatchState) { 79 | context.dataStore.edit { set -> 80 | set[SW_ELAPSED_MS_BEFORE_PAUSE] = stopwatchState.elapsedMsBeforePause 81 | set[SW_START_TIME] = stopwatchState.startTime 82 | set[SW_IS_RUNNING] = stopwatchState.isRunning 83 | } 84 | } 85 | 86 | fun restoreStopwatch() = context.dataStore.data.map { get -> 87 | StopwatchState( 88 | get[SW_ELAPSED_MS_BEFORE_PAUSE] ?: 0L, 89 | get[SW_START_TIME] ?: 0L, 90 | get[SW_IS_RUNNING] ?: false, 91 | ) 92 | } 93 | 94 | suspend fun resetStopwatch() { 95 | context.dataStore.edit { set -> 96 | set.remove(SW_ELAPSED_MS_BEFORE_PAUSE) 97 | set.remove(SW_START_TIME) 98 | set.remove(SW_IS_RUNNING) 99 | set.remove(SW_LAPS) 100 | } 101 | } 102 | 103 | suspend fun saveLaps(laps: String) { 104 | context.dataStore.edit { set -> set[SW_LAPS] = laps } 105 | } 106 | 107 | fun restoreLaps() = context.dataStore.data.map { get -> 108 | get[SW_LAPS] ?: "" 109 | } 110 | } -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/ui/DisplayButton.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.ui 2 | 3 | import androidx.compose.animation.EnterTransition 4 | import androidx.compose.animation.core.animateIntAsState 5 | import androidx.compose.animation.core.keyframes 6 | import androidx.compose.animation.core.tween 7 | import androidx.compose.animation.fadeOut 8 | import androidx.compose.foundation.layout.Arrangement 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.Row 11 | import androidx.compose.foundation.layout.Spacer 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.layout.width 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.platform.LocalContext 21 | import androidx.compose.ui.res.painterResource 22 | import androidx.compose.ui.tooling.preview.Preview 23 | import androidx.compose.ui.unit.dp 24 | import com.justdeax.composeStopwatch.R 25 | import com.justdeax.composeStopwatch.ui.theme.DarkColorScheme 26 | 27 | @Composable 28 | fun DisplayButton( 29 | modifier: Modifier, 30 | isStarted: Boolean, 31 | isRunning: Boolean, 32 | showHideAdditional: () -> Unit, 33 | reset: () -> Unit, 34 | startResume: () -> Unit, 35 | pause: () -> Unit, 36 | addLap: () -> Unit, 37 | ) { 38 | val context = LocalContext.current 39 | val startDrawable = painterResource(R.drawable.round_play_arrow_24) 40 | val pauseDrawable = painterResource(R.drawable.round_pause_24) 41 | val stopDrawable = painterResource(R.drawable.round_stop_24) 42 | val addLapsDrawable = painterResource(R.drawable.round_add_circle_24) 43 | val additionalDrawable = painterResource(R.drawable.round_grid_view_24) 44 | val startButtonSizeAnimation by animateIntAsState( 45 | targetValue = if (isStarted) 120 else 300, 46 | animationSpec = keyframes { durationMillis = 250 }, 47 | label = "" 48 | ) 49 | 50 | Row( 51 | modifier = modifier, 52 | horizontalArrangement = Arrangement.Center 53 | ) { 54 | Box(contentAlignment = Alignment.Center) { 55 | androidx.compose.animation.AnimatedVisibility( 56 | visible = isStarted, 57 | enter = EnterTransition.None, 58 | exit = fadeOut(tween(500)) 59 | ) { 60 | Row { 61 | IconButton( 62 | onClick = { showHideAdditional() }, 63 | painter = additionalDrawable, 64 | contentDesc = context.getString(R.string.additional_action) 65 | ) 66 | Spacer(Modifier.width(170.dp)) 67 | if (isRunning) 68 | IconButton( 69 | onClick = { addLap() }, 70 | painter = addLapsDrawable, 71 | contentDesc = context.getString(R.string.add_lap) 72 | ) 73 | else 74 | IconButton( 75 | onClick = { reset() }, 76 | painter = stopDrawable, 77 | contentDesc = context.getString(R.string.stop) 78 | ) 79 | } 80 | } 81 | IconButton( 82 | width = startButtonSizeAnimation, 83 | onClick = { 84 | if (isRunning) pause() 85 | else startResume() 86 | }, 87 | painter = if (isRunning) pauseDrawable else startDrawable, 88 | contentDesc = 89 | if (isRunning) context.getString(R.string.pause) 90 | else context.getString(R.string.resume) 91 | ) 92 | } 93 | } 94 | } 95 | 96 | @Preview(showBackground = true) 97 | @Composable 98 | fun DisplayButtonPreview() { 99 | MaterialTheme(colorScheme = DarkColorScheme) { 100 | DisplayButton( 101 | Modifier 102 | .fillMaxWidth() 103 | .padding(top = 20.dp, bottom = 50.dp), 104 | isStarted = true, 105 | isRunning = false, 106 | showHideAdditional = { }, 107 | reset = { }, 108 | startResume = { }, 109 | pause = { }, 110 | addLap = { } 111 | ) 112 | } 113 | } -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/ui/DisplayLaps.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.ui 2 | 3 | import androidx.compose.animation.core.animateFloatAsState 4 | import androidx.compose.animation.core.tween 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxHeight 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.lazy.LazyColumn 10 | import androidx.compose.foundation.lazy.items 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.CompositionLocalProvider 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.platform.LocalDensity 19 | import androidx.compose.ui.text.TextStyle 20 | import androidx.compose.ui.text.font.FontFamily 21 | import androidx.compose.ui.text.font.FontWeight 22 | import androidx.compose.ui.text.style.TextAlign 23 | import androidx.compose.ui.tooling.preview.Preview 24 | import androidx.compose.ui.unit.Density 25 | import androidx.compose.ui.unit.dp 26 | import androidx.compose.ui.unit.sp 27 | import com.justdeax.composeStopwatch.ui.theme.Copper 28 | import com.justdeax.composeStopwatch.ui.theme.DarkColorScheme 29 | import com.justdeax.composeStopwatch.ui.theme.Gold 30 | import com.justdeax.composeStopwatch.ui.theme.Iron 31 | import com.justdeax.composeStopwatch.ui.theme.Silver 32 | import com.justdeax.composeStopwatch.util.Lap 33 | import com.justdeax.composeStopwatch.util.toFormatString 34 | 35 | @Composable 36 | fun DisplayLaps( 37 | modifier: Modifier, 38 | laps: List, 39 | elapsedMs: Long 40 | ) { 41 | val heightAnimation by animateFloatAsState( 42 | targetValue = if (laps.isEmpty()) 0.0001f else 1f, 43 | animationSpec = tween(500), 44 | label = "" 45 | ) 46 | Row(modifier = modifier.fillMaxHeight(heightAnimation)) { 47 | LazyColumn { 48 | if (laps.isNotEmpty()) 49 | item { 50 | val deltaLap = "+ ${(elapsedMs - laps.first().elapsedTime).toFormatString()}" 51 | LapItem("+", MaterialTheme.colorScheme.onBackground, elapsedMs, deltaLap) 52 | } 53 | items(laps, key = { laps[it.index - 1].index }) { (index, elapsedTime, deltaLap) -> 54 | val indexColor = when (index) { 55 | 1 -> Gold 56 | 2 -> Silver 57 | 3 -> Copper 58 | else -> Iron 59 | } 60 | LapItem(index.toString(), indexColor, elapsedTime, deltaLap) 61 | } 62 | } 63 | } 64 | } 65 | 66 | @Composable 67 | fun LapItem(indexText: String, indexColor: Color, elapsedTime: Long, deltaLap: String) { 68 | val newDensity = Density(LocalDensity.current.density, fontScale = 1f) 69 | Row( 70 | modifier = Modifier 71 | .padding(12.dp) 72 | .fillMaxWidth() 73 | ) { 74 | CompositionLocalProvider(LocalDensity provides newDensity) { 75 | val textStyle = TextStyle( 76 | fontFamily = FontFamily.Monospace, 77 | fontSize = 20.sp 78 | ) 79 | Text( 80 | modifier = Modifier.weight(1f), 81 | text = indexText, 82 | style = textStyle, 83 | fontWeight = FontWeight.Bold, 84 | color = indexColor 85 | ) 86 | Text( 87 | modifier = Modifier.weight(2f), 88 | text = elapsedTime.toFormatString(), 89 | style = textStyle, 90 | fontWeight = FontWeight.Normal 91 | ) 92 | Text( 93 | modifier = Modifier.weight(2f), 94 | text = deltaLap, 95 | style = textStyle, 96 | fontWeight = FontWeight.Medium, 97 | color = MaterialTheme.colorScheme.onSecondaryContainer, 98 | textAlign = TextAlign.End, 99 | ) 100 | } 101 | } 102 | } 103 | 104 | @Preview(showBackground = true) 105 | @Composable 106 | fun DisplayLapsPreview() { 107 | MaterialTheme(colorScheme = DarkColorScheme) { 108 | DisplayLaps( 109 | modifier = Modifier 110 | .padding(8.dp, 0.dp) 111 | .fillMaxWidth(), 112 | laps = listOf(Lap(1, 1000L, "+ 1.00")), 113 | elapsedMs = 2000L 114 | ) 115 | } 116 | } -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/ui/DisplayButtonInLandscape.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.ui 2 | 3 | import androidx.compose.animation.EnterTransition 4 | import androidx.compose.animation.core.animateIntAsState 5 | import androidx.compose.animation.core.keyframes 6 | import androidx.compose.animation.core.tween 7 | import androidx.compose.animation.fadeOut 8 | import androidx.compose.foundation.layout.Arrangement 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.Spacer 12 | import androidx.compose.foundation.layout.fillMaxHeight 13 | import androidx.compose.foundation.layout.height 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.platform.LocalContext 21 | import androidx.compose.ui.res.painterResource 22 | import androidx.compose.ui.tooling.preview.Preview 23 | import androidx.compose.ui.unit.dp 24 | import com.justdeax.composeStopwatch.R 25 | import com.justdeax.composeStopwatch.ui.theme.DarkColorScheme 26 | 27 | @Composable 28 | fun DisplayButtonInLandscape( 29 | modifier: Modifier, 30 | isStarted: Boolean, 31 | isRunning: Boolean, 32 | showHideAdditional: () -> Unit, 33 | reset: () -> Unit, 34 | startResume: () -> Unit, 35 | pause: () -> Unit, 36 | addLap: () -> Unit, 37 | ) { 38 | val context = LocalContext.current 39 | val startDrawable = painterResource(R.drawable.round_play_arrow_24) 40 | val pauseDrawable = painterResource(R.drawable.round_pause_24) 41 | val stopDrawable = painterResource(R.drawable.round_stop_24) 42 | val addLapsDrawable = painterResource(R.drawable.round_add_circle_24) 43 | val additionalDrawable = painterResource(R.drawable.round_grid_view_24) 44 | val startButtonSizeAnimation by animateIntAsState( 45 | targetValue = if (isStarted) 120 else 300, 46 | animationSpec = keyframes { durationMillis = 250 }, 47 | label = "" 48 | ) 49 | 50 | Column( 51 | modifier = modifier, 52 | verticalArrangement = Arrangement.Center 53 | ) { 54 | Box(contentAlignment = Alignment.Center) { 55 | androidx.compose.animation.AnimatedVisibility( 56 | visible = isStarted, 57 | enter = EnterTransition.None, 58 | exit = fadeOut(tween(500)) 59 | ) { 60 | Column { 61 | IconButton( 62 | onClick = { showHideAdditional() }, 63 | painter = additionalDrawable, 64 | contentDesc = context.getString(R.string.additional_action) 65 | ) 66 | Spacer(Modifier.height(170.dp)) 67 | if (isRunning) 68 | IconButton( 69 | onClick = { addLap() }, 70 | painter = addLapsDrawable, 71 | contentDesc = context.getString(R.string.add_lap) 72 | ) 73 | else 74 | IconButton( 75 | onClick = { reset() }, 76 | painter = stopDrawable, 77 | contentDesc = context.getString(R.string.stop) 78 | ) 79 | } 80 | } 81 | IconButtonInLandscape( 82 | height = startButtonSizeAnimation, 83 | onClick = { 84 | if (isRunning) pause() 85 | else startResume() 86 | }, 87 | painter = if (isRunning) pauseDrawable else startDrawable, 88 | contentDesc = 89 | if (isRunning) context.getString(R.string.pause) 90 | else context.getString(R.string.resume) 91 | ) 92 | } 93 | } 94 | } 95 | 96 | @Preview(showBackground = true) 97 | @Composable 98 | fun DisplayButtonInLandscapePreview() { 99 | MaterialTheme(colorScheme = DarkColorScheme) { 100 | DisplayButtonInLandscape( 101 | Modifier 102 | .fillMaxHeight() 103 | .padding(start = 50.dp, end = 20.dp), 104 | isStarted = true, 105 | isRunning = false, 106 | showHideAdditional = { }, 107 | reset = { }, 108 | startResume = { }, 109 | pause = { }, 110 | addLap = { } 111 | ) 112 | } 113 | } -------------------------------------------------------------------------------- /app/src/main/res/values-ru/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Отмена 3 | ОК 4 | Применить 5 | 6 | Уведомления секундомера 7 | Приостановлено 8 | Пауза 9 | Возобновить 10 | Стоп 11 | Добавить круг 12 | Последний круг 13 | 14 | Настройки секундомера 15 | Дополнительные действия 16 | Автозапуск секундомера при запуске приложения 17 | Включить вибрацию 18 | Включить/выключить уведомления 19 | Тема 20 | Не выключать экран 21 | 22 | Изменить действие при нажатии на часы 23 | Выберите действие при нажатии на часы, показывающие время 24 | 25 | Ничего 26 | Возобновить или пауза 27 | Возобновить или добавить круг 28 | Возобновить или стоп 29 | 30 | 31 | Сбросить секундомер? 32 | Для включения уведомлений необходимо сбросить секундомер 33 | Для отключения уведомлений необходимо сбросить секундомер 34 | 35 | Изменить тему 36 | Динамическая тема доступна только на Android 12+\nЭкстра-тёмная тема экономит заряд на AMOLED экранах 37 | 38 | Системная 39 | Светлая 40 | Тёмная 41 | Экстра-тёмная 42 | 43 | 44 | Системная (динамическая тема) 45 | Светлая 46 | Тёмная 47 | Экстра-тёмная 48 | 49 | 50 | Режим активного экрана 51 | Режим активного экрана включен 52 | Режим активного экрана выключен 53 | 54 | Автозапуск 55 | Автоматический запуск секундомера начнется через 56 | Вы можете полностью отключить автозапуск в настройках секундомера 57 | 58 | О приложении Compose Stopwatch 59 | "Приложение-секундомер для Android в стиле Material You, разработанное для удобства использования и лучших функций 60 | 61 | ► Новые настройки секундомера 62 | • Включить автозапуск через 3 секунды после запуска приложения с возможностью отмены 63 | • Включить вибрацию для лучшей тактильной отдачи 64 | • Выбрать действие при нажатии на часы 65 | 66 | ► Круговой прогресс 67 | • Отображает прогресс текущего круга относительно самого первого 68 | • Черточка на прогрессе круга показывает предыдущий круг 69 | 70 | ► Смена темы, доступны новые темы 71 | • Динамическая тема доступна только на Android 12+ 72 | • Экстра-тёмная тема экономит заряд на AMOLED экранах 73 | 74 | ► Изменение ориентации 75 | • В портретном режиме больше места для кругов 76 | • В альбомном режиме текст времени крупнее 77 | 78 | ► Выберите действие при нажатии на часы, показывающие время 79 | • Возобновить или пауза 80 | • Возобновить или добавить круг 81 | • Возобновить или стоп 82 | • Если вы не хотите этого, можете убрать действие 83 | 84 | ► Включение/выключение уведомлений 85 | • Управление секундомером в уведомлениях 86 | • При выключении секундомер работает на ViewModel и DataStore 87 | • При включении секундомер работает на LifecycleService и ForegroundService 88 | 89 | ► Режим активного экрана 90 | • Возможность следить за секундомером без отключения экрана 91 | " 92 | Автор: 93 | \nВерсия: 94 | 95 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Compose Stopwatch 3 | 1.8.3 4 | JustDeax (github.com/JustDeax) 5 | 6 | Cancel 7 | OK 8 | Apply 9 | 10 | Stopwatch notifications 11 | Paused 12 | Pause 13 | Resume 14 | Stop 15 | Add Lap 16 | Last Lap 17 | 18 | Stopwatch Settings 19 | Additional Actions 20 | Autostart Stopwatch when launch app 21 | Turn on vibration 22 | Turn on/off notification 23 | Theme 24 | Lock Awake 25 | 26 | Change tap on clock 27 | Select an action when you tap on the clock that shows the time 28 | 29 | None 30 | Resume or Pause 31 | Resume or Add Lap 32 | Resume or Stop 33 | 34 | 35 | Reset Stopwatch? 36 | To enable notifications you need to reset the stopwatch 37 | To disable notifications you need to reset the stopwatch 38 | 39 | Change theme 40 | Dynamic theme is only available with android 12+\nExtra Dark theme saves charging for Amoled screen 41 | 42 | System 43 | Light 44 | Dark 45 | Extra Dark 46 | 47 | 48 | System (Dynamic theme) 49 | Light 50 | Dark 51 | Extra Dark 52 | 53 | 54 | Screen Awake Mode 55 | The screen wake-up mode is switched on 56 | The screen wake-up mode is switched off 57 | 58 | Auto Start 59 | Automatic start of the stopwatch will begin after 60 | You can disable auto start completely in the stopwatch settings 61 | 62 | About Compose Stopwatch 63 | "Android stopwatch app in the Material You theme, designed for ease of use and best features 64 | 65 | ► Stopwatch Settings 66 | • Enable Auto Start 3 seconds after app launch with override option 67 | • Enable vibration for better tactile sensation 68 | • Select action when you tap on the clock 69 | 70 | ► Circular progress 71 | • Displays the progress of the current lap based on the very first one 72 | • A dash on the lap progress shows the previous lap 73 | 74 | ► Change theme, new themes are available 75 | • Dynamic theme is only available with android 12+ 76 | • Extra Dark theme saves charging for Amoled screen 77 | 78 | ► Change orientation 79 | • Portrait mode has more space for laps 80 | • In Landscape mode, time text is larger 81 | 82 | ► Select an action when you tap on the clock that shows the time 83 | • Resume or pause 84 | • Resume or add lap 85 | • Resume or pause 86 | • If you don't want to do this, you can remove the action 87 | 88 | ► Switching notifications on/off 89 | • Stopwatch control in notifications 90 | • When switched off, the stopwatch works on ViewModel and DataStore 91 | • When enabled, the stopwatch works on LifecycleService and ForegroundService 92 | 93 | ► Screen Awake mode 94 | • Ability to watch the stopwatch without the screen falling asleep 95 | " 96 | Author: 97 | \nVersion: 98 | 99 | -------------------------------------------------------------------------------- /app/src/main/res/values-de/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Abbrechen 3 | OK 4 | Anwenden 5 | 6 | Stoppuhr-Benachrichtigungen 7 | Pausiert 8 | Pause 9 | Fortsetzen 10 | Stopp 11 | Runde hinzufügen 12 | Letzte Runde 13 | 14 | Stoppuhr-Einstellungen 15 | Zusätzliche Aktionen 16 | Stoppuhr automatisch starten, wenn App gestartet wird 17 | Vibration einschalten 18 | Benachrichtigungen ein-/ausschalten 19 | Thema 20 | Bildschirm wach halten 21 | 22 | Aktion bei Tippen auf die Uhr ändern 23 | Wählen Sie eine Aktion aus, wenn Sie auf die Zeit anzeigende Uhr tippen 24 | 25 | Keine 26 | Fortsetzen oder Pause 27 | Fortsetzen oder Runde hinzufügen 28 | Fortsetzen oder Stopp 29 | 30 | 31 | Stoppuhr zurücksetzen? 32 | Um Benachrichtigungen zu aktivieren, müssen Sie die Stoppuhr zurücksetzen 33 | Um Benachrichtigungen zu deaktivieren, müssen Sie die Stoppuhr zurücksetzen 34 | 35 | Thema ändern 36 | Dynamisches Thema ist nur ab Android 12+ verfügbar\nExtra Dunkel-Thema spart Akku bei AMOLED-Bildschirmen 37 | 38 | System 39 | Hell 40 | Dunkel 41 | Extra Dunkel 42 | 43 | 44 | System (Dynamisches Thema) 45 | Hell 46 | Dunkel 47 | Extra Dunkel 48 | 49 | 50 | Bildschirm-Wachmodus 51 | Der Bildschirm-Wachmodus ist eingeschaltet 52 | Der Bildschirm-Wachmodus ist ausgeschaltet 53 | 54 | Automatischer Start 55 | Der automatische Start der Stoppuhr beginnt nach 56 | Sie können den automatischen Start vollständig in den Stoppuhr-Einstellungen deaktivieren 57 | 58 | Über Compose Stopwatch 59 | "Android-Stoppuhr-App im Material You-Design, entwickelt für einfache Bedienung und beste Funktionen 60 | 61 | ► Neue Stoppuhr-Einstellungen 62 | • Automatischen Start 3 Sekunden nach App-Start mit Übersteuerungsoption aktivieren 63 | • Vibration für besseres taktiles Feedback aktivieren 64 | • Aktion bei Tippen auf die Uhr auswählen 65 | 66 | ► Kreisförtiger Fortschritt 67 | • Zeigt den Fortschritt der aktuellen Runde basierend auf der allerersten an 68 | • Ein Strich auf dem Rundenfortschritt zeigt die vorherige Runde an 69 | 70 | ► Thema ändern, neue Themen verfügbar 71 | • Dynamisches Thema ist nur ab Android 12+ verfügbar 72 | • Extra Dunkel-Thema spart Akku bei AMOLED-Bildschirmen 73 | 74 | ► Ausrichtung ändern 75 | • Porträtmodus bietet mehr Platz für Runden 76 | • Im Querformat ist der Zeittext größer 77 | 78 | ► Wählen Sie eine Aktion aus, wenn Sie auf die Zeit anzeigende Uhr tippen 79 | • Fortsetzen oder Pause 80 | • Fortsetzen oder Runde hinzufügen 81 | • Fortsetzen oder Stopp 82 | • Wenn Sie dies nicht möchten, können Sie die Aktion entfernen 83 | 84 | ► Benachrichtigungen ein-/ausschalten 85 | • Stoppuhr-Steuerung in Benachrichtigungen 86 | • Bei Deaktivierung arbeitet die Stoppuhr mit ViewModel und DataStore 87 | • Bei Aktivierung arbeitet die Stoppuhr mit LifecycleService und ForegroundService 88 | 89 | ► Bildschirm-Wachmodus 90 | • Möglichkeit, die Stoppuhr zu beobachten, ohne dass der Bildschirm in den Ruhezustand geht 91 | " 92 | Autor: 93 | \nVersion: 94 | 95 | -------------------------------------------------------------------------------- /app/src/main/res/values-es/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Cancelar 3 | OK 4 | Aplicar 5 | 6 | Notificaciones del cronómetro 7 | En pausa 8 | Pausar 9 | Reanudar 10 | Detener 11 | Añadir vuelta 12 | Última Vuelta 13 | 14 | Ajustes del cronómetro 15 | Acciones adicionales 16 | Inicio automático del cronómetro al iniciar la aplicación 17 | Activar vibración 18 | Activar/desactivar notificaciones 19 | Tema 20 | Mantener pantalla activa 21 | 22 | Cambiar acción al tocar el reloj 23 | Selecciona una acción cuando toques el reloj que muestra el tiempo 24 | 25 | Ninguna 26 | Reanudar o pausar 27 | Reanudar o añadir vuelta 28 | Reanudar o detener 29 | 30 | 31 | ¿Reiniciar el cronómetro? 32 | Para activar las notificaciones, debes reiniciar el cronómetro 33 | Para desactivar las notificaciones, debes reiniciar el cronómetro 34 | 35 | Cambiar tema 36 | El tema dinámico solo está disponible en Android 12+\nEl tema Extra Oscuro ahorra batería en pantallas AMOLED 37 | 38 | Sistema 39 | Claro 40 | Oscuro 41 | Extra Oscuro 42 | 43 | 44 | Sistema (Tema dinámico) 45 | Claro 46 | Oscuro 47 | Extra Oscuro 48 | 49 | 50 | Modo de pantalla siempre activa 51 | El modo de pantalla siempre activa está activado 52 | El modo de pantalla siempre activa está desactivado 53 | 54 | Inicio automático 55 | El inicio automático del cronómetro comenzará después de 56 | Puedes desactivar el inicio automático por completo en la configuración del cronómetro 57 | 58 | Acerca de Compose Stopwatch 59 | "Aplicación de cronómetro para Android con el tema Material You, diseñada para facilidad de uso y las mejores características 60 | 61 | ► Nuevos ajustes del cronómetro 62 | • Habilitar inicio automático 3 segundos después de iniciar la app con opción de anular 63 | • Activar vibración para mejor sensación táctil 64 | • Seleccionar acción al tocar el reloj 65 | 66 | ► Progreso circular 67 | • Muestra el progreso de la vuelta actual basado en la primera vuelta 68 | • Un guion en el progreso de la vuelta muestra la vuelta anterior 69 | 70 | ► Cambiar tema, nuevos temas disponibles 71 | • El tema dinámico solo está disponible en Android 12+ 72 | • El tema Extra Oscuro ahorra batería en pantallas AMOLED 73 | 74 | ► Cambiar orientación 75 | • El modo retrato tiene más espacio para las vueltas 76 | • En modo paisaje, el texto del tiempo es más grande 77 | 78 | ► Selecciona una acción cuando toques el reloj que muestra el tiempo 79 | • Reanudar o pausar 80 | • Reanudar o añadir vuelta 81 | • Reanudar o detener 82 | • Si no quieres hacer esto, puedes eliminar la acción 83 | 84 | ► Activar/desactivar notificaciones 85 | • Control del cronómetro en las notificaciones 86 | • Cuando está desactivado, el cronómetro funciona con ViewModel y DataStore 87 | • Cuando está activado, el cronómetro funciona con LifecycleService y ForegroundService 88 | 89 | ► Modo de pantalla siempre activa 90 | • Posibilidad de ver el cronómetro sin que la pantalla se apague 91 | " 92 | Autor: 93 | \nVersión: 94 | 95 | -------------------------------------------------------------------------------- /app/src/main/res/values-fr/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Annuler 3 | OK 4 | Appliquer 5 | 6 | Notifications du chronomètre 7 | En pause 8 | Pause 9 | Reprendre 10 | Arrêter 11 | Ajouter un tour 12 | Dernier Tour 13 | 14 | Paramètres du chronomètre 15 | Actions supplémentaires 16 | Démarrage automatique du chronomètre au lancement de l\'application 17 | Activer la vibration 18 | Activer/désactiver les notifications 19 | Thème 20 | Maintenir l\'écran allumé 21 | 22 | Modifier l\'action au toucher de l\'horloge 23 | Sélectionnez une action lorsque vous appuyez sur l\'horloge qui affiche le temps 24 | 25 | Aucune 26 | Reprendre ou mettre en pause 27 | Reprendre ou ajouter un tour 28 | Reprendre ou arrêter 29 | 30 | 31 | Réinitialiser le chronomètre ? 32 | Pour activer les notifications, vous devez réinitialiser le chronomètre 33 | Pour désactiver les notifications, vous devez réinitialiser le chronomètre 34 | 35 | Changer de thème 36 | Le thème dynamique n\'est disponible qu\'à partir d\'Android 12+\nLe thème Extra Sombre économise la batterie pour les écrans AMOLED 37 | 38 | Système 39 | Clair 40 | Sombre 41 | Extra Sombre 42 | 43 | 44 | Système (Thème dynamique) 45 | Clair 46 | Sombre 47 | Extra Sombre 48 | 49 | 50 | Mode écran toujours actif 51 | Le mode écran toujours actif est activé 52 | Le mode écran toujours actif est désactivé 53 | 54 | Démarrage automatique 55 | Le démarrage automatique du chronomètre commencera après 56 | Vous pouvez désactiver le démarrage automatique complètement dans les paramètres du chronomètre 57 | 58 | À propos de Compose Stopwatch 59 | "Application chronomètre Android au design Material You, conçue pour une utilisation facile et les meilleures fonctionnalités 60 | 61 | ► Nouveaux paramètres du chronomètre 62 | • Activer le démarrage automatique 3 secondes après le lancement avec option de modification 63 | • Activer la vibration pour une meilleure sensation tactile 64 | • Sélectionner l'action lors d'un appui sur l'horloge 65 | 66 | ► Progression circulaire 67 | • Affiche la progression du tour actuel par rapport au tout premier 68 | • Un tiret sur la progression du tour indique le tour précédent 69 | 70 | ► Changez de thème, de nouveaux thèmes sont disponibles 71 | • Le thème dynamique n\'est disponible qu\'à partir d\'Android 12+ 72 | • Le thème Extra Sombre économise la batterie pour les écrans AMOLED 73 | 74 | ► Changez l\'orientation 75 | • Le mode portrait offre plus d\'espace pour les tours 76 | • En mode paysage, le texte de l\'heure est plus grand 77 | 78 | ► Sélectionnez une action lorsque vous appuyez sur l\'horloge qui affiche le temps 79 | • Reprendre ou mettre en pause 80 | • Reprendre ou ajouter un tour 81 | • Reprendre ou arrêter 82 | • Si vous ne voulez pas faire cela, vous pouvez supprimer l\'action 83 | 84 | ► Activation/désactivation des notifications 85 | • Contrôle du chronomètre dans les notifications 86 | • Lorsqu\'elles sont désactivées, le chronomètre fonctionne sur ViewModel et DataStore 87 | • Lorsqu\'elles sont activées, le chronomètre fonctionne sur LifecycleService et ForegroundService 88 | 89 | ► Mode écran toujours actif 90 | • Possibilité de regarder le chronomètre sans que l\'écran ne s\'éteigne 91 | " 92 | Auteur : 93 | \nVersion : 94 | 95 | -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/ui/DisplayAppName.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.ui 2 | 3 | import android.content.Intent 4 | import androidx.compose.animation.core.tween 5 | import androidx.compose.animation.fadeIn 6 | import androidx.compose.animation.fadeOut 7 | import androidx.compose.animation.slideInVertically 8 | import androidx.compose.animation.slideOutVertically 9 | import androidx.compose.foundation.clickable 10 | import androidx.compose.foundation.interaction.MutableInteractionSource 11 | import androidx.compose.foundation.layout.Row 12 | import androidx.compose.foundation.layout.Spacer 13 | import androidx.compose.foundation.layout.height 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.foundation.layout.size 16 | import androidx.compose.foundation.layout.width 17 | import androidx.compose.material3.ExperimentalMaterial3Api 18 | import androidx.compose.material3.Icon 19 | import androidx.compose.material3.MaterialTheme 20 | import androidx.compose.material3.Text 21 | import androidx.compose.material3.rememberModalBottomSheetState 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.runtime.getValue 24 | import androidx.compose.runtime.mutableStateOf 25 | import androidx.compose.runtime.remember 26 | import androidx.compose.runtime.setValue 27 | import androidx.compose.ui.Alignment 28 | import androidx.compose.ui.Modifier 29 | import androidx.compose.ui.platform.LocalContext 30 | import androidx.compose.ui.res.painterResource 31 | import androidx.compose.ui.text.SpanStyle 32 | import androidx.compose.ui.text.buildAnnotatedString 33 | import androidx.compose.ui.text.style.TextDecoration 34 | import androidx.compose.ui.text.withStyle 35 | import androidx.compose.ui.tooling.preview.Preview 36 | import androidx.compose.ui.unit.dp 37 | import androidx.core.net.toUri 38 | import com.justdeax.composeStopwatch.R 39 | import com.justdeax.composeStopwatch.ui.dialog.EasyBottomSheet 40 | import com.justdeax.composeStopwatch.ui.theme.DarkColorScheme 41 | import com.justdeax.composeStopwatch.ui.theme.Hypertext 42 | 43 | @OptIn(ExperimentalMaterial3Api::class) 44 | @Composable 45 | fun DisplayAppName( 46 | modifier: Modifier, 47 | show: Boolean 48 | ) { 49 | val context = LocalContext.current 50 | val helpDraw = painterResource(R.drawable.round_help_outline_24) 51 | var showAboutApp by remember { mutableStateOf(false) } 52 | 53 | androidx.compose.animation.AnimatedVisibility( 54 | visible = show, 55 | enter = fadeIn(tween(500)) + slideInVertically(tween(500)) { -40 }, 56 | exit = fadeOut(tween(300)) + slideOutVertically(tween(300)) { -40 } 57 | ) { 58 | Row( 59 | modifier = modifier.clickable( 60 | remember { MutableInteractionSource() }, null 61 | ) { showAboutApp = true }, 62 | verticalAlignment = Alignment.CenterVertically 63 | ) { 64 | Text( 65 | text = context.getString(R.string.app_name), 66 | style = MaterialTheme.typography.titleLarge 67 | ) 68 | Spacer(modifier = Modifier.width(6.dp)) 69 | Icon( 70 | modifier = Modifier.size(24.dp), 71 | painter = helpDraw, 72 | contentDescription = context.getString(R.string.about_app), 73 | tint = MaterialTheme.colorScheme.onBackground 74 | ) 75 | } 76 | } 77 | 78 | val sheetState = rememberModalBottomSheetState() 79 | EasyBottomSheet( 80 | sheetState = sheetState, 81 | show = showAboutApp, 82 | onDismissRequest = { showAboutApp = false }, 83 | onButtonClick = { showAboutApp = false } 84 | ) { 85 | Text( 86 | text = context.getString(R.string.about_app), 87 | style = MaterialTheme.typography.titleLarge 88 | ) 89 | Spacer(Modifier.height(8.dp)) 90 | Text( 91 | text = context.getString(R.string.about_app_desc), 92 | style = MaterialTheme.typography.titleMedium 93 | ) 94 | val annotatedString = buildAnnotatedString { 95 | append(context.getString(R.string.about_app_desc_a) + " ") 96 | withStyle( 97 | style = SpanStyle( 98 | color = Hypertext, 99 | textDecoration = TextDecoration.Underline 100 | ) 101 | ) { 102 | append(context.getString(R.string.app_author)) 103 | } 104 | append(context.getString(R.string.about_app_desc_v)) 105 | append(" " + context.getString(R.string.app_version)) 106 | } 107 | Text( 108 | text = annotatedString, 109 | style = MaterialTheme.typography.titleMedium, 110 | modifier = Modifier.clickable { 111 | val intent = Intent(Intent.ACTION_VIEW, "https://github.com/JustDeax".toUri()) 112 | context.startActivity(intent) 113 | } 114 | ) 115 | } 116 | } 117 | 118 | @Preview(showBackground = true) 119 | @Composable 120 | fun DisplayAppNamePreview() { 121 | MaterialTheme(colorScheme = DarkColorScheme) { 122 | DisplayAppName( 123 | modifier = Modifier.padding(21.dp, 16.dp), 124 | show = true 125 | ) 126 | } 127 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/stopwatch/StopwatchViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.stopwatch 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MutableLiveData 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.asLiveData 7 | import androidx.lifecycle.viewModelScope 8 | import com.justdeax.composeStopwatch.util.DataStoreManager 9 | import com.justdeax.composeStopwatch.util.Lap 10 | import com.justdeax.composeStopwatch.util.StopwatchState 11 | import com.justdeax.composeStopwatch.util.toFormatString 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.cancelChildren 14 | import kotlinx.coroutines.delay 15 | import kotlinx.coroutines.flow.first 16 | import kotlinx.coroutines.launch 17 | import kotlinx.serialization.json.Json 18 | import java.util.LinkedList 19 | 20 | class StopwatchViewModel(private val dataStoreManager: DataStoreManager) : ViewModel() { 21 | private var elapsedMsBeforePause = 0L 22 | private var startTime = 0L 23 | val theme = dataStoreManager.getTheme().asLiveData() 24 | val tapOnClock = dataStoreManager.getTapOnClock().asLiveData() 25 | val notificationEnabled = dataStoreManager.notificationEnabled().asLiveData() 26 | val lockAwakeEnabled = dataStoreManager.lockAwakeEnabled().asLiveData() 27 | val vibrationEnabled = dataStoreManager.vibrationEnabled().asLiveData() 28 | val autoStartEnabled = dataStoreManager.autoStartEnabled().asLiveData() 29 | 30 | fun changeTheme(themeCode: Int) = viewModelScope.launch { 31 | dataStoreManager.changeTheme(themeCode) 32 | } 33 | 34 | fun changeTapOnClock(tapType: Int) = viewModelScope.launch { 35 | dataStoreManager.changeTapOnClock(tapType) 36 | } 37 | 38 | fun changeNotificationEnabled(enabled: Boolean) = viewModelScope.launch { 39 | dataStoreManager.changeNotificationEnabled(enabled) 40 | } 41 | 42 | fun changeLockAwakeEnabled(enabled: Boolean) = viewModelScope.launch { 43 | dataStoreManager.changeLockAwakeEnabled(enabled) 44 | } 45 | 46 | fun changeVibrationEnabled(enabled: Boolean) = viewModelScope.launch { 47 | dataStoreManager.changeVibrationEnabled(enabled) 48 | } 49 | 50 | fun changeAutoStartEnabled(enabled: Boolean) = viewModelScope.launch { 51 | dataStoreManager.changeAutoStartEnabled(enabled) 52 | } 53 | 54 | private fun saveStopwatch() { 55 | if (!notificationEnabled.value!!) 56 | viewModelScope.launch { 57 | dataStoreManager.saveStopwatch( 58 | StopwatchState( 59 | elapsedMsBeforePause, 60 | startTime, 61 | isRunning.value!! 62 | ) 63 | ) 64 | } 65 | } 66 | 67 | fun restoreStopwatch() { 68 | viewModelScope.launch { 69 | val laps = dataStoreManager.restoreLaps().first() 70 | this@StopwatchViewModel.laps.value = if (laps.isNotEmpty()) 71 | LinkedList(Json.decodeFromString>(laps)) 72 | else LinkedList() 73 | dataStoreManager.restoreStopwatch().collect { restoredState -> 74 | elapsedMsBeforePause = restoredState.elapsedMsBeforePause 75 | startTime = restoredState.startTime 76 | isRunning.value = restoredState.isRunning 77 | if (isRunning.value == true) { 78 | val currentTime = System.currentTimeMillis() 79 | elapsedMsBeforePause += currentTime - startTime 80 | startTime = currentTime 81 | startResume() 82 | } else { 83 | elapsedMs.value = elapsedMsBeforePause 84 | elapsedSec.value = elapsedMsBeforePause / 1000 85 | } 86 | isStarted.value = elapsedMsBeforePause != 0L 87 | } 88 | } 89 | } 90 | 91 | fun startResume() { 92 | if (isStarted.value == true && isRunning.value == true) return 93 | isStarted.value = true 94 | isRunning.value = true 95 | viewModelScope.launch(Dispatchers.Main) { 96 | if (startTime == 0L) startTime = System.currentTimeMillis() 97 | saveStopwatch() 98 | while (isRunning.value!!) { 99 | elapsedMs.postValue((System.currentTimeMillis() - startTime) + elapsedMsBeforePause) 100 | val seconds = elapsedMs.value!! / 1000 101 | if (elapsedSec.value != seconds) elapsedSec.postValue(seconds) 102 | delay(10) 103 | } 104 | } 105 | } 106 | 107 | fun pause() { 108 | isRunning.value = false 109 | elapsedMsBeforePause = elapsedMs.value!! 110 | startTime = 0L 111 | saveStopwatch() 112 | } 113 | 114 | fun reset() { 115 | isStarted.value = false 116 | isRunning.value = false 117 | elapsedMs.value = 0L 118 | elapsedSec.value = 0L 119 | elapsedMsBeforePause = 0L 120 | laps.value!!.clear() 121 | previousLapDelta.value = 1L 122 | viewModelScope.launch { 123 | dataStoreManager.resetStopwatch() 124 | viewModelScope.coroutineContext.cancelChildren() 125 | } 126 | } 127 | 128 | fun hardReset() { 129 | pause() 130 | viewModelScope.launch { 131 | delay(10) 132 | reset() 133 | } 134 | } 135 | 136 | fun addLap() { 137 | viewModelScope.launch(Dispatchers.Main) { 138 | val deltaLap = if (laps.value!!.isEmpty()) 139 | elapsedMs.value!! 140 | else 141 | elapsedMs.value!! - laps.value!!.first().elapsedTime 142 | val deltaLapString = "+ ${deltaLap.toFormatString()}" 143 | val newLaps = LinkedList(laps.value!!) 144 | newLaps.addFirst(Lap(laps.value!!.size + 1, elapsedMs.value!!, deltaLapString)) 145 | laps.value = newLaps 146 | previousLapDelta.value = deltaLap 147 | dataStoreManager.saveLaps(Json.encodeToString(newLaps.toList())) 148 | } 149 | } 150 | 151 | private val isStarted = MutableLiveData(false) 152 | private val isRunning = MutableLiveData(false) 153 | private val elapsedMs = MutableLiveData(0L) 154 | private val elapsedSec = MutableLiveData(0L) 155 | private val laps = MutableLiveData>(LinkedList()) 156 | 157 | val isStartedI: LiveData get() = isStarted 158 | val isRunningI: LiveData get() = isRunning 159 | val elapsedMsI: LiveData get() = elapsedMs 160 | val elapsedSecI: LiveData get() = elapsedSec 161 | val lapsI: LiveData> get() = laps 162 | 163 | val previousLapDelta = MutableLiveData(1L) 164 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 17 | 24 | 31 | 38 | 45 | 52 | 59 | 66 | 74 | 81 | 88 | 95 | 102 | 109 | 116 | 124 | 132 | 140 | 147 | 155 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/ui/DisplayTime.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.ui 2 | 3 | import androidx.compose.animation.core.RepeatMode 4 | import androidx.compose.animation.core.animateFloat 5 | import androidx.compose.animation.core.infiniteRepeatable 6 | import androidx.compose.animation.core.rememberInfiniteTransition 7 | import androidx.compose.animation.core.tween 8 | import androidx.compose.foundation.Canvas 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.Row 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.heightIn 13 | import androidx.compose.foundation.layout.offset 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.foundation.layout.size 16 | import androidx.compose.foundation.layout.wrapContentSize 17 | import androidx.compose.material3.CircularProgressIndicator 18 | import androidx.compose.material3.MaterialTheme 19 | import androidx.compose.material3.Text 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.runtime.CompositionLocalProvider 22 | import androidx.compose.runtime.getValue 23 | import androidx.compose.ui.Alignment 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.draw.alpha 26 | import androidx.compose.ui.geometry.Offset 27 | import androidx.compose.ui.graphics.Color 28 | import androidx.compose.ui.graphics.StrokeCap 29 | import androidx.compose.ui.platform.LocalDensity 30 | import androidx.compose.ui.text.TextStyle 31 | import androidx.compose.ui.text.font.FontFamily 32 | import androidx.compose.ui.text.font.FontWeight 33 | import androidx.compose.ui.tooling.preview.Preview 34 | import androidx.compose.ui.unit.Density 35 | import androidx.compose.ui.unit.Dp 36 | import androidx.compose.ui.unit.dp 37 | import androidx.compose.ui.unit.sp 38 | import com.justdeax.composeStopwatch.ui.theme.DarkColorScheme 39 | import com.justdeax.composeStopwatch.util.Lap 40 | import com.justdeax.composeStopwatch.util.cutToMs 41 | import com.justdeax.composeStopwatch.util.formatSeconds 42 | import com.justdeax.composeStopwatch.util.formatSecondsWithHours 43 | import com.justdeax.composeStopwatch.util.getHours 44 | import kotlin.math.cos 45 | import kotlin.math.sin 46 | 47 | @Composable 48 | fun DisplayTime( 49 | modifier: Modifier, 50 | isPortrait: Boolean, 51 | isPausing: Boolean, 52 | seconds: Long, 53 | milliseconds: Long, 54 | laps: List, 55 | previousLapDelta: Long 56 | ) { 57 | val firstLapDelta = if (laps.isNotEmpty()) laps.last().elapsedTime else 1 58 | val lastLapDelta = if (laps.isNotEmpty()) milliseconds - laps.first().elapsedTime else 0 59 | val textStyle = TextStyle( 60 | fontFamily = FontFamily.Monospace, 61 | fontWeight = FontWeight.Bold 62 | ) 63 | 64 | Box( 65 | modifier = modifier, 66 | contentAlignment = Alignment.Center 67 | ) { 68 | if (isPortrait) { 69 | StopwatchCircularProgress( 70 | progress = lastLapDelta / firstLapDelta.toFloat(), 71 | modifier = Modifier.size(300.dp), 72 | markerPosition = previousLapDelta / firstLapDelta.toFloat(), 73 | strokeWidth = 8.dp, 74 | markerColor = MaterialTheme.colorScheme.primary 75 | ) 76 | if (seconds >= 3600L) 77 | Text( 78 | text = seconds.getHours() + "h", 79 | fontSize = 36.sp, 80 | fontFamily = FontFamily.Monospace, 81 | fontWeight = FontWeight.Medium, 82 | modifier = Modifier 83 | .padding(top = 48.dp) 84 | .align(Alignment.TopCenter) 85 | ) 86 | TimeRow(isPausing) { 87 | Text( 88 | text = seconds.formatSeconds(), 89 | style = textStyle, 90 | fontSize = 60.sp 91 | ) 92 | Text( 93 | text = milliseconds.cutToMs(), 94 | style = textStyle, 95 | fontSize = 40.sp, 96 | modifier = Modifier.offset(y = 30.dp) 97 | ) 98 | } 99 | } else { 100 | TimeRow(isPausing) { 101 | Text( 102 | text = seconds.formatSecondsWithHours(), 103 | style = textStyle, 104 | fontSize = 90.sp, 105 | ) 106 | Text( 107 | text = milliseconds.cutToMs(), 108 | style = textStyle, 109 | fontSize = 60.sp, 110 | modifier = Modifier.offset(y = 45.dp) 111 | ) 112 | } 113 | } 114 | } 115 | } 116 | 117 | @Composable 118 | fun StopwatchCircularProgress( 119 | progress: Float, 120 | modifier: Modifier, 121 | markerPosition: Float, 122 | strokeWidth: Dp = 8.dp, 123 | markerColor: Color 124 | ) { 125 | Box( 126 | contentAlignment = Alignment.Center, 127 | modifier = Modifier.wrapContentSize() 128 | ) { 129 | CircularProgressIndicator( 130 | progress = { progress }, 131 | modifier = modifier, 132 | color = MaterialTheme.colorScheme.primary, 133 | strokeWidth = strokeWidth, 134 | trackColor = MaterialTheme.colorScheme.surfaceContainer 135 | ) 136 | 137 | if (markerPosition != 1f) 138 | Canvas(modifier) { 139 | val canvasSize = size.width 140 | val radius = canvasSize / 2 - strokeWidth.toPx() / 2 141 | val angleInRadians = (360 * markerPosition - 90) * (Math.PI / 180) 142 | 143 | val xStart = 144 | (size.width / 2) + (radius - strokeWidth.toPx()) * cos(angleInRadians).toFloat() 145 | val yStart = 146 | (size.height / 2) + (radius - strokeWidth.toPx()) * sin(angleInRadians).toFloat() 147 | val xEnd = 148 | (size.width / 2) + (radius + strokeWidth.toPx()) * cos(angleInRadians).toFloat() 149 | val yEnd = 150 | (size.height / 2) + (radius + strokeWidth.toPx()) * sin(angleInRadians).toFloat() 151 | 152 | drawLine( 153 | color = markerColor, 154 | start = Offset(xStart, yStart), 155 | end = Offset(xEnd, yEnd), 156 | strokeWidth = strokeWidth.toPx() / 2, 157 | cap = StrokeCap.Round 158 | ) 159 | } 160 | } 161 | } 162 | 163 | @Composable 164 | fun TimeRow(isPausing: Boolean, content: @Composable () -> Unit) { 165 | val infiniteTransition = rememberInfiniteTransition("") 166 | val blinkAnimation by infiniteTransition.animateFloat( 167 | initialValue = 1f, 168 | targetValue = 0.1f, 169 | animationSpec = infiniteRepeatable( 170 | animation = tween(500), 171 | repeatMode = RepeatMode.Reverse 172 | ), label = "" 173 | ) 174 | val newDensity = Density(LocalDensity.current.density, fontScale = 1f) 175 | 176 | Row( 177 | modifier = Modifier 178 | .alpha(if (isPausing) blinkAnimation else 1f) 179 | .wrapContentSize() 180 | ) { 181 | CompositionLocalProvider( 182 | LocalDensity provides newDensity, 183 | content 184 | ) 185 | } 186 | } 187 | 188 | @Preview(showBackground = true) 189 | @Composable 190 | fun DisplayTimePreview() { 191 | MaterialTheme(colorScheme = DarkColorScheme) { 192 | DisplayTime( 193 | Modifier 194 | .fillMaxWidth() 195 | .padding(10.dp) 196 | .heightIn(min = 100.dp), 197 | isPortrait = true, 198 | isPausing = false, 199 | seconds = 3700L, 200 | milliseconds = 102000L, 201 | laps = listOf(), 202 | previousLapDelta = 1000L 203 | ) 204 | } 205 | } -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/stopwatch/StopwatchService.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.stopwatch 2 | 3 | import android.app.Notification 4 | import android.app.NotificationChannel 5 | import android.app.NotificationManager 6 | import android.app.PendingIntent 7 | import android.content.Intent 8 | import android.os.Build 9 | import androidx.core.app.NotificationCompat 10 | import androidx.lifecycle.LifecycleService 11 | import androidx.lifecycle.LiveData 12 | import androidx.lifecycle.MutableLiveData 13 | import androidx.lifecycle.lifecycleScope 14 | import com.justdeax.composeStopwatch.AppActivity 15 | import com.justdeax.composeStopwatch.R 16 | import com.justdeax.composeStopwatch.util.Lap 17 | import com.justdeax.composeStopwatch.util.StopwatchAction 18 | import com.justdeax.composeStopwatch.util.fullFormatSeconds 19 | import com.justdeax.composeStopwatch.util.toFormatString 20 | import kotlinx.coroutines.Dispatchers 21 | import kotlinx.coroutines.cancelChildren 22 | import kotlinx.coroutines.delay 23 | import kotlinx.coroutines.launch 24 | import java.util.LinkedList 25 | 26 | class StopwatchService : LifecycleService() { 27 | private lateinit var notificationManager: NotificationManager 28 | private lateinit var pendingIntent: PendingIntent 29 | private lateinit var intentStartResume: PendingIntent 30 | private lateinit var intentPause: PendingIntent 31 | private lateinit var intentStop: PendingIntent 32 | private lateinit var intentAddLap: PendingIntent 33 | private var elapsedMsBeforePause = 0L 34 | private var startTime = 0L 35 | private val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) 36 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE 37 | else 38 | PendingIntent.FLAG_UPDATE_CURRENT 39 | 40 | private fun setupNotification() { 41 | notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager 42 | val notificationChannel = NotificationChannel( 43 | NOTIFICATION_CHANNEL_ID, 44 | getString(R.string.stopwatch_channel), 45 | NotificationManager.IMPORTANCE_LOW 46 | ) 47 | notificationManager.createNotificationChannel(notificationChannel) 48 | 49 | elapsedSec.observe(this) { elapsedSeconds -> 50 | if (isRunning.value!!) 51 | notificationManager.notify( 52 | NOTIFICATION_ID, getNotification(elapsedSeconds.fullFormatSeconds()) 53 | ) 54 | } 55 | 56 | pendingIntent = PendingIntent.getActivity( 57 | this, 1, Intent(this, AppActivity::class.java), flag 58 | ) 59 | intentStartResume = createPendingIntent(2, StopwatchAction.START_RESUME) 60 | intentPause = createPendingIntent(3, StopwatchAction.PAUSE) 61 | intentStop = createPendingIntent(4, StopwatchAction.RESET) 62 | intentAddLap = createPendingIntent(5, StopwatchAction.ADD_LAP) 63 | } 64 | 65 | private fun createPendingIntent(code: Int, action: StopwatchAction) = PendingIntent.getService( 66 | this, 67 | code, 68 | Intent(this, StopwatchService::class.java).also { 69 | it.action = action.name 70 | }, 71 | flag 72 | ) 73 | 74 | private fun getNotification(time: String): Notification { 75 | val lapItem = if (laps.value!!.isEmpty()) "" else { 76 | val lastLap = laps.value!!.first() 77 | val elapsedTime = lastLap.elapsedTime.toFormatString() 78 | val lastLapText = getString(R.string.last_lap) 79 | "$lastLapText: ${lastLap.index} $elapsedTime | ${lastLap.deltaLap}" 80 | } 81 | 82 | if (isRunning.value!!) 83 | return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) 84 | .setOngoing(true) 85 | .setAutoCancel(false) 86 | .setSmallIcon(R.drawable.ic_launcher_foreground) 87 | .setContentTitle(time) 88 | .setContentText(lapItem) 89 | .setContentIntent(pendingIntent) 90 | .addAction( 91 | R.drawable.round_pause_24, 92 | getString(R.string.pause), 93 | intentPause 94 | ) 95 | .addAction( 96 | R.drawable.round_add_circle_24, 97 | getString(R.string.add_lap), 98 | intentAddLap 99 | ) 100 | .build() 101 | else 102 | return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) 103 | .setOngoing(true) 104 | .setAutoCancel(false) 105 | .setSmallIcon(R.drawable.ic_launcher_foreground) 106 | .setContentTitle(time + " " + getString(R.string.stopwatch_paused)) 107 | .setContentText(lapItem) 108 | .setContentIntent(pendingIntent) 109 | .addAction( 110 | R.drawable.round_play_arrow_24, 111 | getString(R.string.resume), 112 | intentStartResume 113 | ) 114 | .addAction( 115 | R.drawable.round_stop_24, 116 | getString(R.string.stop), 117 | intentStop 118 | ) 119 | .build() 120 | } 121 | 122 | override fun onCreate() { 123 | super.onCreate() 124 | setupNotification() 125 | } 126 | 127 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 128 | intent?.action?.let { action -> 129 | when (action) { 130 | StopwatchAction.START_RESUME.name -> startResume() 131 | StopwatchAction.PAUSE.name -> pause() 132 | StopwatchAction.RESET.name -> reset() 133 | StopwatchAction.HARD_RESET.name -> hardReset() 134 | StopwatchAction.ADD_LAP.name -> addLap() 135 | } 136 | } 137 | return super.onStartCommand(intent, flags, startId) 138 | } 139 | 140 | private fun startResume() { 141 | if (isStarted.value == true && isRunning.value == true) return 142 | isStarted.value = true 143 | isRunning.value = true 144 | lifecycleScope.launch(Dispatchers.Main) { 145 | if (startTime == 0L) startTime = System.currentTimeMillis() 146 | while (isRunning.value!!) { 147 | elapsedMs.postValue((System.currentTimeMillis() - startTime) + elapsedMsBeforePause) 148 | val seconds = elapsedMs.value!! / 1000 149 | if (elapsedSec.value != seconds) elapsedSec.postValue(seconds) 150 | delay(10) 151 | } 152 | } 153 | startForeground( 154 | NOTIFICATION_ID, getNotification((elapsedMsBeforePause / 1000).fullFormatSeconds()) 155 | ) 156 | } 157 | 158 | private fun pause() { 159 | isRunning.value = false 160 | elapsedMsBeforePause = elapsedMs.value!! 161 | startTime = 0L 162 | notificationManager.notify( 163 | NOTIFICATION_ID, getNotification(elapsedMs.value!!.toFormatString()) 164 | ) 165 | } 166 | 167 | private fun reset() { 168 | isStarted.value = false 169 | isRunning.value = false 170 | elapsedMs.value = 0L 171 | elapsedSec.value = 0L 172 | elapsedMsBeforePause = 0L 173 | laps.value!!.clear() 174 | previousLapDelta.value = 1L 175 | lifecycleScope.coroutineContext.cancelChildren() 176 | stopForeground(STOP_FOREGROUND_REMOVE) 177 | stopSelf() 178 | } 179 | 180 | private fun hardReset() { 181 | pause() 182 | lifecycleScope.launch { 183 | delay(10) 184 | reset() 185 | } 186 | } 187 | 188 | private fun addLap() { 189 | lifecycleScope.launch(Dispatchers.Main) { 190 | val deltaLap = if (laps.value!!.isEmpty()) 191 | elapsedMs.value!! 192 | else 193 | elapsedMs.value!! - laps.value!!.first().elapsedTime 194 | val deltaLapString = "+ ${deltaLap.toFormatString()}" 195 | laps.value?.addFirst(Lap(laps.value!!.size + 1, elapsedMs.value!!, deltaLapString)) 196 | previousLapDelta.value = deltaLap 197 | } 198 | notificationManager.notify( 199 | NOTIFICATION_ID, getNotification(elapsedMs.value!!.toFormatString()) 200 | ) 201 | } 202 | 203 | companion object { 204 | private const val NOTIFICATION_ID = 1448 205 | private const val NOTIFICATION_CHANNEL_ID = "stopwatch_service_channel" 206 | 207 | private val isStarted = MutableLiveData(false) 208 | private val isRunning = MutableLiveData(false) 209 | private val elapsedMs = MutableLiveData(0L) 210 | private val elapsedSec = MutableLiveData(0L) 211 | private val laps = MutableLiveData>(LinkedList()) 212 | 213 | val isStartedI: LiveData get() = isStarted 214 | val isRunningI: LiveData get() = isRunning 215 | val elapsedMsI: LiveData get() = elapsedMs 216 | val elapsedSecI: LiveData get() = elapsedSec 217 | val lapsI: LiveData> get() = laps 218 | 219 | val previousLapDelta = MutableLiveData(1L) 220 | } 221 | } -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/ui/DisplayActions.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch.ui 2 | 3 | import android.os.Build 4 | import androidx.compose.animation.core.tween 5 | import androidx.compose.animation.fadeIn 6 | import androidx.compose.animation.fadeOut 7 | import androidx.compose.animation.slideInHorizontally 8 | import androidx.compose.animation.slideInVertically 9 | import androidx.compose.animation.slideOutHorizontally 10 | import androidx.compose.animation.slideOutVertically 11 | import androidx.compose.foundation.clickable 12 | import androidx.compose.foundation.layout.Arrangement 13 | import androidx.compose.foundation.layout.Column 14 | import androidx.compose.foundation.layout.Row 15 | import androidx.compose.foundation.layout.fillMaxWidth 16 | import androidx.compose.foundation.layout.padding 17 | import androidx.compose.foundation.layout.wrapContentHeight 18 | import androidx.compose.material3.MaterialTheme 19 | import androidx.compose.material3.Text 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.runtime.LaunchedEffect 22 | import androidx.compose.runtime.getValue 23 | import androidx.compose.runtime.mutableStateOf 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.runtime.setValue 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.platform.LocalContext 28 | import androidx.compose.ui.res.painterResource 29 | import androidx.compose.ui.tooling.preview.Preview 30 | import androidx.compose.ui.unit.dp 31 | import androidx.compose.ui.unit.sp 32 | import com.justdeax.composeStopwatch.R 33 | import com.justdeax.composeStopwatch.ui.dialog.OkayDialog 34 | import com.justdeax.composeStopwatch.ui.dialog.RadioDialog 35 | import com.justdeax.composeStopwatch.ui.dialog.SimpleDialog 36 | import com.justdeax.composeStopwatch.ui.theme.DarkColorScheme 37 | import kotlinx.coroutines.delay 38 | 39 | @Composable 40 | fun DisplayActions( 41 | modifier: Modifier, 42 | isPortrait: Boolean, 43 | isStarted: Boolean, 44 | show: Boolean, 45 | tapOnClock: Int, 46 | changeTapOnClock: (Int) -> Unit, 47 | notificationEnabled: Boolean, 48 | changeNotificationEnabled: () -> Unit, 49 | hardReset: () -> Unit, 50 | theme: Int, 51 | changeTheme: (Int) -> Unit, 52 | lockAwakeEnabled: Boolean, 53 | changeLockAwakeEnabled: () -> Unit, 54 | vibrationEnabled: Boolean, 55 | changeVibrationEnabled: () -> Unit, 56 | autoStartEnabled: Boolean, 57 | changeAutoStartEnabled: () -> Unit 58 | ) { 59 | val context = LocalContext.current 60 | val settingsDraw = painterResource(R.drawable.round_settings_24) 61 | val turnOffNotifDraw = painterResource(R.drawable.round_notifications_24) 62 | val turnOnNotifDraw = painterResource(R.drawable.round_notifications_none_24) 63 | val themeDraw = painterResource(R.drawable.round_invert_colors_24) 64 | val unlockAwake = painterResource(R.drawable.round_lock_outline_24) 65 | val lockAwake = painterResource(R.drawable.round_lock_24) 66 | 67 | var showSettingsDialog by remember { mutableStateOf(false) } 68 | var showResetStopwatchDialog by remember { mutableStateOf(false) } 69 | var showThemeDialog by remember { mutableStateOf(false) } 70 | var showLockAwakeDialog by remember { mutableStateOf(false) } 71 | 72 | @Composable 73 | fun actionDialogs(modifier: Modifier) { 74 | OutlineIconButton( 75 | modifier = modifier, 76 | onClick = { showSettingsDialog = true }, 77 | painter = settingsDraw, 78 | contentDesc = context.getString(R.string.stopwatch_settings) 79 | ) 80 | OutlineIconButton( 81 | modifier = modifier, 82 | onClick = { showResetStopwatchDialog = true }, 83 | painter = if (notificationEnabled) turnOffNotifDraw else turnOnNotifDraw, 84 | contentDesc = context.getString(R.string.turn_off_notif) 85 | ) 86 | OutlineIconButton( 87 | modifier = modifier, 88 | onClick = { showThemeDialog = true }, 89 | painter = themeDraw, 90 | contentDesc = context.getString(R.string.theme) 91 | ) 92 | OutlineIconButton( 93 | modifier = modifier, 94 | onClick = { showLockAwakeDialog = true }, 95 | painter = if (lockAwakeEnabled) lockAwake else unlockAwake, 96 | contentDesc = context.getString(R.string.lock_awake) 97 | ) 98 | } 99 | 100 | if (showSettingsDialog) { 101 | var showTapOnClockDialog by remember { mutableStateOf(false) } 102 | OkayDialog( 103 | title = context.getString(R.string.stopwatch_settings), 104 | content = { 105 | SettingsRow( 106 | context.getString(R.string.change_tap_on_clock), 107 | tapOnClock.toString() 108 | ) { 109 | showTapOnClockDialog = true 110 | } 111 | SettingsRow( 112 | context.getString(R.string.auto_start_sw), 113 | if (autoStartEnabled) "ON" else "OFF" 114 | ) { 115 | changeAutoStartEnabled() 116 | } 117 | SettingsRow( 118 | context.getString(R.string.turn_on_vibration), 119 | if (vibrationEnabled) "ON" else "OFF" 120 | ) { 121 | changeVibrationEnabled() 122 | } 123 | }, 124 | isPortrait = isPortrait, 125 | confirmText = context.getString(R.string.ok), 126 | onConfirm = { showSettingsDialog = false } 127 | ) 128 | if (showTapOnClockDialog) 129 | RadioDialog( 130 | title = context.getString(R.string.change_tap_on_clock), 131 | desc = context.getString(R.string.change_tap_on_clock_desc), 132 | isPortrait = isPortrait, 133 | defaultIndex = tapOnClock, 134 | options = context.resources.getStringArray(R.array.tap_on_clock), 135 | setSelectedIndex = { newState -> changeTapOnClock(newState) }, 136 | onDismiss = { showTapOnClockDialog = false }, 137 | confirmText = context.getString(R.string.apply), 138 | onConfirm = { showTapOnClockDialog = false } 139 | ) 140 | } 141 | if (showResetStopwatchDialog) { 142 | if (isStarted) 143 | SimpleDialog( 144 | title = context.getString(R.string.reset_stopwatch), 145 | desc = if (notificationEnabled) 146 | context.getString(R.string.reset_stopwatch_desc_disable) 147 | else 148 | context.getString(R.string.reset_stopwatch_desc_enable), 149 | isPortrait = isPortrait, 150 | confirmText = context.getString(R.string.ok), 151 | onConfirm = { 152 | hardReset() 153 | changeNotificationEnabled() 154 | showResetStopwatchDialog = false 155 | }, 156 | dismissText = context.getString(R.string.cancel), 157 | onDismiss = { showResetStopwatchDialog = false } 158 | ) 159 | else 160 | changeNotificationEnabled() 161 | } 162 | if (showThemeDialog) { 163 | RadioDialog( 164 | title = context.getString(R.string.change_theme), 165 | isPortrait = isPortrait, 166 | desc = context.getString(R.string.change_theme_desc), 167 | defaultIndex = theme, 168 | options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) 169 | context.resources.getStringArray(R.array.theme12) 170 | else 171 | context.resources.getStringArray(R.array.theme), 172 | setSelectedIndex = { newState -> changeTheme(newState) }, 173 | onDismiss = { showThemeDialog = false }, 174 | confirmText = context.getString(R.string.apply), 175 | onConfirm = { showThemeDialog = false } 176 | ) 177 | } 178 | if (showLockAwakeDialog) { 179 | LaunchedEffect(Unit) { 180 | changeLockAwakeEnabled() 181 | delay(1500) 182 | showLockAwakeDialog = false 183 | } 184 | 185 | OkayDialog( 186 | title = context.getString(R.string.lock_awake_mode), 187 | content = { 188 | Text( 189 | text = if (lockAwakeEnabled) 190 | context.getString(R.string.lock_awake_mode_desc_enable) 191 | else 192 | context.getString(R.string.lock_awake_mode_desc_disable), 193 | style = MaterialTheme.typography.titleMedium 194 | ) 195 | }, 196 | isPortrait = isPortrait, 197 | confirmText = context.getString(R.string.ok), 198 | onConfirm = { showLockAwakeDialog = false } 199 | ) 200 | } 201 | 202 | if (isPortrait) 203 | androidx.compose.animation.AnimatedVisibility( 204 | visible = show, 205 | enter = fadeIn(tween(500)) + slideInVertically(tween(500)) { 80 }, 206 | exit = fadeOut(tween(300)) + slideOutVertically(tween(300)) { 80 } 207 | ) { 208 | Row( 209 | modifier = modifier, 210 | horizontalArrangement = Arrangement.SpaceEvenly 211 | ) { 212 | actionDialogs(Modifier.weight(1f)) 213 | } 214 | } 215 | else 216 | androidx.compose.animation.AnimatedVisibility( 217 | visible = show, 218 | enter = fadeIn(tween(500)) + slideInHorizontally(tween(500)) { -80 }, 219 | exit = fadeOut(tween(300)) + slideOutHorizontally(tween(300)) { -80 } 220 | ) { 221 | Column( 222 | modifier = modifier, 223 | verticalArrangement = Arrangement.SpaceEvenly 224 | ) { 225 | actionDialogs(Modifier.weight(1f)) 226 | } 227 | } 228 | } 229 | 230 | @Composable 231 | fun SettingsRow(text: String, value: String, onClick: () -> Unit) { 232 | Row( 233 | Modifier 234 | .fillMaxWidth() 235 | .clickable(onClick = onClick) 236 | .padding(10.dp, 12.dp) 237 | ) { 238 | Text( 239 | modifier = Modifier.weight(1f), 240 | text = text, 241 | fontSize = 20.sp 242 | ) 243 | Text( 244 | text = value, 245 | fontSize = 21.sp, 246 | color = MaterialTheme.colorScheme.outline 247 | ) 248 | } 249 | } 250 | 251 | @Preview(showBackground = true) 252 | @Composable 253 | fun DisplayActionsPreview() { 254 | MaterialTheme(colorScheme = DarkColorScheme) { 255 | DisplayActions( 256 | Modifier 257 | .fillMaxWidth() 258 | .wrapContentHeight() 259 | .padding(8.dp, 8.dp, 8.dp, 14.dp), 260 | isPortrait = true, 261 | isStarted = true, 262 | show = true, 263 | tapOnClock = 0, 264 | changeTapOnClock = { }, 265 | notificationEnabled = false, 266 | changeNotificationEnabled = { }, 267 | hardReset = { }, 268 | theme = 0, 269 | changeTheme = { }, 270 | lockAwakeEnabled = false, 271 | changeLockAwakeEnabled = { }, 272 | vibrationEnabled = false, 273 | changeVibrationEnabled = { }, 274 | autoStartEnabled = false, 275 | changeAutoStartEnabled = { } 276 | ) 277 | } 278 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /app/src/main/java/com/justdeax/composeStopwatch/AppActivity.kt: -------------------------------------------------------------------------------- 1 | package com.justdeax.composeStopwatch 2 | 3 | import android.content.pm.PackageManager 4 | import android.content.res.Configuration 5 | import android.os.Build 6 | import android.os.Bundle 7 | import android.os.VibrationEffect 8 | import android.os.Vibrator 9 | import android.os.VibratorManager 10 | import android.view.WindowManager 11 | import androidx.activity.ComponentActivity 12 | import androidx.activity.compose.setContent 13 | import androidx.activity.enableEdgeToEdge 14 | import androidx.activity.viewModels 15 | import androidx.compose.foundation.clickable 16 | import androidx.compose.foundation.interaction.MutableInteractionSource 17 | import androidx.compose.foundation.isSystemInDarkTheme 18 | import androidx.compose.foundation.layout.Arrangement 19 | import androidx.compose.foundation.layout.Box 20 | import androidx.compose.foundation.layout.Column 21 | import androidx.compose.foundation.layout.Row 22 | import androidx.compose.foundation.layout.fillMaxHeight 23 | import androidx.compose.foundation.layout.fillMaxSize 24 | import androidx.compose.foundation.layout.fillMaxWidth 25 | import androidx.compose.foundation.layout.heightIn 26 | import androidx.compose.foundation.layout.padding 27 | import androidx.compose.foundation.layout.wrapContentHeight 28 | import androidx.compose.foundation.layout.wrapContentWidth 29 | import androidx.compose.material3.MaterialTheme 30 | import androidx.compose.material3.Scaffold 31 | import androidx.compose.material3.dynamicDarkColorScheme 32 | import androidx.compose.material3.dynamicLightColorScheme 33 | import androidx.compose.runtime.Composable 34 | import androidx.compose.runtime.LaunchedEffect 35 | import androidx.compose.runtime.getValue 36 | import androidx.compose.runtime.livedata.observeAsState 37 | import androidx.compose.runtime.mutableStateOf 38 | import androidx.compose.runtime.remember 39 | import androidx.compose.runtime.setValue 40 | import androidx.compose.ui.Alignment 41 | import androidx.compose.ui.Modifier 42 | import androidx.compose.ui.platform.LocalConfiguration 43 | import androidx.compose.ui.unit.dp 44 | import androidx.core.app.ActivityCompat 45 | import androidx.core.content.ContextCompat 46 | import com.justdeax.composeStopwatch.stopwatch.StopwatchService 47 | import com.justdeax.composeStopwatch.stopwatch.StopwatchViewModel 48 | import com.justdeax.composeStopwatch.stopwatch.StopwatchViewModelFactory 49 | import com.justdeax.composeStopwatch.ui.DisplayActions 50 | import com.justdeax.composeStopwatch.ui.DisplayAppName 51 | import com.justdeax.composeStopwatch.ui.DisplayButton 52 | import com.justdeax.composeStopwatch.ui.DisplayButtonInLandscape 53 | import com.justdeax.composeStopwatch.ui.DisplayLaps 54 | import com.justdeax.composeStopwatch.ui.DisplayTime 55 | import com.justdeax.composeStopwatch.ui.dialog.DisplayAutoStartDialog 56 | import com.justdeax.composeStopwatch.ui.theme.DarkColorScheme 57 | import com.justdeax.composeStopwatch.ui.theme.ExtraDarkColorScheme 58 | import com.justdeax.composeStopwatch.ui.theme.LightColorScheme 59 | import com.justdeax.composeStopwatch.ui.theme.Typography 60 | import com.justdeax.composeStopwatch.util.DataStoreManager 61 | import com.justdeax.composeStopwatch.util.Lap 62 | import com.justdeax.composeStopwatch.util.StopwatchAction 63 | import com.justdeax.composeStopwatch.util.commandService 64 | import java.util.LinkedList 65 | 66 | class AppActivity : ComponentActivity() { 67 | private val viewModel: StopwatchViewModel by viewModels { 68 | StopwatchViewModelFactory(DataStoreManager(this)) 69 | } 70 | 71 | override fun onCreate(savedInstanceState: Bundle?) { 72 | super.onCreate(savedInstanceState) 73 | enableEdgeToEdge() 74 | requestNotificationPermission() 75 | setContent { AppScreen() } 76 | } 77 | 78 | private fun requestNotificationPermission() { 79 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) 80 | if (ContextCompat.checkSelfPermission( 81 | this, 82 | android.Manifest.permission.POST_NOTIFICATIONS 83 | ) != PackageManager.PERMISSION_GRANTED 84 | ) 85 | ActivityCompat.requestPermissions( 86 | this, 87 | arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 88 | 101 89 | ) 90 | } 91 | 92 | @Composable 93 | fun AppScreen() { 94 | val notificationEnabled: Boolean by viewModel.notificationEnabled.observeAsState(true) 95 | if (notificationEnabled) { 96 | val isStarted by StopwatchService.isStartedI.observeAsState(false) 97 | val isRunning by StopwatchService.isRunningI.observeAsState(false) 98 | val elapsedMs by StopwatchService.elapsedMsI.observeAsState(0L) 99 | val elapsedSec by StopwatchService.elapsedSecI.observeAsState(0L) 100 | val laps by StopwatchService.lapsI.observeAsState(LinkedList()) 101 | val previousLapDelta by StopwatchService.previousLapDelta.observeAsState(0L) 102 | StopwatchScreen( 103 | true, isStarted, isRunning, elapsedMs, elapsedSec, laps, previousLapDelta 104 | ) 105 | } else { 106 | LaunchedEffect(Unit) { viewModel.restoreStopwatch() } 107 | val isStarted by viewModel.isStartedI.observeAsState(false) 108 | val isRunning by viewModel.isRunningI.observeAsState(false) 109 | val elapsedMs by viewModel.elapsedMsI.observeAsState(0L) 110 | val elapsedSec by viewModel.elapsedSecI.observeAsState(0L) 111 | val laps by viewModel.lapsI.observeAsState(LinkedList()) 112 | val previousLapDelta by viewModel.previousLapDelta.observeAsState(0L) 113 | StopwatchScreen( 114 | false, isStarted, isRunning, elapsedMs, elapsedSec, laps, previousLapDelta 115 | ) 116 | } 117 | } 118 | 119 | @Composable 120 | fun StopwatchScreen( 121 | notificationEnabled: Boolean, 122 | isStarted: Boolean, 123 | isRunning: Boolean, 124 | elapsedMs: Long, 125 | elapsedSec: Long, 126 | laps: List, 127 | previousLapDelta: Long 128 | ) { 129 | var additionalActionsShow by remember { mutableStateOf(false) } 130 | var autoStartEnabledNow by remember { mutableStateOf(false) } 131 | 132 | val theme by viewModel.theme.observeAsState(0) 133 | val tapOnClock by viewModel.tapOnClock.observeAsState(0) 134 | val lockAwakeEnabled by viewModel.lockAwakeEnabled.observeAsState(false) 135 | val vibrationEnabled by viewModel.vibrationEnabled.observeAsState(false) 136 | val autoStartEnabled by viewModel.autoStartEnabled.observeAsState(false) 137 | 138 | val configuration = LocalConfiguration.current 139 | val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT 140 | val isDarkTheme = isSystemInDarkTheme() 141 | val colorScheme = remember(theme, isDarkTheme) { 142 | when (theme) { 143 | 1 -> LightColorScheme 144 | 2 -> DarkColorScheme 145 | 3 -> ExtraDarkColorScheme 146 | else -> { 147 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 148 | if (isDarkTheme) dynamicDarkColorScheme(this) 149 | else dynamicLightColorScheme(this) 150 | } else { 151 | if (isDarkTheme) DarkColorScheme else LightColorScheme 152 | } 153 | } 154 | } 155 | } 156 | 157 | val vibrator = remember { 158 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 159 | val vibratorManager = getSystemService(VibratorManager::class.java) 160 | vibratorManager?.defaultVibrator 161 | } else { 162 | @Suppress("DEPRECATION") 163 | getSystemService(VIBRATOR_SERVICE) as Vibrator 164 | } 165 | } 166 | val startResumeVibration = VibrationEffect.createOneShot(150, VibrationEffect.DEFAULT_AMPLITUDE) 167 | val pauseVibration = VibrationEffect.createOneShot(200, 80) 168 | val resetVibration = VibrationEffect.createOneShot(300, VibrationEffect.DEFAULT_AMPLITUDE) 169 | val addLapVibration = VibrationEffect.createWaveform(longArrayOf(0, 100, 50, 100), -1) 170 | 171 | LaunchedEffect(lockAwakeEnabled) { 172 | if (lockAwakeEnabled) 173 | window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 174 | else 175 | window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 176 | } 177 | LaunchedEffect(autoStartEnabled) { 178 | autoStartEnabledNow = autoStartEnabled 179 | } 180 | 181 | MaterialTheme(colorScheme = colorScheme, typography = Typography) { 182 | Scaffold(Modifier.fillMaxSize()) { innerPadding -> 183 | if (!isStarted && autoStartEnabledNow) 184 | DisplayAutoStartDialog( 185 | isPortrait = isPortrait, 186 | onDismiss = { autoStartEnabledNow = false }, 187 | startStopwatch = { 188 | notificationEnabled.startResume() 189 | if (vibrationEnabled && vibrator != null) { 190 | vibrator.cancel() 191 | vibrator.vibrate(startResumeVibration) 192 | } 193 | } 194 | ) 195 | 196 | if (isPortrait) { 197 | Column(Modifier.padding(innerPadding)) { 198 | Box( 199 | modifier = Modifier 200 | .fillMaxWidth() 201 | .weight(1f), 202 | contentAlignment = Alignment.TopStart 203 | ) { 204 | DisplayAppName( 205 | Modifier.padding(21.dp, 16.dp), 206 | !isStarted 207 | ) 208 | Column( 209 | modifier = Modifier.fillMaxSize(), 210 | verticalArrangement = Arrangement.Center 211 | ) { 212 | DisplayTime( 213 | Modifier 214 | .fillMaxWidth() 215 | .padding(10.dp) 216 | .heightIn(min = 100.dp) 217 | .clickable( 218 | indication = null, 219 | interactionSource = remember { MutableInteractionSource() } 220 | ) { 221 | clickOnClock(tapOnClock, isRunning, notificationEnabled) 222 | if (vibrationEnabled && vibrator != null) { 223 | vibrator.cancel() 224 | if (isRunning) when (tapOnClock) { 225 | 1 -> vibrator.vibrate(pauseVibration) 226 | 2 -> vibrator.vibrate(addLapVibration) 227 | 3 -> vibrator.vibrate(resetVibration) 228 | } else vibrator.vibrate(startResumeVibration) 229 | } 230 | }, 231 | true, 232 | isStarted && !isRunning, 233 | elapsedSec, 234 | elapsedMs, 235 | laps, 236 | previousLapDelta 237 | ) 238 | DisplayLaps( 239 | Modifier 240 | .padding(8.dp, 0.dp) 241 | .fillMaxWidth(), 242 | laps, 243 | elapsedMs 244 | ) 245 | } 246 | } 247 | DisplayActions( 248 | Modifier 249 | .fillMaxWidth() 250 | .wrapContentHeight() 251 | .padding(8.dp, 8.dp, 8.dp, 14.dp), 252 | true, 253 | isStarted, 254 | !isStarted || additionalActionsShow, 255 | tapOnClock, 256 | { newState -> viewModel.changeTapOnClock(newState) }, 257 | notificationEnabled, 258 | { viewModel.changeNotificationEnabled(!notificationEnabled) }, 259 | { viewModel.hardReset() }, 260 | theme, 261 | { newState -> viewModel.changeTheme(newState) }, 262 | lockAwakeEnabled, 263 | { viewModel.changeLockAwakeEnabled(!lockAwakeEnabled) }, 264 | vibrationEnabled, 265 | { viewModel.changeVibrationEnabled(!vibrationEnabled) }, 266 | autoStartEnabled, 267 | { viewModel.changeAutoStartEnabled(!autoStartEnabled) } 268 | ) 269 | DisplayButton( 270 | Modifier 271 | .fillMaxWidth() 272 | .padding(top = 20.dp, bottom = 50.dp), 273 | isStarted, 274 | isRunning, 275 | { additionalActionsShow = !additionalActionsShow }, 276 | { 277 | if (additionalActionsShow) additionalActionsShow = false 278 | notificationEnabled.reset() 279 | if (vibrationEnabled && vibrator != null) { 280 | vibrator.cancel() 281 | vibrator.vibrate(resetVibration) 282 | } 283 | }, 284 | { 285 | notificationEnabled.startResume() 286 | if (vibrationEnabled && vibrator != null) { 287 | vibrator.cancel() 288 | vibrator.vibrate(startResumeVibration) 289 | } 290 | }, 291 | { 292 | notificationEnabled.pause() 293 | if (vibrationEnabled && vibrator != null) { 294 | vibrator.cancel() 295 | vibrator.vibrate(pauseVibration) 296 | } 297 | }, 298 | { 299 | notificationEnabled.addLap() 300 | if (vibrationEnabled && vibrator != null) { 301 | vibrator.cancel() 302 | vibrator.vibrate(addLapVibration) 303 | } 304 | } 305 | ) 306 | } 307 | } else { 308 | Row(Modifier.padding(innerPadding)) { 309 | DisplayButtonInLandscape( 310 | Modifier 311 | .fillMaxHeight() 312 | .padding(start = 50.dp, end = 20.dp), 313 | isStarted, 314 | isRunning, 315 | { additionalActionsShow = !additionalActionsShow }, 316 | { 317 | if (additionalActionsShow) additionalActionsShow = false 318 | notificationEnabled.reset() 319 | if (vibrationEnabled && vibrator != null) { 320 | vibrator.cancel() 321 | vibrator.vibrate(resetVibration) 322 | } 323 | }, 324 | { 325 | notificationEnabled.startResume() 326 | if (vibrationEnabled && vibrator != null) { 327 | vibrator.cancel() 328 | vibrator.vibrate(startResumeVibration) 329 | } 330 | }, 331 | { 332 | notificationEnabled.pause() 333 | if (vibrationEnabled && vibrator != null) { 334 | vibrator.cancel() 335 | vibrator.vibrate(pauseVibration) 336 | } 337 | }, 338 | { 339 | notificationEnabled.addLap() 340 | if (vibrationEnabled && vibrator != null) { 341 | vibrator.cancel() 342 | vibrator.vibrate(addLapVibration) 343 | } 344 | } 345 | ) 346 | DisplayActions( 347 | Modifier 348 | .fillMaxHeight() 349 | .wrapContentWidth() 350 | .padding(14.dp, 8.dp, 8.dp, 8.dp), 351 | false, 352 | isStarted, 353 | !isStarted || additionalActionsShow, 354 | tapOnClock, 355 | { newState -> viewModel.changeTapOnClock(newState) }, 356 | notificationEnabled, 357 | { viewModel.changeNotificationEnabled(!notificationEnabled) }, 358 | { notificationEnabled.hardReset() }, 359 | theme, 360 | { newState -> viewModel.changeTheme(newState) }, 361 | lockAwakeEnabled, 362 | { viewModel.changeLockAwakeEnabled(!lockAwakeEnabled) }, 363 | vibrationEnabled, 364 | { viewModel.changeVibrationEnabled(!vibrationEnabled) }, 365 | autoStartEnabled, 366 | { viewModel.changeAutoStartEnabled(!autoStartEnabled) } 367 | ) 368 | Box( 369 | modifier = Modifier 370 | .fillMaxHeight() 371 | .weight(1f), 372 | contentAlignment = Alignment.TopEnd 373 | ) { 374 | DisplayAppName( 375 | Modifier.padding(21.dp, 16.dp), 376 | !isStarted 377 | ) 378 | Column( 379 | modifier = Modifier.fillMaxSize(), 380 | verticalArrangement = Arrangement.Center 381 | ) { 382 | DisplayTime( 383 | Modifier 384 | .fillMaxWidth() 385 | .padding(10.dp) 386 | .heightIn(min = 100.dp) 387 | .clickable( 388 | indication = null, 389 | interactionSource = remember { MutableInteractionSource() } 390 | ) { 391 | clickOnClock(tapOnClock, isRunning, notificationEnabled) 392 | if (vibrationEnabled && vibrator != null) { 393 | vibrator.cancel() 394 | if (isRunning) when (tapOnClock) { 395 | 1 -> vibrator.vibrate(pauseVibration) 396 | 2 -> vibrator.vibrate(addLapVibration) 397 | 3 -> vibrator.vibrate(resetVibration) 398 | } else vibrator.vibrate(startResumeVibration) 399 | } 400 | }, 401 | false, 402 | isStarted && !isRunning, 403 | elapsedSec, 404 | elapsedMs, 405 | laps, 406 | previousLapDelta 407 | ) 408 | DisplayLaps( 409 | Modifier 410 | .fillMaxWidth() 411 | .padding(32.dp, 0.dp), 412 | laps, 413 | elapsedMs 414 | ) 415 | } 416 | } 417 | } 418 | } 419 | } 420 | } 421 | } 422 | 423 | private fun clickOnClock(tapType: Int, isRunning: Boolean, notificationEnabled: Boolean) { 424 | if (isRunning) when (tapType) { 425 | 1 -> notificationEnabled.pause() 426 | 2 -> notificationEnabled.addLap() 427 | 3 -> notificationEnabled.hardReset() 428 | } else notificationEnabled.startResume() 429 | } 430 | 431 | private fun Boolean.startResume() { 432 | if (this) commandService(StopwatchAction.START_RESUME) 433 | else viewModel.startResume() 434 | } 435 | 436 | private fun Boolean.pause() { 437 | if (this) commandService(StopwatchAction.PAUSE) 438 | else viewModel.pause() 439 | } 440 | 441 | private fun Boolean.addLap() { 442 | if (this) commandService(StopwatchAction.ADD_LAP) 443 | else viewModel.addLap() 444 | } 445 | 446 | private fun Boolean.reset() { 447 | if (this) commandService(StopwatchAction.RESET) 448 | else viewModel.reset() 449 | } 450 | 451 | private fun Boolean.hardReset() { 452 | if (this) commandService(StopwatchAction.HARD_RESET) 453 | else viewModel.hardReset() 454 | } 455 | } --------------------------------------------------------------------------------
Android stopwatch in the Material You theme, designed for ease of use and best feature
20 | 21 | 22 | 23 | 24 | 25 | 26 |