Merge branch 'dev'
This commit is contained in:
2
.github/workflows/android.yml
vendored
2
.github/workflows/android.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
@@ -33,15 +33,16 @@ android {
|
||||
applicationId = "org.nsh07.pomodoro"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 5
|
||||
versionName = "1.1.0"
|
||||
versionCode = 6
|
||||
versionName = "1.2.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
)
|
||||
|
||||
4
app/proguard-rules.pro
vendored
4
app/proguard-rules.pro
vendored
@@ -18,4 +18,6 @@
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-dontobfuscate
|
||||
@@ -2,6 +2,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
|
||||
|
||||
@@ -26,6 +28,13 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".service.TimerService"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Required to keep the timer running in the background" />
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.nsh07.pomodoro
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import org.nsh07.pomodoro.data.AppContainer
|
||||
import org.nsh07.pomodoro.data.DefaultAppContainer
|
||||
|
||||
@@ -9,5 +11,13 @@ class TomatoApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = DefaultAppContainer(this)
|
||||
|
||||
val notificationChannel = NotificationChannel(
|
||||
"timer",
|
||||
"Timer progress",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
)
|
||||
|
||||
container.notificationManager.createNotificationChannel(notificationChannel)
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,26 @@
|
||||
|
||||
package org.nsh07.pomodoro.data
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.nsh07.pomodoro.R
|
||||
import org.nsh07.pomodoro.service.addTimerActions
|
||||
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState
|
||||
import org.nsh07.pomodoro.utils.millisecondsToStr
|
||||
|
||||
interface AppContainer {
|
||||
val appPreferenceRepository: AppPreferenceRepository
|
||||
val appStatRepository: AppStatRepository
|
||||
val appTimerRepository: AppTimerRepository
|
||||
val notificationManager: NotificationManagerCompat
|
||||
val notificationBuilder: NotificationCompat.Builder
|
||||
val timerState: MutableStateFlow<TimerState>
|
||||
val time: MutableStateFlow<Long>
|
||||
}
|
||||
|
||||
class DefaultAppContainer(context: Context) : AppContainer {
|
||||
@@ -27,4 +41,41 @@ class DefaultAppContainer(context: Context) : AppContainer {
|
||||
|
||||
override val appTimerRepository: AppTimerRepository by lazy { AppTimerRepository() }
|
||||
|
||||
override val notificationManager: NotificationManagerCompat by lazy {
|
||||
NotificationManagerCompat.from(context)
|
||||
}
|
||||
|
||||
override val notificationBuilder: NotificationCompat.Builder by lazy {
|
||||
NotificationCompat.Builder(context, "timer")
|
||||
.setSmallIcon(R.drawable.tomato_logo_notification)
|
||||
.setColor(Color.Red.toArgb())
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
context.packageManager.getLaunchIntentForPackage(context.packageName),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
.addTimerActions(context, R.drawable.play, "Start")
|
||||
.setShowWhen(true)
|
||||
.setSilent(true)
|
||||
.setOngoing(true)
|
||||
.setRequestPromotedOngoing(true)
|
||||
}
|
||||
|
||||
override val timerState: MutableStateFlow<TimerState> by lazy {
|
||||
MutableStateFlow(
|
||||
TimerState(
|
||||
totalTime = appTimerRepository.focusTime,
|
||||
timeStr = millisecondsToStr(appTimerRepository.focusTime),
|
||||
nextTimeStr = millisecondsToStr(appTimerRepository.shortBreakTime)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override val time: MutableStateFlow<Long> by lazy {
|
||||
MutableStateFlow(appTimerRepository.focusTime)
|
||||
}
|
||||
|
||||
}
|
||||
74
app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt
Normal file
74
app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Nishant Mishra
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.nsh07.pomodoro.service
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.app.NotificationCompat
|
||||
import org.nsh07.pomodoro.R
|
||||
|
||||
fun NotificationCompat.Builder.addTimerActions(
|
||||
context: Context,
|
||||
@DrawableRes playPauseIcon: Int,
|
||||
playPauseText: String
|
||||
): NotificationCompat.Builder = this
|
||||
.addAction(
|
||||
playPauseIcon,
|
||||
playPauseText,
|
||||
PendingIntent.getService(
|
||||
context,
|
||||
0,
|
||||
Intent(context, TimerService::class.java).also {
|
||||
it.action = TimerService.Actions.TOGGLE.toString()
|
||||
},
|
||||
FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.restart,
|
||||
"Reset",
|
||||
PendingIntent.getService(
|
||||
context,
|
||||
0,
|
||||
Intent(context, TimerService::class.java).also {
|
||||
it.action = TimerService.Actions.RESET.toString()
|
||||
},
|
||||
FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.skip_next,
|
||||
"Skip",
|
||||
PendingIntent.getService(
|
||||
context,
|
||||
0,
|
||||
Intent(context, TimerService::class.java).also {
|
||||
it.action = TimerService.Actions.SKIP.toString()
|
||||
},
|
||||
FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
|
||||
fun NotificationCompat.Builder.addStopAlarmAction(
|
||||
context: Context
|
||||
): NotificationCompat.Builder = this
|
||||
.addAction(
|
||||
R.drawable.alarm,
|
||||
"Stop alarm",
|
||||
PendingIntent.getService(
|
||||
context,
|
||||
0,
|
||||
Intent(context, TimerService::class.java).also {
|
||||
it.action = TimerService.Actions.STOP_ALARM.toString()
|
||||
},
|
||||
FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
383
app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt
Normal file
383
app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt
Normal file
@@ -0,0 +1,383 @@
|
||||
package org.nsh07.pomodoro.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.media.MediaPlayer
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.nsh07.pomodoro.R
|
||||
import org.nsh07.pomodoro.TomatoApplication
|
||||
import org.nsh07.pomodoro.data.AppContainer
|
||||
import org.nsh07.pomodoro.data.StatRepository
|
||||
import org.nsh07.pomodoro.data.TimerRepository
|
||||
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode
|
||||
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState
|
||||
import org.nsh07.pomodoro.utils.millisecondsToStr
|
||||
import kotlin.text.Typography.middleDot
|
||||
|
||||
class TimerService : Service() {
|
||||
private lateinit var appContainer: AppContainer
|
||||
|
||||
private lateinit var timerRepository: TimerRepository
|
||||
private lateinit var statRepository: StatRepository
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
private lateinit var notificationBuilder: NotificationCompat.Builder
|
||||
private lateinit var _timerState: MutableStateFlow<TimerState>
|
||||
private lateinit var _time: MutableStateFlow<Long>
|
||||
|
||||
val timeStateFlow by lazy {
|
||||
_time.asStateFlow()
|
||||
}
|
||||
|
||||
var time: Long
|
||||
get() = timeStateFlow.value
|
||||
set(value) = _time.update { value }
|
||||
|
||||
lateinit var timerState: StateFlow<TimerState>
|
||||
|
||||
private var cycles = 0
|
||||
private var startTime = 0L
|
||||
private var pauseTime = 0L
|
||||
private var pauseDuration = 0L
|
||||
|
||||
private var job = SupervisorJob()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + job)
|
||||
private val skipScope = CoroutineScope(Dispatchers.IO + job)
|
||||
|
||||
private lateinit var alarm: MediaPlayer
|
||||
|
||||
private var cs: ColorScheme = lightColorScheme()
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
appContainer = (application as TomatoApplication).container
|
||||
timerRepository = appContainer.appTimerRepository
|
||||
statRepository = appContainer.appStatRepository
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
notificationBuilder = appContainer.notificationBuilder
|
||||
_timerState = appContainer.timerState
|
||||
_time = appContainer.time
|
||||
|
||||
timerState = _timerState.asStateFlow()
|
||||
|
||||
alarm = MediaPlayer.create(
|
||||
this,
|
||||
Settings.System.DEFAULT_ALARM_ALERT_URI ?: Settings.System.DEFAULT_RINGTONE_URI
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
Actions.TOGGLE.toString() -> {
|
||||
startForegroundService()
|
||||
toggleTimer()
|
||||
}
|
||||
|
||||
Actions.RESET.toString() -> {
|
||||
if (timerState.value.timerRunning) toggleTimer()
|
||||
resetTimer()
|
||||
stopForegroundService()
|
||||
}
|
||||
|
||||
Actions.SKIP.toString() -> skipTimer(true)
|
||||
|
||||
Actions.STOP_ALARM.toString() -> stopAlarm()
|
||||
}
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
private fun toggleTimer() {
|
||||
if (timerState.value.timerRunning) {
|
||||
notificationBuilder
|
||||
.clearActions()
|
||||
.addTimerActions(
|
||||
this,
|
||||
R.drawable.play,
|
||||
"Start"
|
||||
)
|
||||
showTimerNotification(time.toInt(), paused = true)
|
||||
_timerState.update { currentState ->
|
||||
currentState.copy(timerRunning = false)
|
||||
}
|
||||
pauseTime = SystemClock.elapsedRealtime()
|
||||
} else {
|
||||
notificationBuilder
|
||||
.clearActions()
|
||||
.addTimerActions(
|
||||
this,
|
||||
R.drawable.pause,
|
||||
"Stop"
|
||||
)
|
||||
_timerState.update { it.copy(timerRunning = true) }
|
||||
if (pauseTime != 0L) pauseDuration += SystemClock.elapsedRealtime() - pauseTime
|
||||
|
||||
var iterations = -1
|
||||
|
||||
scope.launch {
|
||||
while (true) {
|
||||
if (!timerState.value.timerRunning) break
|
||||
if (startTime == 0L) startTime = SystemClock.elapsedRealtime()
|
||||
|
||||
time = when (timerState.value.timerMode) {
|
||||
TimerMode.FOCUS -> timerRepository.focusTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
|
||||
|
||||
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
|
||||
|
||||
else -> timerRepository.longBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
|
||||
}
|
||||
|
||||
iterations = (iterations + 1) % 10
|
||||
|
||||
if (iterations == 0) showTimerNotification(time.toInt())
|
||||
|
||||
if (time < 0) {
|
||||
skipTimer()
|
||||
_timerState.update { currentState ->
|
||||
currentState.copy(timerRunning = false)
|
||||
}
|
||||
break
|
||||
} else {
|
||||
_timerState.update { currentState ->
|
||||
currentState.copy(
|
||||
timeStr = millisecondsToStr(time)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission") // We check for the permission when pressing the Play button in the UI
|
||||
fun showTimerNotification(
|
||||
remainingTime: Int, paused: Boolean = false, complete: Boolean = false
|
||||
) {
|
||||
if (complete) notificationBuilder.clearActions().addStopAlarmAction(this)
|
||||
|
||||
val totalTime = when (timerState.value.timerMode) {
|
||||
TimerMode.FOCUS -> timerRepository.focusTime.toInt()
|
||||
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
|
||||
else -> timerRepository.longBreakTime.toInt()
|
||||
}
|
||||
|
||||
val currentTimer = when (timerState.value.timerMode) {
|
||||
TimerMode.FOCUS -> "Focus"
|
||||
TimerMode.SHORT_BREAK -> "Short break"
|
||||
else -> "Long break"
|
||||
}
|
||||
|
||||
val nextTimer = when (timerState.value.nextTimerMode) {
|
||||
TimerMode.FOCUS -> "Focus"
|
||||
TimerMode.SHORT_BREAK -> "Short break"
|
||||
else -> "Long break"
|
||||
}
|
||||
|
||||
val remainingTimeString = if ((remainingTime.toFloat() / 60000f) < 1.0f) "< 1"
|
||||
else (remainingTime.toFloat() / 60000f).toInt()
|
||||
|
||||
notificationManager.notify(
|
||||
1,
|
||||
notificationBuilder
|
||||
.setContentTitle(
|
||||
if (!complete) {
|
||||
"$currentTimer $middleDot $remainingTimeString min remaining" + if (paused) " $middleDot Paused" else ""
|
||||
} else "$currentTimer $middleDot Completed"
|
||||
)
|
||||
.setContentText("Up next: $nextTimer (${timerState.value.nextTimeStr})")
|
||||
.setStyle(
|
||||
NotificationCompat.ProgressStyle().also {
|
||||
// Add all the Focus, Short break and long break intervals in order
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
|
||||
// Android 16 and later supports live updates
|
||||
// Set progress bar sections if on Baklava or later
|
||||
for (i in 0..<timerRepository.sessionLength * 2) {
|
||||
if (i % 2 == 0) it.addProgressSegment(
|
||||
NotificationCompat.ProgressStyle.Segment(
|
||||
timerRepository.focusTime.toInt()
|
||||
).setColor(cs.primary.toArgb())
|
||||
)
|
||||
else if (i != (timerRepository.sessionLength * 2 - 1)) it.addProgressSegment(
|
||||
NotificationCompat.ProgressStyle.Segment(
|
||||
timerRepository.shortBreakTime.toInt()
|
||||
).setColor(cs.tertiary.toArgb())
|
||||
)
|
||||
else it.addProgressSegment(
|
||||
NotificationCompat.ProgressStyle.Segment(
|
||||
timerRepository.longBreakTime.toInt()
|
||||
).setColor(cs.tertiary.toArgb())
|
||||
)
|
||||
}
|
||||
} else {
|
||||
it.addProgressSegment(
|
||||
NotificationCompat.ProgressStyle.Segment(
|
||||
when (timerState.value.timerMode) {
|
||||
TimerMode.FOCUS -> timerRepository.focusTime.toInt()
|
||||
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
|
||||
else -> timerRepository.longBreakTime.toInt()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.setProgress( // Set the current progress by filling the previous intervals and part of the current interval
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
|
||||
(totalTime - remainingTime) + ((cycles + 1) / 2) * timerRepository.focusTime.toInt() + (cycles / 2) * timerRepository.shortBreakTime.toInt()
|
||||
} else (totalTime - remainingTime)
|
||||
)
|
||||
)
|
||||
.setWhen(System.currentTimeMillis() + remainingTime) // Sets the Live Activity/Now Bar chip time
|
||||
.setShortCriticalText(millisecondsToStr(time.coerceAtLeast(0)))
|
||||
.build()
|
||||
)
|
||||
|
||||
if (complete) {
|
||||
alarm.start()
|
||||
_timerState.update { currentState ->
|
||||
currentState.copy(alarmRinging = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetTimer() {
|
||||
skipScope.launch {
|
||||
saveTimeToDb()
|
||||
time = timerRepository.focusTime
|
||||
cycles = 0
|
||||
startTime = 0L
|
||||
pauseTime = 0L
|
||||
pauseDuration = 0L
|
||||
|
||||
_timerState.update { currentState ->
|
||||
currentState.copy(
|
||||
timerMode = TimerMode.FOCUS,
|
||||
timeStr = millisecondsToStr(time),
|
||||
totalTime = time,
|
||||
nextTimerMode = if (timerRepository.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK,
|
||||
nextTimeStr = millisecondsToStr(if (timerRepository.sessionLength > 1) timerRepository.shortBreakTime else timerRepository.longBreakTime),
|
||||
currentFocusCount = 1,
|
||||
totalFocusCount = timerRepository.sessionLength
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun skipTimer(fromButton: Boolean = false) {
|
||||
skipScope.launch {
|
||||
saveTimeToDb()
|
||||
showTimerNotification(0, paused = true, complete = !fromButton)
|
||||
startTime = 0L
|
||||
pauseTime = 0L
|
||||
pauseDuration = 0L
|
||||
|
||||
cycles = (cycles + 1) % (timerRepository.sessionLength * 2)
|
||||
|
||||
if (cycles % 2 == 0) {
|
||||
time = timerRepository.focusTime
|
||||
_timerState.update { currentState ->
|
||||
currentState.copy(
|
||||
timerMode = TimerMode.FOCUS,
|
||||
timeStr = millisecondsToStr(time),
|
||||
totalTime = time,
|
||||
nextTimerMode = if (cycles == (timerRepository.sessionLength - 1) * 2) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK,
|
||||
nextTimeStr = if (cycles == (timerRepository.sessionLength - 1) * 2) millisecondsToStr(
|
||||
timerRepository.longBreakTime
|
||||
) else millisecondsToStr(
|
||||
timerRepository.shortBreakTime
|
||||
),
|
||||
currentFocusCount = cycles / 2 + 1,
|
||||
totalFocusCount = timerRepository.sessionLength
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val long = cycles == (timerRepository.sessionLength * 2) - 1
|
||||
time = if (long) timerRepository.longBreakTime else timerRepository.shortBreakTime
|
||||
|
||||
_timerState.update { currentState ->
|
||||
currentState.copy(
|
||||
timerMode = if (long) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK,
|
||||
timeStr = millisecondsToStr(time),
|
||||
totalTime = time,
|
||||
nextTimerMode = TimerMode.FOCUS,
|
||||
nextTimeStr = millisecondsToStr(timerRepository.focusTime)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopAlarm() {
|
||||
alarm.pause()
|
||||
alarm.seekTo(0)
|
||||
_timerState.update { currentState ->
|
||||
currentState.copy(alarmRinging = false)
|
||||
}
|
||||
notificationBuilder.clearActions().addTimerActions(this, R.drawable.play, "Start next")
|
||||
showTimerNotification(
|
||||
when (timerState.value.timerMode) {
|
||||
TimerMode.FOCUS -> timerRepository.focusTime.toInt()
|
||||
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
|
||||
else -> timerRepository.longBreakTime.toInt()
|
||||
},
|
||||
paused = true,
|
||||
complete = false
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun saveTimeToDb() {
|
||||
when (timerState.value.timerMode) {
|
||||
TimerMode.FOCUS -> statRepository.addFocusTime(
|
||||
(timerState.value.totalTime - time).coerceAtLeast(
|
||||
0L
|
||||
)
|
||||
)
|
||||
|
||||
else -> statRepository.addBreakTime((timerState.value.totalTime - time).coerceAtLeast(0L))
|
||||
}
|
||||
}
|
||||
|
||||
private fun startForegroundService() {
|
||||
startForeground(1, notificationBuilder.build())
|
||||
}
|
||||
|
||||
private fun stopForegroundService() {
|
||||
notificationManager.cancel(1)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
runBlocking {
|
||||
job.cancel()
|
||||
saveTimeToDb()
|
||||
notificationManager.cancel(1)
|
||||
}
|
||||
}
|
||||
|
||||
enum class Actions {
|
||||
TOGGLE, SKIP, RESET, STOP_ALARM
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
package org.nsh07.pomodoro.ui
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -33,6 +34,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -44,10 +46,12 @@ import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import org.nsh07.pomodoro.MainActivity.Companion.screens
|
||||
import org.nsh07.pomodoro.service.TimerService
|
||||
import org.nsh07.pomodoro.ui.settingsScreen.SettingsScreenRoot
|
||||
import org.nsh07.pomodoro.ui.statsScreen.StatsScreenRoot
|
||||
import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel
|
||||
import org.nsh07.pomodoro.ui.timerScreen.TimerScreen
|
||||
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
|
||||
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@@ -57,6 +61,8 @@ fun AppScreen(
|
||||
timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory),
|
||||
statsViewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val uiState by timerViewModel.timerState.collectAsStateWithLifecycle()
|
||||
val remainingTime by timerViewModel.time.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -139,7 +145,33 @@ fun AppScreen(
|
||||
TimerScreen(
|
||||
timerState = uiState,
|
||||
progress = { progress },
|
||||
onAction = timerViewModel::onAction,
|
||||
onAction = { action ->
|
||||
when (action) {
|
||||
TimerAction.ResetTimer ->
|
||||
Intent(context, TimerService::class.java).also {
|
||||
it.action = TimerService.Actions.RESET.toString()
|
||||
context.startService(it)
|
||||
}
|
||||
|
||||
is TimerAction.SkipTimer ->
|
||||
Intent(context, TimerService::class.java).also {
|
||||
it.action = TimerService.Actions.SKIP.toString()
|
||||
context.startService(it)
|
||||
}
|
||||
|
||||
TimerAction.StopAlarm ->
|
||||
Intent(context, TimerService::class.java).also {
|
||||
it.action = TimerService.Actions.STOP_ALARM.toString()
|
||||
context.startService(it)
|
||||
}
|
||||
|
||||
TimerAction.ToggleTimer ->
|
||||
Intent(context, TimerService::class.java).also {
|
||||
it.action = TimerService.Actions.TOGGLE.toString()
|
||||
context.startService(it)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier.padding(
|
||||
start = contentPadding.calculateStartPadding(layoutDirection),
|
||||
end = contentPadding.calculateEndPadding(layoutDirection),
|
||||
@@ -160,6 +192,7 @@ fun AppScreen(
|
||||
|
||||
entry<Screen.Stats> {
|
||||
StatsScreenRoot(
|
||||
contentPadding = contentPadding,
|
||||
viewModel = statsViewModel,
|
||||
modifier = modifier.padding(
|
||||
start = contentPadding.calculateStartPadding(layoutDirection),
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock
|
||||
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -59,7 +60,7 @@ fun MinuteInputField(
|
||||
.background(
|
||||
animateColorAsState(
|
||||
if (state.text.isNotEmpty())
|
||||
colorScheme.surface
|
||||
listItemColors.containerColor
|
||||
else colorScheme.errorContainer,
|
||||
motionScheme.defaultEffectsSpec()
|
||||
).value,
|
||||
|
||||
@@ -60,6 +60,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import org.nsh07.pomodoro.R
|
||||
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel
|
||||
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
|
||||
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
|
||||
import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors
|
||||
import org.nsh07.pomodoro.ui.theme.TomatoTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -120,7 +122,7 @@ private fun SettingsScreen(
|
||||
)
|
||||
},
|
||||
subtitle = {},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = colorScheme.surfaceContainer),
|
||||
colors = topBarColors,
|
||||
titleHorizontalAlignment = Alignment.CenterHorizontally,
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
@@ -128,7 +130,7 @@ private fun SettingsScreen(
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier
|
||||
.background(colorScheme.surfaceContainer)
|
||||
.background(topBarColors.containerColor)
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
@@ -221,6 +223,7 @@ private fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = listItemColors,
|
||||
modifier = Modifier.clip(shapes.large)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -64,6 +66,7 @@ import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes
|
||||
|
||||
@Composable
|
||||
fun StatsScreenRoot(
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)
|
||||
) {
|
||||
@@ -74,6 +77,7 @@ fun StatsScreenRoot(
|
||||
.lastMonthAverageFocusTimes.collectAsStateWithLifecycle(null)
|
||||
|
||||
StatsScreen(
|
||||
contentPadding = contentPadding,
|
||||
lastWeekSummaryChartData = remember { viewModel.lastWeekSummaryChartData },
|
||||
lastWeekSummaryAnalysisModelProducer = remember { viewModel.lastWeekSummaryAnalysisModelProducer },
|
||||
lastMonthSummaryChartData = remember { viewModel.lastMonthSummaryChartData },
|
||||
@@ -88,6 +92,7 @@ fun StatsScreenRoot(
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun StatsScreen(
|
||||
contentPadding: PaddingValues,
|
||||
lastWeekSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
|
||||
lastWeekSummaryAnalysisModelProducer: CartesianChartModelProducer,
|
||||
lastMonthSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
|
||||
@@ -114,12 +119,16 @@ fun StatsScreen(
|
||||
fontFamily = robotoFlexTopBar,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 32.sp
|
||||
)
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(top = contentPadding.calculateTopPadding())
|
||||
.padding(vertical = 14.dp)
|
||||
)
|
||||
},
|
||||
subtitle = {},
|
||||
titleHorizontalAlignment = Alignment.CenterHorizontally,
|
||||
scrollBehavior = scrollBehavior
|
||||
scrollBehavior = scrollBehavior,
|
||||
windowInsets = WindowInsets()
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
@@ -356,6 +365,7 @@ fun StatsScreenPreview() {
|
||||
}
|
||||
|
||||
StatsScreen(
|
||||
PaddingValues(),
|
||||
Pair(modelProducer, ExtraStore.Key()),
|
||||
modelProducer,
|
||||
Pair(modelProducer, ExtraStore.Key()),
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package org.nsh07.pomodoro.ui.theme
|
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ListItemColors
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||
import androidx.compose.material3.TopAppBarColors
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
@@ -8,4 +15,18 @@ val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
|
||||
object CustomColors {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
val topBarColors: TopAppBarColors
|
||||
@Composable get() {
|
||||
return TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = colorScheme.surfaceContainer,
|
||||
scrolledContainerColor = colorScheme.surfaceContainer
|
||||
)
|
||||
}
|
||||
|
||||
val listItemColors: ListItemColors
|
||||
@Composable get() = ListItemDefaults.colors(containerColor = colorScheme.surfaceBright)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Nishant Mishra
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.nsh07.pomodoro.ui.timerScreen
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.material3.AlertDialogDefaults
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MaterialTheme.typography
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.nsh07.pomodoro.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AlarmDialog(
|
||||
modifier: Modifier = Modifier,
|
||||
stopAlarm: () -> Unit
|
||||
) {
|
||||
BasicAlertDialog(
|
||||
onDismissRequest = stopAlarm,
|
||||
modifier = modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.wrapContentHeight()
|
||||
.clickable(onClick = stopAlarm),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = AlertDialogDefaults.TonalElevation,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp)) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.alarm),
|
||||
contentDescription = "Alarm",
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Stop Alarm?",
|
||||
style = typography.headlineSmall,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Current timer session is complete. Tap anywhere to stop the alarm."
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = stopAlarm,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Text("Ok")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,16 @@ import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -41,14 +47,20 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||
import androidx.compose.material3.MaterialTheme.motionScheme
|
||||
import androidx.compose.material3.MaterialTheme.shapes
|
||||
import androidx.compose.material3.MaterialTheme.typography
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -99,6 +111,9 @@ fun TimerScreen(
|
||||
onResult = {}
|
||||
)
|
||||
|
||||
if (timerState.alarmRinging)
|
||||
AlarmDialog { onAction(TimerAction.StopAlarm) }
|
||||
|
||||
Column(modifier = modifier) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
@@ -170,15 +185,15 @@ fun TimerScreen(
|
||||
}
|
||||
},
|
||||
subtitle = {},
|
||||
titleHorizontalAlignment = Alignment.CenterHorizontally
|
||||
titleHorizontalAlignment = CenterHorizontally
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Column(horizontalAlignment = CenterHorizontally) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
if (timerState.timerMode == TimerMode.FOCUS) {
|
||||
CircularProgressIndicator(
|
||||
@@ -190,7 +205,7 @@ fun TimerScreen(
|
||||
color = color,
|
||||
trackColor = colorContainer,
|
||||
strokeWidth = 16.dp,
|
||||
gapSize = 16.dp
|
||||
gapSize = 8.dp
|
||||
)
|
||||
} else {
|
||||
CircularWavyProgressIndicator(
|
||||
@@ -214,20 +229,45 @@ fun TimerScreen(
|
||||
cap = StrokeCap.Round,
|
||||
),
|
||||
wavelength = 60.dp,
|
||||
gapSize = 16.dp
|
||||
gapSize = 8.dp
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = timerState.timeStr,
|
||||
style = TextStyle(
|
||||
fontFamily = openRundeClock,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 72.sp,
|
||||
letterSpacing = (-2).sp
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1
|
||||
)
|
||||
var expanded by remember { mutableStateOf(timerState.showBrandTitle) }
|
||||
Column(
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.clip(shapes.largeIncreased)
|
||||
.clickable(onClick = { expanded = !expanded })
|
||||
) {
|
||||
LaunchedEffect(timerState.showBrandTitle) {
|
||||
expanded = timerState.showBrandTitle
|
||||
}
|
||||
Text(
|
||||
text = timerState.timeStr,
|
||||
style = TextStyle(
|
||||
fontFamily = openRundeClock,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 72.sp,
|
||||
letterSpacing = (-2).sp
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1
|
||||
)
|
||||
AnimatedVisibility(
|
||||
expanded,
|
||||
enter = fadeIn(motionScheme.defaultEffectsSpec()) +
|
||||
expandVertically(motionScheme.defaultSpatialSpec()),
|
||||
exit = fadeOut(motionScheme.defaultEffectsSpec()) +
|
||||
shrinkVertically(motionScheme.defaultSpatialSpec())
|
||||
) {
|
||||
Text(
|
||||
"${timerState.currentFocusCount} of ${timerState.totalFocusCount}",
|
||||
fontFamily = openRundeClock,
|
||||
style = typography.titleLarge,
|
||||
color = colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val interactionSources = remember { List(3) { MutableInteractionSource() } }
|
||||
ButtonGroup(
|
||||
@@ -260,10 +300,10 @@ fun TimerScreen(
|
||||
{
|
||||
FilledIconToggleButton(
|
||||
onCheckedChange = { checked ->
|
||||
onAction(TimerAction.ToggleTimer)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checked) {
|
||||
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
onAction(TimerAction.ToggleTimer)
|
||||
},
|
||||
checked = timerState.timerRunning,
|
||||
colors = IconButtonDefaults.filledIconToggleButtonColors(
|
||||
@@ -355,7 +395,7 @@ fun TimerScreen(
|
||||
customItem(
|
||||
{
|
||||
FilledTonalIconButton(
|
||||
onClick = { onAction(TimerAction.SkipTimer) },
|
||||
onClick = { onAction(TimerAction.SkipTimer(fromButton = true)) },
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = colorContainer
|
||||
),
|
||||
@@ -382,7 +422,7 @@ fun TimerScreen(
|
||||
},
|
||||
text = { Text("Skip to next") },
|
||||
onClick = {
|
||||
onAction(TimerAction.SkipTimer)
|
||||
onAction(TimerAction.SkipTimer(fromButton = true))
|
||||
state.dismiss()
|
||||
}
|
||||
)
|
||||
@@ -393,7 +433,7 @@ fun TimerScreen(
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Column(horizontalAlignment = CenterHorizontally) {
|
||||
Text("Up next", style = typography.titleSmall)
|
||||
Text(
|
||||
timerState.nextTimeStr,
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
package org.nsh07.pomodoro.ui.timerScreen.viewModel
|
||||
|
||||
sealed interface TimerAction {
|
||||
data class SkipTimer(val fromButton: Boolean) : TimerAction
|
||||
|
||||
data object ResetTimer : TimerAction
|
||||
data object SkipTimer : TimerAction
|
||||
data object StopAlarm : TimerAction
|
||||
data object ToggleTimer : TimerAction
|
||||
}
|
||||
@@ -14,7 +14,10 @@ data class TimerState(
|
||||
val timerRunning: Boolean = false,
|
||||
val nextTimerMode: TimerMode = TimerMode.SHORT_BREAK,
|
||||
val nextTimeStr: String = "5:00",
|
||||
val showBrandTitle: Boolean = true
|
||||
val showBrandTitle: Boolean = true,
|
||||
val currentFocusCount: Int = 1,
|
||||
val totalFocusCount: Int = 4,
|
||||
val alarmRinging: Boolean = false
|
||||
)
|
||||
|
||||
enum class TimerMode {
|
||||
|
||||
@@ -7,18 +7,9 @@
|
||||
|
||||
package org.nsh07.pomodoro.ui.timerScreen.viewModel
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.app.Application
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -26,15 +17,12 @@ import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.nsh07.pomodoro.MainActivity
|
||||
import org.nsh07.pomodoro.R
|
||||
import org.nsh07.pomodoro.TomatoApplication
|
||||
import org.nsh07.pomodoro.data.PreferenceRepository
|
||||
import org.nsh07.pomodoro.data.Stat
|
||||
@@ -42,27 +30,17 @@ import org.nsh07.pomodoro.data.StatRepository
|
||||
import org.nsh07.pomodoro.data.TimerRepository
|
||||
import org.nsh07.pomodoro.utils.millisecondsToStr
|
||||
import java.time.LocalDate
|
||||
import kotlin.text.Typography.middleDot
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
class TimerViewModel(
|
||||
application: Application,
|
||||
private val preferenceRepository: PreferenceRepository,
|
||||
private val statRepository: StatRepository,
|
||||
private val timerRepository: TimerRepository,
|
||||
private val notificationBuilder: NotificationCompat.Builder,
|
||||
private val notificationManager: NotificationManagerCompat
|
||||
) : ViewModel() {
|
||||
private val _timerState = MutableStateFlow(
|
||||
TimerState(
|
||||
totalTime = timerRepository.focusTime,
|
||||
timeStr = millisecondsToStr(timerRepository.focusTime),
|
||||
nextTimeStr = millisecondsToStr(timerRepository.shortBreakTime)
|
||||
)
|
||||
)
|
||||
|
||||
private val _timerState: MutableStateFlow<TimerState>,
|
||||
private val _time: MutableStateFlow<Long>
|
||||
) : AndroidViewModel(application) {
|
||||
val timerState: StateFlow<TimerState> = _timerState.asStateFlow()
|
||||
var timerJob: Job? = null
|
||||
private val _time = MutableStateFlow(timerRepository.focusTime)
|
||||
|
||||
val time: StateFlow<Long> = _time.asStateFlow()
|
||||
private var cycles = 0
|
||||
@@ -122,14 +100,6 @@ class TimerViewModel(
|
||||
cs = colorScheme
|
||||
}
|
||||
|
||||
fun onAction(action: TimerAction) {
|
||||
when (action) {
|
||||
TimerAction.ResetTimer -> resetTimer()
|
||||
TimerAction.SkipTimer -> skipTimer()
|
||||
TimerAction.ToggleTimer -> toggleTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetTimer() {
|
||||
viewModelScope.launch {
|
||||
saveTimeToDb()
|
||||
@@ -145,111 +115,14 @@ class TimerViewModel(
|
||||
timeStr = millisecondsToStr(time.value),
|
||||
totalTime = time.value,
|
||||
nextTimerMode = if (timerRepository.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK,
|
||||
nextTimeStr = millisecondsToStr(if (timerRepository.sessionLength > 1) timerRepository.shortBreakTime else timerRepository.longBreakTime)
|
||||
nextTimeStr = millisecondsToStr(if (timerRepository.sessionLength > 1) timerRepository.shortBreakTime else timerRepository.longBreakTime),
|
||||
currentFocusCount = 1,
|
||||
totalFocusCount = timerRepository.sessionLength
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun skipTimer() {
|
||||
viewModelScope.launch {
|
||||
saveTimeToDb()
|
||||
showTimerNotification(0, paused = true, complete = true)
|
||||
startTime = 0L
|
||||
pauseTime = 0L
|
||||
pauseDuration = 0L
|
||||
|
||||
cycles = (cycles + 1) % (timerRepository.sessionLength * 2)
|
||||
|
||||
if (cycles % 2 == 0) {
|
||||
_time.update { timerRepository.focusTime }
|
||||
_timerState.update { currentState ->
|
||||
currentState.copy(
|
||||
timerMode = TimerMode.FOCUS,
|
||||
timeStr = millisecondsToStr(time.value),
|
||||
totalTime = time.value,
|
||||
nextTimerMode = if (cycles == (timerRepository.sessionLength - 1) * 2) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK,
|
||||
nextTimeStr = if (cycles == (timerRepository.sessionLength - 1) * 2) millisecondsToStr(
|
||||
timerRepository.longBreakTime
|
||||
) else millisecondsToStr(
|
||||
timerRepository.shortBreakTime
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val long = cycles == (timerRepository.sessionLength * 2) - 1
|
||||
_time.update { if (long) timerRepository.longBreakTime else timerRepository.shortBreakTime }
|
||||
|
||||
_timerState.update { currentState ->
|
||||
currentState.copy(
|
||||
timerMode = if (long) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK,
|
||||
timeStr = millisecondsToStr(time.value),
|
||||
totalTime = time.value,
|
||||
nextTimerMode = TimerMode.FOCUS,
|
||||
nextTimeStr = millisecondsToStr(timerRepository.focusTime)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleTimer() {
|
||||
if (timerState.value.timerRunning) {
|
||||
showTimerNotification(time.value.toInt(), paused = true)
|
||||
_timerState.update { currentState ->
|
||||
currentState.copy(timerRunning = false)
|
||||
}
|
||||
timerJob?.cancel()
|
||||
pauseTime = SystemClock.elapsedRealtime()
|
||||
} else {
|
||||
_timerState.update { it.copy(timerRunning = true) }
|
||||
if (pauseTime != 0L) pauseDuration += SystemClock.elapsedRealtime() - pauseTime
|
||||
|
||||
var iterations = -1
|
||||
|
||||
timerJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
if (!timerState.value.timerRunning) break
|
||||
if (startTime == 0L) startTime = SystemClock.elapsedRealtime()
|
||||
|
||||
_time.update {
|
||||
when (timerState.value.timerMode) {
|
||||
TimerMode.FOCUS ->
|
||||
timerRepository.focusTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
|
||||
|
||||
TimerMode.SHORT_BREAK ->
|
||||
timerRepository.shortBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
|
||||
|
||||
else ->
|
||||
timerRepository.longBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
iterations = (iterations + 1) % 50
|
||||
|
||||
if (iterations == 0) showTimerNotification(time.value.toInt())
|
||||
|
||||
if (time.value < 0) {
|
||||
skipTimer()
|
||||
|
||||
_timerState.update { currentState ->
|
||||
currentState.copy(timerRunning = false)
|
||||
}
|
||||
timerJob?.cancel()
|
||||
} else {
|
||||
_timerState.update { currentState ->
|
||||
currentState.copy(
|
||||
timeStr = millisecondsToStr(time.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveTimeToDb() {
|
||||
when (timerState.value.timerMode) {
|
||||
TimerMode.FOCUS -> statRepository
|
||||
@@ -260,78 +133,6 @@ class TimerViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission") // We check for the permission when pressing the Play button in the UI
|
||||
fun showTimerNotification(
|
||||
remainingTime: Int,
|
||||
paused: Boolean = false,
|
||||
complete: Boolean = false
|
||||
) {
|
||||
val totalTime = when (timerState.value.timerMode) {
|
||||
TimerMode.FOCUS -> timerRepository.focusTime.toInt()
|
||||
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
|
||||
else -> timerRepository.longBreakTime.toInt()
|
||||
}
|
||||
|
||||
val currentTimer = when (timerState.value.timerMode) {
|
||||
TimerMode.FOCUS -> "Focus"
|
||||
TimerMode.SHORT_BREAK -> "Short break"
|
||||
else -> "Long break"
|
||||
}
|
||||
|
||||
val nextTimer = when (timerState.value.nextTimerMode) {
|
||||
TimerMode.FOCUS -> "Focus"
|
||||
TimerMode.SHORT_BREAK -> "Short break"
|
||||
else -> "Long break"
|
||||
}
|
||||
|
||||
val remainingTimeString =
|
||||
if ((remainingTime.toFloat() / 60000f) < 1.0f) "< 1"
|
||||
else (remainingTime.toFloat() / 60000f).toInt()
|
||||
|
||||
notificationManager.notify(
|
||||
1,
|
||||
notificationBuilder
|
||||
.setContentTitle(
|
||||
if (!complete) {
|
||||
"$currentTimer $middleDot $remainingTimeString min remaining" + if (paused) " $middleDot Paused" else ""
|
||||
} else "$currentTimer $middleDot Completed"
|
||||
)
|
||||
.setContentText("Up next: $nextTimer (${timerState.value.nextTimeStr})")
|
||||
.setStyle(
|
||||
NotificationCompat.ProgressStyle()
|
||||
.also {
|
||||
// Add all the Focus, Short break and long break intervals in order
|
||||
for (i in 0..<timerRepository.sessionLength * 2) {
|
||||
if (i % 2 == 0) it.addProgressSegment(
|
||||
NotificationCompat.ProgressStyle.Segment(
|
||||
timerRepository.focusTime.toInt()
|
||||
).setColor(cs.primary.toArgb())
|
||||
)
|
||||
else if (i != (timerRepository.sessionLength * 2 - 1)) it.addProgressSegment(
|
||||
NotificationCompat.ProgressStyle.Segment(
|
||||
timerRepository.shortBreakTime.toInt()
|
||||
).setColor(cs.tertiary.toArgb())
|
||||
)
|
||||
else it.addProgressSegment(
|
||||
NotificationCompat.ProgressStyle.Segment(
|
||||
timerRepository.longBreakTime.toInt()
|
||||
).setColor(cs.tertiary.toArgb())
|
||||
)
|
||||
}
|
||||
}
|
||||
.setProgress( // Set the current progress by filling the previous intervals and part of the current interval
|
||||
(totalTime - remainingTime) +
|
||||
((cycles + 1) / 2) * timerRepository.focusTime.toInt() +
|
||||
(cycles / 2) * timerRepository.shortBreakTime.toInt()
|
||||
)
|
||||
)
|
||||
.setShowWhen(true)
|
||||
.setWhen(System.currentTimeMillis() + remainingTime) // Sets the Live Activity/Now Bar chip time
|
||||
.setSilent(!complete)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
@@ -339,42 +140,16 @@ class TimerViewModel(
|
||||
val appPreferenceRepository = application.container.appPreferenceRepository
|
||||
val appStatRepository = application.container.appStatRepository
|
||||
val appTimerRepository = application.container.appTimerRepository
|
||||
|
||||
val notificationManager = NotificationManagerCompat.from(application)
|
||||
|
||||
val notificationChannel = NotificationChannel(
|
||||
"timer",
|
||||
"Timer progress",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
)
|
||||
|
||||
notificationManager.createNotificationChannel(notificationChannel)
|
||||
|
||||
val openAppIntent = Intent(application, MainActivity::class.java)
|
||||
openAppIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
openAppIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
|
||||
val contentIntent = PendingIntent.getActivity(
|
||||
application,
|
||||
System.currentTimeMillis().toInt(),
|
||||
openAppIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notificationBuilder = NotificationCompat.Builder(application, "timer")
|
||||
.setSmallIcon(R.drawable.tomato_logo_notification)
|
||||
.setOngoing(true)
|
||||
.setColor(Color.Red.toArgb())
|
||||
.setContentIntent(contentIntent)
|
||||
.setRequestPromotedOngoing(true)
|
||||
.setOngoing(true)
|
||||
val timerState = application.container.timerState
|
||||
val time = application.container.time
|
||||
|
||||
TimerViewModel(
|
||||
application = application,
|
||||
preferenceRepository = appPreferenceRepository,
|
||||
statRepository = appStatRepository,
|
||||
timerRepository = appTimerRepository,
|
||||
notificationBuilder = notificationBuilder,
|
||||
notificationManager = notificationManager
|
||||
_timerState = timerState,
|
||||
_time = time
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
16
app/src/main/res/drawable/alarm.xml
Normal file
16
app/src/main/res/drawable/alarm.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<!--
|
||||
~ Copyright (c) 2025 Nishant Mishra
|
||||
~
|
||||
~ You should have received a copy of the GNU General Public License
|
||||
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#e3e3e3"
|
||||
android:pathData="M520,504v-144q0,-17 -11.5,-28.5T480,320q-17,0 -28.5,11.5T440,360v159q0,8 3,15.5t9,13.5l112,112q11,11 28,11t28,-11q11,-11 11,-28t-11,-28L520,504ZM480,880q-75,0 -140.5,-28.5t-114,-77q-48.5,-48.5 -77,-114T120,520q0,-75 28.5,-140.5t77,-114q48.5,-48.5 114,-77T480,160q75,0 140.5,28.5t114,77q48.5,48.5 77,114T840,520q0,75 -28.5,140.5t-77,114q-48.5,48.5 -114,77T480,880ZM82,292q-11,-11 -11,-28t11,-28l114,-114q11,-11 28,-11t28,11q11,11 11,28t-11,28L138,292q-11,11 -28,11t-28,-11ZM878,292q-11,11 -28,11t-28,-11L708,178q-11,-11 -11,-28t11,-28q11,-11 28,-11t28,11l114,114q11,11 11,28t-11,28Z" />
|
||||
</vector>
|
||||
9
fastlane/metadata/android/en-US/changelogs/6.txt
Normal file
9
fastlane/metadata/android/en-US/changelogs/6.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
New features:
|
||||
- The app now rings the system alarm sound when a timer completes
|
||||
- Clicking the timer clock now shows the current session count
|
||||
- The app can now stay in the background and the timer keeps running even when the app is closed
|
||||
- New notification buttons to control the timer without opening the app
|
||||
|
||||
Fixes:
|
||||
- Current elapsed time is now saved in the stats when the app is closed while a timer is running
|
||||
- Live Updates/Now Bar now shows precisely how much time is remaining (in seconds)
|
||||
@@ -1,17 +1,17 @@
|
||||
[versions]
|
||||
activityCompose = "1.10.1"
|
||||
activityCompose = "1.11.0"
|
||||
adaptive = "1.1.0"
|
||||
agp = "8.11.1"
|
||||
composeBom = "2025.08.00"
|
||||
composeBom = "2025.09.00"
|
||||
coreKtx = "1.17.0"
|
||||
espressoCore = "3.7.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.3.0"
|
||||
kotlin = "2.2.10"
|
||||
ksp = "2.2.10-2.0.2"
|
||||
lifecycleRuntimeKtx = "2.9.2"
|
||||
navigation3Runtime = "1.0.0-alpha07"
|
||||
room = "2.7.2"
|
||||
kotlin = "2.2.20"
|
||||
ksp = "2.2.20-2.0.3"
|
||||
lifecycleRuntimeKtx = "2.9.3"
|
||||
navigation3Runtime = "1.0.0-alpha09"
|
||||
room = "2.8.0"
|
||||
vico = "2.2.0-alpha.1"
|
||||
|
||||
[libraries]
|
||||
|
||||
Reference in New Issue
Block a user