migrate timer to ForegroundService for background survival

Move CountDownTimer logic from a Dialog into a ForegroundService so
the timer survives when the user tabs away or the activity is
destroyed. The activity becomes a thin UI projection bound to the
service via TimerStateListener callbacks.

- Create CountdownService with notification, state persistence,
  and ToneGenerator/Vibrator ownership
- Persist timer state to SharedPreferences for process death recovery
- Add notification with progress bar and Stop/Restart actions
- Embed countdown UI in activity_main.xml alongside picker UI
- Delete CountdownDialog and dialog_countdown.xml
- Update AndroidManifest with foreground service permissions
This commit is contained in:
2026-06-23 01:49:11 +00:00
parent da29e51993
commit d7ce314c22
9 changed files with 697 additions and 438 deletions
+2 -1
View File
@@ -9,7 +9,8 @@
"felipecaslazana.vscode-android-logcat", "felipecaslazana.vscode-android-logcat",
"ms-azuretools.vscode-docker", "ms-azuretools.vscode-docker",
"fwcd.kotlin", "fwcd.kotlin",
"vscjava.vscode-java-pack" "vscjava.vscode-java-pack",
"Anthropic.claude-code"
] ]
} }
}, },
+11 -8
View File
@@ -17,16 +17,19 @@
- **Android Patterns**: Use `findViewById` for view binding in this project to maintain consistency with existing simple architecture. - **Android Patterns**: Use `findViewById` for view binding in this project to maintain consistency with existing simple architecture.
## Architecture ## Architecture
- **Pattern**: Simple Activity-Dialog architecture. - **Pattern**: Activity-Service architecture with `ForegroundService`.
- **State**: The countdown state is managed locally within `CountdownDialog` using `android.os.CountDownTimer`. - **State**: The countdown state is managed in `CountdownService` using `android.os.CountDownTimer`, persisted to `SharedPreferences` for process death recovery.
- **UI**: `MainActivity` binds to the service and acts as a thin UI projection, receiving state updates via `TimerStateListener` callback.
- **Dependency Management**: Gradle with Groovy DSL. - **Dependency Management**: Gradle with Groovy DSL.
- **Key Constraints**: - **Key Constraints**:
- The `CountdownDialog` is explicitly set to `setCancelable(false)` and `setCanceledOnTouchOutside(false)` to ensure the timer completes. - The notification is set to `setOngoing(true)` — cannot be swiped away.
- Timer state survives process death via `SharedPreferences` persistence.
## Critical Files Reference ## Critical Files Reference
- `app/src/main/java/com/example/countdowntimer/MainActivity.kt`: Entry point and trigger for the timer. - `app/src/main/java/com/example/countdowntimer/MainActivity.kt`: Entry point, binds to service, implements `TimerStateListener`.
- `app/src/main/java/com/example/countdowntimer/CountdownDialog.kt`: Implementation of the countdown logic and `ToneGenerator` trigger. - `app/src/main/java/com/example/countdowntimer/CountdownService.kt`: ForegroundService owning timer logic, `ToneGenerator`, `Vibrator`, notification, and state persistence.
- `app/src/main/java/com/example/countdowntimer/TimerStateListener.kt`: Callback interface for service-to-activity state updates.
- `app/src/main/java/com/example/countdowntimer/CountdownCanvasView.kt`: Custom view for the circular progress ring and countdown text. - `app/src/main/java/com/example/countdowntimer/CountdownCanvasView.kt`: Custom view for the circular progress ring and countdown text.
- `app/src/main/res/layout/activity_main.xml`: Main screen layout. - `app/src/main/res/layout/activity_main.xml`: Main screen layout (contains both picker and countdown UI).
- `app/src/main/res/layout/dialog_countdown.xml`: Timer dialog layout. - `app/src/main/AndroidManifest.xml`: Declares service and foreground service permissions.
- `app/build.gradle`: Build configuration, SDK versions (minSdk 24, targetSdk 34), and dependencies. - `app/build.gradle`: Build configuration, SDK versions (minSdk 34, targetSdk 34), and dependencies.
+7
View File
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/ic_timer" android:icon="@drawable/ic_timer"
@@ -16,5 +18,10 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".CountdownService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application> </application>
</manifest> </manifest>
@@ -1,132 +0,0 @@
package com.example.countdowntimer
import android.app.Dialog
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import android.media.AudioManager
import android.media.ToneGenerator
import android.os.Bundle
import android.os.CountDownTimer
import android.os.Vibrator
import android.view.View
import android.view.WindowManager
import android.widget.Button
class CountdownDialog(
private val context: Context,
private val durationMillis: Long,
private val prefs: SharedPreferences
) : Dialog(context) {
private lateinit var cvCountdown: CountdownCanvasView
private lateinit var btnStop: View
private lateinit var btnRestart: View
private lateinit var btnDismiss: Button
private var timer: CountDownTimer? = null
private var toneGenerator: ToneGenerator? = null
private var vibrator: Vibrator? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.dialog_countdown)
setCanceledOnTouchOutside(false)
setCancelable(false)
window?.let { win ->
win.setBackgroundDrawableResource(android.R.color.transparent)
val lp = win.attributes
lp.width = WindowManager.LayoutParams.MATCH_PARENT
lp.height = WindowManager.LayoutParams.MATCH_PARENT
win.attributes = lp
}
cvCountdown = findViewById(R.id.cvCountdown)
btnStop = findViewById(R.id.btnStop)
btnRestart = findViewById(R.id.btnRestart)
btnDismiss = findViewById(R.id.btnDismiss)
toneGenerator = ToneGenerator(AudioManager.STREAM_ALARM, 100)
vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
btnStop.setOnClickListener { cancelTimer() }
btnRestart.setOnClickListener { restartTimer() }
btnDismiss.setOnClickListener { dismiss() }
startTimer()
}
private fun startTimer() {
cvCountdown.setProgress(1f)
cvCountdown.setColor(ContextCompat.getColor(context, R.color.md_color_ring_progress))
timer = object : CountDownTimer(durationMillis, 1000) {
override fun onTick(millisUntilFinished: Long) {
val progress = millisUntilFinished.toFloat() / durationMillis
cvCountdown.setProgress(progress)
// Interpolate ring color: green → yellow at 50% → red at end
val green = android.graphics.Color.parseColor("#2E7D32")
val yellow = android.graphics.Color.parseColor("#FFC107")
val red = android.graphics.Color.parseColor("#F44336")
val ringColor = when {
progress >= 0.5f -> {
val t = (progress - 0.5f) * 2f
ColorUtils.blendARGB(green, yellow, t)
}
else -> {
val t = progress * 2f
ColorUtils.blendARGB(yellow, red, t)
}
}
cvCountdown.setColor(ringColor)
val secondsLeft = (millisUntilFinished / 1000).toInt() + 1
cvCountdown.setText(secondsLeft.toString())
if (secondsLeft >= 1 && secondsLeft <= 3) {
playTone(ToneGenerator.TONE_PROP_BEEP)
if (prefs.getBoolean("vibration", true)) {
vibrator?.vibrate(50)
}
}
}
override fun onFinish() {
cvCountdown.setText(context.getString(R.string.timer_zero))
cvCountdown.setProgress(0f)
playTone(ToneGenerator.TONE_SUP_CONFIRM, 500)
if (prefs.getBoolean("vibration", true)) {
vibrator?.vibrate(200)
}
btnStop.visibility = View.GONE
btnRestart.visibility = View.GONE
btnDismiss.visibility = View.VISIBLE
}
}.start()
}
private fun cancelTimer() {
timer?.cancel()
dismiss()
}
private fun restartTimer() {
timer?.cancel()
startTimer()
}
override fun dismiss() {
timer?.cancel()
toneGenerator?.release()
toneGenerator = null
super.dismiss()
}
private fun playTone(tone: Int, durationMillis: Int? = null) {
if (durationMillis == null) {
toneGenerator?.startTone(tone)
} else {
toneGenerator?.startTone(tone, durationMillis)
}
}
}
@@ -0,0 +1,263 @@
package com.example.countdowntimer
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.media.AudioManager
import android.media.ToneGenerator
import android.os.Binder
import android.os.CountDownTimer
import android.os.IBinder
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.core.app.NotificationCompat
import androidx.core.graphics.ColorUtils
class CountdownService : Service() {
companion object {
const val ACTION_START = "com.example.countdowntimer.START"
const val ACTION_STOP = "com.example.countdowntimer.STOP"
const val ACTION_RESTART = "com.example.countdowntimer.RESTART"
const val EXTRA_DURATION = "extra_duration"
private const val NOTIFICATION_ID = 1
private const val CHANNEL_ID = "countdown_channel"
private const val CHANNEL_NAME = "Countdown Timer"
private const val PREFS_NAME = "timer_prefs"
private const val KEY_REMAINING_MILLIS = "remaining_millis"
private const val KEY_TOTAL_DURATION = "total_duration"
private const val KEY_TIMER_RUNNING = "timer_running"
}
private var countDownTimer: CountDownTimer? = null
private var toneGenerator: ToneGenerator? = null
private var vibrator: Vibrator? = null
private var prefs: SharedPreferences? = null
private var stateListener: TimerStateListener? = null
private var durationMillis: Long = 0
private var notificationManager: NotificationManager? = null
private var isTimerRunning: Boolean = false
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): CountdownService = this@CountdownService
}
fun setStateListener(listener: TimerStateListener?) {
stateListener = listener
}
fun isRunning(): Boolean = isTimerRunning
// ---- Service lifecycle ----
override fun onCreate() {
super.onCreate()
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
toneGenerator = ToneGenerator(AudioManager.STREAM_ALARM, 100)
val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibrator = vibratorManager.defaultVibrator
createNotificationChannel()
// Restore timer state if it was running before process death
if (prefs?.getBoolean(KEY_TIMER_RUNNING, false) == true) {
val remaining = prefs?.getLong(KEY_REMAINING_MILLIS, 0) ?: 0
durationMillis = prefs?.getLong(KEY_TOTAL_DURATION, 0) ?: 0
if (remaining > 0 && durationMillis > 0) {
resumeTimer(remaining)
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action
when (action) {
ACTION_START -> {
val duration = intent.getLongExtra(EXTRA_DURATION, 600000L)
startTimer(duration)
}
ACTION_STOP -> stopTimer()
ACTION_RESTART -> restartTimer()
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onDestroy() {
countDownTimer?.cancel()
countDownTimer = null
toneGenerator?.release()
toneGenerator = null
notificationManager?.cancel(NOTIFICATION_ID)
super.onDestroy()
}
// ---- Timer logic ----
private fun startTimer(duration: Long) {
durationMillis = duration
countDownTimer?.cancel()
countDownTimer = createCountDownTimer(duration).start()
isTimerRunning = true
persistState(duration, duration)
startForeground(NOTIFICATION_ID, buildNotification(duration, duration))
}
private fun resumeTimer(remaining: Long) {
isTimerRunning = true
countDownTimer = createCountDownTimer(remaining).start()
startForeground(NOTIFICATION_ID, buildNotification(remaining, durationMillis))
}
fun stopTimer() {
countDownTimer?.cancel()
countDownTimer = null
isTimerRunning = false
prefs?.edit()?.apply {
putBoolean(KEY_TIMER_RUNNING, false)
remove(KEY_REMAINING_MILLIS)
remove(KEY_TOTAL_DURATION)
}?.apply()
notificationManager?.cancel(NOTIFICATION_ID)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
stateListener?.onTimerStopped()
}
fun restartTimer() {
countDownTimer?.cancel()
startTimer(durationMillis)
}
private fun persistState(remaining: Long, total: Long) {
prefs?.edit()?.apply {
putLong(KEY_REMAINING_MILLIS, remaining)
putLong(KEY_TOTAL_DURATION, total)
putBoolean(KEY_TIMER_RUNNING, true)
}?.apply()
}
private fun createCountDownTimer(remaining: Long): CountDownTimer {
return object : CountDownTimer(remaining, 1000) {
override fun onTick(millisUntilFinished: Long) {
val progress = millisUntilFinished.toFloat() / durationMillis
// Persist remaining time
persistState(millisUntilFinished, durationMillis)
// Color interpolation: green → yellow at 50% → red at end
val green = android.graphics.Color.parseColor("#2E7D32")
val yellow = android.graphics.Color.parseColor("#FFC107")
val red = android.graphics.Color.parseColor("#F44336")
val ringColor = when {
progress >= 0.5f -> {
val t = (progress - 0.5f) * 2f
ColorUtils.blendARGB(green, yellow, t)
}
else -> {
val t = progress * 2f
ColorUtils.blendARGB(yellow, red, t)
}
}
val secondsLeft = (millisUntilFinished / 1000).toInt() + 1
// Warning beeps and vibration in last 3 seconds
if (secondsLeft >= 1 && secondsLeft <= 3) {
toneGenerator?.startTone(ToneGenerator.TONE_PROP_BEEP)
vibrator?.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE))
}
// Update notification
updateNotification(millisUntilFinished, durationMillis)
// Push state to activity
stateListener?.onTimerTick(millisUntilFinished, durationMillis, secondsLeft, ringColor)
}
override fun onFinish() {
isTimerRunning = false
prefs?.edit()?.putBoolean(KEY_TIMER_RUNNING, false)?.apply()
toneGenerator?.startTone(ToneGenerator.TONE_SUP_CONFIRM, 500)
vibrator?.vibrate(VibrationEffect.createOneShot(200, VibrationEffect.DEFAULT_AMPLITUDE))
updateNotification(0, durationMillis)
stateListener?.onTimerFinished()
}
}
}
// ---- Notification ----
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW
).apply {
description = "Countdown timer progress"
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
}
notificationManager?.createNotificationChannel(channel)
}
private fun buildNotification(remainingMillis: Long, totalMillis: Long): android.app.Notification {
val secondsRemaining = (remainingMillis / 1000).toInt()
val minutes = secondsRemaining / 60
val seconds = secondsRemaining % 60
val timeText = String.format("%02d:%02d", minutes, seconds)
val progressPercent = if (totalMillis > 0) {
((1 - remainingMillis.toFloat() / totalMillis) * 100).toInt()
} else {
0
}
val stopPendingIntent = PendingIntent.getService(
this, 0,
Intent(this, CountdownService::class.java).apply { action = ACTION_STOP },
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val restartPendingIntent = PendingIntent.getService(
this, 1,
Intent(this, CountdownService::class.java).apply { action = ACTION_RESTART },
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val activityPendingIntent = PendingIntent.getActivity(
this, 2,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Bong Hit Timer")
.setContentText(timeText)
.setSmallIcon(R.drawable.ic_timer)
.setProgress(100, progressPercent, false)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.addAction(R.drawable.ic_stop_square, "Stop", stopPendingIntent)
.addAction(R.drawable.ic_refresh_square, "Restart", restartPendingIntent)
.setContentIntent(activityPendingIntent)
.build()
}
private fun updateNotification(remainingMillis: Long, totalMillis: Long) {
notificationManager?.notify(NOTIFICATION_ID, buildNotification(remainingMillis, totalMillis))
}
}
@@ -1,8 +1,12 @@
package com.example.countdowntimer package com.example.countdowntimer
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.os.IBinder
import android.os.VibrationEffect import android.os.VibrationEffect
import android.os.Vibrator import android.os.Vibrator
import android.os.VibratorManager import android.os.VibratorManager
@@ -14,12 +18,12 @@ import com.google.android.material.chip.ChipGroup
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.view.animation.AnticipateOvershootInterpolator import android.view.animation.AnticipateOvershootInterpolator
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity(), TimerStateListener {
companion object { companion object {
private const val PREFS_NAME = "timer_prefs" private const val PREFS_NAME = "timer_prefs"
private const val KEY_SELECTED_DURATION = "selected_duration" private const val KEY_SELECTED_DURATION = "selected_duration"
private const val DEFAULT_DURATION = 10000L private const val DEFAULT_DURATION = 600000L
} }
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
@@ -32,18 +36,31 @@ class MainActivity : AppCompatActivity() {
private var lastSelectedChipId: Int = -1 private var lastSelectedChipId: Int = -1
private var pulseAnimator: ValueAnimator? = null private var pulseAnimator: ValueAnimator? = null
// Service binding
private var service: CountdownService? = null
private var serviceBound = false
private lateinit var serviceConnection: ServiceConnection
// Countdown UI views
private lateinit var cvCountdown: CountdownCanvasView
private lateinit var btnStop: View
private lateinit var btnRestart: View
private lateinit var btnDismiss: Button
private lateinit var countdownLayout: View
private lateinit var pickerLayout: View
private fun durationToChipId(duration: Long): Int = when (duration) { private fun durationToChipId(duration: Long): Int = when (duration) {
5000L -> R.id.chip5s 300000L -> R.id.chip5m
15000L -> R.id.chip15s 600000L -> R.id.chip10m
30000L -> R.id.chip30s 900000L -> R.id.chip15m
else -> R.id.chip10s else -> R.id.chip10m
} }
private fun chipIdToDuration(chipId: Int): Long = when (chipId) { private fun chipIdToDuration(chipId: Int): Long = when (chipId) {
R.id.chip5s -> 5000L R.id.chip5m -> 300000L
R.id.chip10s -> 10000L R.id.chip10m -> 600000L
R.id.chip15s -> 15000L R.id.chip15m -> 900000L
R.id.chip30s -> 30000L R.id.chip30m -> 1800000L
else -> DEFAULT_DURATION else -> DEFAULT_DURATION
} }
@@ -53,7 +70,6 @@ class MainActivity : AppCompatActivity() {
private fun onChipClicked(chipId: Int) { private fun onChipClicked(chipId: Int) {
if (chipId == lastSelectedChipId) { if (chipId == lastSelectedChipId) {
// Re-selection: provide feedback so user knows the selection is registered
vibrator.vibrate(VibrationEffect.createOneShot(15, VibrationEffect.DEFAULT_AMPLITUDE)) vibrator.vibrate(VibrationEffect.createOneShot(15, VibrationEffect.DEFAULT_AMPLITUDE))
val originalText = btnDurationLabel.text val originalText = btnDurationLabel.text
btnDurationLabel.text = "${selectedDuration / 1000}s!" btnDurationLabel.text = "${selectedDuration / 1000}s!"
@@ -85,6 +101,65 @@ class MainActivity : AppCompatActivity() {
btnStart.alpha = 1f btnStart.alpha = 1f
} }
private fun showCountdownUI() {
pickerLayout.visibility = View.GONE
countdownLayout.visibility = View.VISIBLE
stopPulseAnimation()
}
private fun hideCountdownUI() {
countdownLayout.visibility = View.GONE
pickerLayout.visibility = View.VISIBLE
btnDismiss.visibility = View.GONE
btnStop.visibility = View.VISIBLE
btnRestart.visibility = View.VISIBLE
startPulseAnimation()
}
// ---- ServiceConnection ----
private fun initServiceConnection() {
serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
val localBinder = binder as CountdownService.LocalBinder
service = localBinder.getService()
serviceBound = true
service?.setStateListener(this@MainActivity)
if (service?.isRunning() == true) {
showCountdownUI()
}
}
override fun onServiceDisconnected(name: ComponentName?) {
serviceBound = false
service = null
}
}
}
// ---- TimerStateListener callbacks ----
override fun onTimerTick(remainingMillis: Long, totalDuration: Long, secondsLeft: Int, ringColor: Int) {
val progress = remainingMillis.toFloat() / totalDuration
cvCountdown.setProgress(progress)
cvCountdown.setColor(ringColor)
cvCountdown.setText(secondsLeft.toString())
}
override fun onTimerFinished() {
cvCountdown.setText(getString(R.string.timer_zero))
cvCountdown.setProgress(0f)
btnStop.visibility = View.GONE
btnRestart.visibility = View.GONE
btnDismiss.visibility = View.VISIBLE
}
override fun onTimerStopped() {
hideCountdownUI()
}
// ---- Activity lifecycle ----
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
@@ -92,6 +167,7 @@ class MainActivity : AppCompatActivity() {
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
selectedDuration = prefs.getLong(KEY_SELECTED_DURATION, DEFAULT_DURATION) selectedDuration = prefs.getLong(KEY_SELECTED_DURATION, DEFAULT_DURATION)
// Picker UI views
btnDurationLabel = findViewById(R.id.btnDurationLabel) btnDurationLabel = findViewById(R.id.btnDurationLabel)
hsvDurationOptions = findViewById(R.id.hsvDurationOptions) hsvDurationOptions = findViewById(R.id.hsvDurationOptions)
chipGroup = findViewById(R.id.chipGroup) chipGroup = findViewById(R.id.chipGroup)
@@ -99,6 +175,14 @@ class MainActivity : AppCompatActivity() {
val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibrator = vibratorManager.defaultVibrator vibrator = vibratorManager.defaultVibrator
// Countdown UI views
cvCountdown = findViewById(R.id.cvCountdown)
btnStop = findViewById(R.id.btnStop)
btnRestart = findViewById(R.id.btnRestart)
btnDismiss = findViewById(R.id.btnDismiss)
countdownLayout = findViewById(R.id.countdownLayout)
pickerLayout = findViewById(R.id.pickerLayout)
updateDurationLabel() updateDurationLabel()
// Restore selected chip // Restore selected chip
@@ -122,34 +206,60 @@ class MainActivity : AppCompatActivity() {
val checkedId = if (checkedIds.size > 0) checkedIds[0].toInt() else -1 val checkedId = if (checkedIds.size > 0) checkedIds[0].toInt() else -1
if (checkedId != -1) { if (checkedId != -1) {
if (checkedId != lastSelectedChipId) { if (checkedId != lastSelectedChipId) {
// New selection: update duration, save, and refresh label
selectedDuration = chipIdToDuration(checkedId) selectedDuration = chipIdToDuration(checkedId)
prefs.edit().putLong(KEY_SELECTED_DURATION, selectedDuration).apply() prefs.edit().putLong(KEY_SELECTED_DURATION, selectedDuration).apply()
updateDurationLabel() updateDurationLabel()
lastSelectedChipId = checkedId lastSelectedChipId = checkedId
} }
// Always close the panel on any selection
hsvDurationOptions.visibility = View.GONE hsvDurationOptions.visibility = View.GONE
} }
} }
// Set individual click listeners to detect re-selection findViewById<View>(R.id.chip5m).setOnClickListener { onChipClicked(R.id.chip5m) }
findViewById<View>(R.id.chip5s).setOnClickListener { onChipClicked(R.id.chip5s) } findViewById<View>(R.id.chip10m).setOnClickListener { onChipClicked(R.id.chip10m) }
findViewById<View>(R.id.chip10s).setOnClickListener { onChipClicked(R.id.chip10s) } findViewById<View>(R.id.chip15m).setOnClickListener { onChipClicked(R.id.chip15m) }
findViewById<View>(R.id.chip15s).setOnClickListener { onChipClicked(R.id.chip15s) } findViewById<View>(R.id.chip30m).setOnClickListener { onChipClicked(R.id.chip30m) }
findViewById<View>(R.id.chip30s).setOnClickListener { onChipClicked(R.id.chip30s) }
findViewById<View>(R.id.btnStart).setOnClickListener { // Start button: launch the service instead of showing a dialog
stopPulseAnimation() btnStart.setOnClickListener {
CountdownDialog(this, selectedDuration, prefs).show() val intent = Intent(this, CountdownService::class.java).apply {
action = CountdownService.ACTION_START
putExtra(CountdownService.EXTRA_DURATION, selectedDuration)
}
startService(intent)
showCountdownUI()
} }
// Countdown button handlers
btnStop.setOnClickListener { service?.stopTimer() }
btnRestart.setOnClickListener { service?.restartTimer() }
btnDismiss.setOnClickListener {
service?.stopTimer()
hideCountdownUI()
}
initServiceConnection()
startPulseAnimation() startPulseAnimation()
} }
override fun onStart() {
super.onStart()
Intent(this, CountdownService::class.java).also { intent ->
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
}
override fun onStop() {
super.onStop()
if (serviceBound) {
unbindService(serviceConnection)
serviceBound = false
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (!isFinishing && !isDestroyed) { if (!isFinishing && !isDestroyed && pickerLayout.visibility == View.VISIBLE) {
startPulseAnimation() startPulseAnimation()
} }
} }
@@ -158,4 +268,4 @@ class MainActivity : AppCompatActivity() {
super.onPause() super.onPause()
stopPulseAnimation() stopPulseAnimation()
} }
} }
@@ -0,0 +1,7 @@
package com.example.countdowntimer
interface TimerStateListener {
fun onTimerTick(remainingMillis: Long, totalDuration: Long, secondsLeft: Int, ringColor: Int)
fun onTimerFinished()
fun onTimerStopped()
}
+274 -191
View File
@@ -29,221 +29,304 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:padding="24dp"> android:padding="24dp">
<ImageView <!-- Picker UI -->
android:id="@+id/timerIcon" <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="80dp" android:id="@+id/pickerLayout"
android:layout_height="80dp"
android:src="@drawable/ic_timer"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="@color/color_icon_tint" />
<TextView
android:id="@+id/tvInstructionTitle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="16dp" android:visibility="visible"
android:gravity="center" app:layout_constraintBottom_toBottomOf="parent"
android:text="@string/instruction_title"
android:textAppearance="@style/TextAppearance.Material3.HeadlineMedium"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/timerIcon" /> app:layout_constraintTop_toTopOf="parent">
<LinearLayout <ImageView
android:id="@+id/instructionsLayout" android:id="@+id/timerIcon"
android:layout_width="wrap_content" android:layout_width="80dp"
android:layout_height="wrap_content" android:layout_height="80dp"
android:layout_marginTop="24dp" android:src="@drawable/ic_timer"
android:orientation="vertical" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvInstructionTitle"> app:tint="@color/color_icon_tint" />
<LinearLayout <TextView
android:id="@+id/tvInstructionTitle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical" android:layout_marginTop="16dp"
android:orientation="horizontal" android:gravity="center"
android:padding="8dp"> android:text="@string/instruction_title"
android:textAppearance="@style/TextAppearance.Material3.HeadlineMedium"
<ImageView android:textStyle="bold"
android:layout_width="24dp" app:layout_constraintEnd_toEndOf="parent"
android:layout_height="24dp" app:layout_constraintStart_toStartOf="parent"
android:src="@drawable/outline_fire_check_24" app:layout_constraintTop_toBottomOf="@+id/timerIcon" />
app:tint="@color/color_icon_tint" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/instruction_volume"
android:textSize="16sp" />
</LinearLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:id="@+id/instructionsLayout"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/outline_air_24"
app:tint="@color/color_icon_tint" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/instruction_position"
android:textSize="16sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_play_arrow"
app:tint="@color/color_icon_tint" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/instruction_distractions"
android:textSize="16sp" />
</LinearLayout>
</LinearLayout>
<Button
android:id="@+id/btnDurationLabel"
android:layout_width="180dp"
android:layout_height="80dp"
android:layout_marginTop="32dp"
android:background="@drawable/btn_duration_selector"
android:drawableEnd="@drawable/ic_keyboard_arrow_down"
android:drawablePadding="8dp"
android:gravity="center"
android:includeFontPadding="false"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:text="@string/start_timer"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/instructionsLayout" />
<HorizontalScrollView
android:id="@+id/hsvDurationOptions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:scrollbars="none"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btnDurationLabel">
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroup"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:singleSelection="true"> android:layout_marginTop="24dp"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvInstructionTitle">
<com.google.android.material.chip.Chip <LinearLayout
android:id="@+id/chip5s" android:layout_width="match_parent"
style="@style/Widget.Material3.Chip.Suggestion.Elevated" android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/outline_fire_check_24"
app:tint="@color/color_icon_tint" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/instruction_volume"
android:textSize="16sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/outline_air_24"
app:tint="@color/color_icon_tint" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/instruction_position"
android:textSize="16sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_play_arrow"
app:tint="@color/color_icon_tint" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/instruction_distractions"
android:textSize="16sp" />
</LinearLayout>
</LinearLayout>
<Button
android:id="@+id/btnDurationLabel"
android:layout_width="180dp"
android:layout_height="80dp"
android:layout_marginTop="32dp"
android:background="@drawable/btn_duration_selector"
android:drawableEnd="@drawable/ic_keyboard_arrow_down"
android:drawablePadding="8dp"
android:gravity="center"
android:includeFontPadding="false"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:text="@string/start_timer"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/instructionsLayout" />
<HorizontalScrollView
android:id="@+id/hsvDurationOptions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:scrollbars="none"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btnDurationLabel">
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroup"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:minWidth="56dp" app:singleSelection="true">
android:text="5s"
app:chipBackgroundColor="@color/chip_duration_bg"
app:chipStrokeColor="@color/chip_duration_stroke"
app:chipStrokeWidth="2dp"
android:textColor="@color/chip_duration_text" />
<com.google.android.material.chip.Chip <com.google.android.material.chip.Chip
android:id="@+id/chip10s" android:id="@+id/chip5m"
style="@style/Widget.Material3.Chip.Suggestion.Elevated" style="@style/Widget.Material3.Chip.Suggestion.Elevated"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:minWidth="56dp" android:minWidth="56dp"
android:text="10s" android:text="5m"
app:chipBackgroundColor="@color/chip_duration_bg" android:textColor="@color/chip_duration_text"
app:chipStrokeColor="@color/chip_duration_stroke" app:chipBackgroundColor="@color/chip_duration_bg"
app:chipStrokeWidth="2dp" app:chipStrokeColor="@color/chip_duration_stroke"
android:textColor="@color/chip_duration_text" /> app:chipStrokeWidth="2dp" />
<com.google.android.material.chip.Chip <com.google.android.material.chip.Chip
android:id="@+id/chip15s" android:id="@+id/chip10m"
style="@style/Widget.Material3.Chip.Suggestion.Elevated" style="@style/Widget.Material3.Chip.Suggestion.Elevated"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:minWidth="56dp" android:minWidth="56dp"
android:text="15s" android:text="10m"
app:chipBackgroundColor="@color/chip_duration_bg" android:textColor="@color/chip_duration_text"
app:chipStrokeColor="@color/chip_duration_stroke" app:chipBackgroundColor="@color/chip_duration_bg"
app:chipStrokeWidth="2dp" app:chipStrokeColor="@color/chip_duration_stroke"
android:textColor="@color/chip_duration_text" /> app:chipStrokeWidth="2dp" />
<com.google.android.material.chip.Chip <com.google.android.material.chip.Chip
android:id="@+id/chip30s" android:id="@+id/chip15m"
style="@style/Widget.Material3.Chip.Suggestion.Elevated" style="@style/Widget.Material3.Chip.Suggestion.Elevated"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:minWidth="56dp" android:minWidth="56dp"
android:text="30s" android:text="15m"
app:chipBackgroundColor="@color/chip_duration_bg" android:textColor="@color/chip_duration_text"
app:chipStrokeColor="@color/chip_duration_stroke" app:chipBackgroundColor="@color/chip_duration_bg"
app:chipStrokeWidth="2dp" app:chipStrokeColor="@color/chip_duration_stroke"
android:textColor="@color/chip_duration_text" /> app:chipStrokeWidth="2dp" />
</com.google.android.material.chip.ChipGroup>
</HorizontalScrollView>
<View <com.google.android.material.chip.Chip
android:id="@+id/spacerBelowChips" android:id="@+id/chip30m"
android:layout_width="1dp" style="@style/Widget.Material3.Chip.Suggestion.Elevated"
android:layout_height="5dp" android:layout_width="wrap_content"
android:layout_marginTop="8dp" android:layout_height="wrap_content"
android:minWidth="56dp"
android:text="30m"
android:textColor="@color/chip_duration_text"
app:chipBackgroundColor="@color/chip_duration_bg"
app:chipStrokeColor="@color/chip_duration_stroke"
app:chipStrokeWidth="2dp" />
</com.google.android.material.chip.ChipGroup>
</HorizontalScrollView>
<View
android:id="@+id/spacerBelowChips"
android:layout_width="1dp"
android:layout_height="5dp"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/hsvDurationOptions" />
<FrameLayout
android:id="@+id/btnStart"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_marginTop="16dp"
android:background="@drawable/btn_start_selector"
android:clickable="true"
android:focusable="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/spacerBelowChips">
<androidx.appcompat.widget.AppCompatImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:contentDescription="Start"
android:padding="20dp"
android:src="@drawable/ic_play_arrow"
android:tint="@android:color/white" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Countdown UI (initially hidden) -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/countdownLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/hsvDurationOptions" /> app:layout_constraintTop_toTopOf="parent">
<FrameLayout <com.example.countdowntimer.CountdownCanvasView
android:id="@+id/btnStart" android:id="@+id/cvCountdown"
android:layout_width="100dp" android:layout_width="0dp"
android:layout_height="100dp" android:layout_height="0dp"
android:layout_marginTop="16dp" app:layout_constraintDimensionRatio="1:1"
android:background="@drawable/btn_start_selector" app:layout_constraintEnd_toEndOf="parent"
android:clickable="true" app:layout_constraintStart_toStartOf="parent"
android:focusable="true" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintWidth_percent="0.9" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/spacerBelowChips">
<androidx.appcompat.widget.AppCompatImageView <LinearLayout
android:layout_width="match_parent" android:id="@+id/buttonRow"
android:layout_height="match_parent" android:layout_width="wrap_content"
android:layout_gravity="center" android:layout_height="wrap_content"
android:contentDescription="Start" android:layout_marginTop="24dp"
android:padding="20dp" android:layout_marginBottom="24dp"
android:src="@drawable/ic_play_arrow" android:gravity="center"
android:tint="@android:color/white" /> android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/cvCountdown">
</FrameLayout> <FrameLayout
android:id="@+id/btnStop"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_marginEnd="16dp"
android:background="@drawable/btn_stop_selector"
android:clickable="true"
android:focusable="true" />
<FrameLayout
android:id="@+id/btnRestart"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="@drawable/btn_restart_selector"
android:clickable="true"
android:focusable="true" />
</LinearLayout>
<Button
android:id="@+id/btnDismiss"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_marginBottom="24dp"
android:background="@drawable/btn_duration_selector"
android:text="Dismiss"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/cvCountdown" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
@@ -1,83 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#80000000">
<com.example.countdowntimer.CountdownCanvasView
android:id="@+id/cvCountdown"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/buttonRow"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:id="@+id/buttonRow"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="48dp"
android:gravity="center"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<FrameLayout
android:id="@+id/btnStop"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_marginEnd="16dp"
android:background="@drawable/btn_stop_selector"
android:clickable="true"
android:focusable="true">
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:contentDescription="@string/stop_timer"
android:src="@drawable/ic_stop_square" />
</FrameLayout>
<FrameLayout
android:id="@+id/btnRestart"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_marginStart="16dp"
android:background="@drawable/btn_restart_selector"
android:clickable="true"
android:focusable="true">
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:contentDescription="@string/restart_timer"
android:src="@drawable/ic_refresh_square" />
</FrameLayout>
</LinearLayout>
<Button
android:id="@+id/btnDismiss"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="@drawable/btn_duration_selector"
android:gravity="center"
android:padding="16dp"
android:text="Dismiss"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:textStyle="bold"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>