Compare commits

...
6 Commits
Author SHA1 Message Date
kfj001 74c822484d string alterations 2026-06-28 08:30:06 +00:00
kfj001 d7ce314c22 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
2026-06-23 01:49:11 +00:00
kfj001 da29e51993 stable, alpine based devcontainer 2026-06-22 23:20:44 +00:00
kfj001 7a91ecba18 visual polish: add pulsing start button and color-shifting progress ring
- Countdown ring interpolates green → yellow → red as timer progresses
- Start button pulses continuously to draw attention
- Pulse stops while dialog is shown and restarts on resume
2026-05-30 10:51:36 -07:00
kfj001 97b01437b2 bug fixes 2026-05-29 20:30:41 -07:00
kfj001 f87d23a922 removes unneeded file 2026-05-28 22:59:26 -07:00
16 changed files with 815 additions and 520 deletions
+26 -24
View File
@@ -1,31 +1,33 @@
FROM mcr.microsoft.com/devcontainers/java:dev-17-jdk-trixie FROM ghcr.io/alvr/alpine-android:android-34-jdk21
# Install Android SDK command-line tools # Install Android SDK command-line tools
# ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708 # ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708
ARG ANDROID_BUILD_TOOLS_VERSION=34.0.0 # ARG ANDROID_BUILD_TOOLS_VERSION=34.0.0
ARG ANDROID_SDK_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" # ARG ANDROID_SDK_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
RUN mkdir -p /usr/local/android-sdk/cmdline-tools && \ # RUN apk update && apk add curl zip git openjdk21-jdk cmake gcc
cd /tmp && \
curl -fsSL "$ANDROID_SDK_URL" -o cmdline-tools.zip && \
unzip -q cmdline-tools.zip -d /usr/local/android-sdk/cmdline-tools && \
mv /usr/local/android-sdk/cmdline-tools/cmdline-tools /usr/local/android-sdk/cmdline-tools/latest && \
rm cmdline-tools.zip
# Set Android SDK paths and environment # RUN mkdir -p /usr/local/android-sdk/cmdline-tools && \
ENV ANDROID_HOME=/usr/local/android-sdk # cd /tmp && \
ENV ANDROID_COMMAND_LINE_TOOL=/usr/local/android-sdk/cmdline-tools/latest/bin # curl -fsSL "$ANDROID_SDK_URL" -o cmdline-tools.zip && \
ENV PATH=${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH} # unzip -q cmdline-tools.zip -d /usr/local/android-sdk/cmdline-tools && \
# mv /usr/local/android-sdk/cmdline-tools/cmdline-tools /usr/local/android-sdk/cmdline-tools/latest && \
# rm cmdline-tools.zip
# Accept licenses and install required SDK packages # # Set Android SDK paths and environment
RUN mkdir -p $ANDROID_HOME/.android && \ # ENV ANDROID_HOME=/usr/local/android-sdk
touch $ANDROID_HOME/.android/repositories.cfg && \ # ENV ANDROID_COMMAND_LINE_TOOL=/usr/local/android-sdk/cmdline-tools/latest/bin
yes | ${ANDROID_COMMAND_LINE_TOOL}/sdkmanager --licenses 2>/dev/null || true && \ # ENV PATH=${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}
${ANDROID_COMMAND_LINE_TOOL}/sdkmanager \
"platforms;android-34" \
"build-tools;33.0.1" \
"platform-tools" \
2>&1 | tee /dev/null
# Grant execute permission to gradlew # # Accept licenses and install required SDK packages
RUN chmod +x /workspaces/*/gradlew 2>/dev/null || true # RUN mkdir -p $ANDROID_HOME/.android && \
# touch $ANDROID_HOME/.android/repositories.cfg && \
# yes | ${ANDROID_COMMAND_LINE_TOOL}/sdkmanager --licenses 2>/dev/null || true && \
# ${ANDROID_COMMAND_LINE_TOOL}/sdkmanager \
# "platforms;android-34" \
# "build-tools;33.0.1" \
# "platform-tools" \
# 2>&1 | tee /dev/null
# # Grant execute permission to gradlew
# RUN chmod +x /workspaces/*/gradlew 2>/dev/null || true
+4 -1
View File
@@ -7,7 +7,10 @@
"redhat.vscode-xml", "redhat.vscode-xml",
"redhat.vscode-yaml", "redhat.vscode-yaml",
"felipecaslazana.vscode-android-logcat", "felipecaslazana.vscode-android-logcat",
"ms-azuretools.vscode-docker" "ms-azuretools.vscode-docker",
"fwcd.kotlin",
"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>
@@ -47,6 +47,13 @@ class CountdownCanvasView @JvmOverloads constructor(
invalidate() invalidate()
} }
fun setColor(color: Int) {
if (ringPaint.color != color) {
ringPaint.color = color
invalidate()
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh) super.onSizeChanged(w, h, oldw, oldh)
val inset = ringPaint.strokeWidth / 2f + 12f val inset = ringPaint.strokeWidth / 2f + 12f
@@ -1,113 +0,0 @@
package com.example.countdowntimer
import android.app.Dialog
import android.content.Context
import android.content.SharedPreferences
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)
timer = object : CountDownTimer(durationMillis, 1000) {
override fun onTick(millisUntilFinished: Long) {
val progress = millisUntilFinished.toFloat() / durationMillis
cvCountdown.setProgress(progress)
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,40 +1,66 @@
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.Vibrator
import android.os.VibratorManager
import android.view.View import android.view.View
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Button import android.widget.Button
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.chip.ChipGroup import com.google.android.material.chip.ChipGroup
import android.animation.ValueAnimator
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
private lateinit var btnDurationLabel: Button private lateinit var btnDurationLabel: Button
private lateinit var hsvDurationOptions: View private lateinit var hsvDurationOptions: View
private lateinit var chipGroup: ChipGroup private lateinit var chipGroup: ChipGroup
private lateinit var btnStart: View
private lateinit var vibrator: Vibrator
private var selectedDuration: Long = DEFAULT_DURATION private var selectedDuration: Long = DEFAULT_DURATION
private var lastSelectedChipId: Int = -1
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
} }
@@ -42,6 +68,98 @@ class MainActivity : AppCompatActivity() {
btnDurationLabel.text = "${selectedDuration / 1000}s" btnDurationLabel.text = "${selectedDuration / 1000}s"
} }
private fun onChipClicked(chipId: Int) {
if (chipId == lastSelectedChipId) {
vibrator.vibrate(VibrationEffect.createOneShot(15, VibrationEffect.DEFAULT_AMPLITUDE))
val originalText = btnDurationLabel.text
btnDurationLabel.text = "${selectedDuration / 1000}s!"
btnDurationLabel.postDelayed({ btnDurationLabel.text = originalText }, 300)
}
}
private fun startPulseAnimation() {
pulseAnimator = ValueAnimator.ofFloat(1f, 0.78f, 1f).apply {
duration = 1500
repeatMode = ValueAnimator.REVERSE
repeatCount = ValueAnimator.INFINITE
interpolator = AnticipateOvershootInterpolator(1f)
addUpdateListener { animation ->
val fraction = animation.animatedValue as Float
btnStart.scaleX = fraction
btnStart.scaleY = fraction
btnStart.alpha = fraction
}
}
pulseAnimator?.start()
}
private fun stopPulseAnimation() {
pulseAnimator?.cancel()
pulseAnimator = null
btnStart.scaleX = 1f
btnStart.scaleY = 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)
@@ -49,9 +167,21 @@ 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)
btnStart = findViewById(R.id.btnStart)
val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
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()
@@ -72,17 +202,70 @@ class MainActivity : AppCompatActivity() {
} }
} }
chipGroup.setOnCheckedChangeListener { _, checkedId -> chipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
val checkedId = if (checkedIds.size > 0) checkedIds[0].toInt() else -1
if (checkedId != -1) { if (checkedId != -1) {
selectedDuration = chipIdToDuration(checkedId) if (checkedId != lastSelectedChipId) {
prefs.edit().putLong(KEY_SELECTED_DURATION, selectedDuration).apply() selectedDuration = chipIdToDuration(checkedId)
updateDurationLabel() prefs.edit().putLong(KEY_SELECTED_DURATION, selectedDuration).apply()
updateDurationLabel()
lastSelectedChipId = checkedId
}
hsvDurationOptions.visibility = View.GONE hsvDurationOptions.visibility = View.GONE
} }
} }
findViewById<View>(R.id.btnStart).setOnClickListener { findViewById<View>(R.id.chip5m).setOnClickListener { onChipClicked(R.id.chip5m) }
CountdownDialog(this, selectedDuration, prefs).show() findViewById<View>(R.id.chip10m).setOnClickListener { onChipClicked(R.id.chip10m) }
findViewById<View>(R.id.chip15m).setOnClickListener { onChipClicked(R.id.chip15m) }
findViewById<View>(R.id.chip30m).setOnClickListener { onChipClicked(R.id.chip30m) }
// Start button: launch the service instead of showing a dialog
btnStart.setOnClickListener {
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()
}
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() {
super.onResume()
if (!isFinishing && !isDestroyed && pickerLayout.visibility == View.VISIBLE) {
startPulseAnimation()
}
}
override fun onPause() {
super.onPause()
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()
}
+2 -2
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"> <selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:color="#81C784" /> <item android:state_checked="true" android:color="#2E7D32" />
<item android:color="#E8E8E8" /> <item android:color="#233024" />
</selector> </selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:color="#C8E6C9" />
<item android:color="#2E7D32" />
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:color="#FFFFFF" />
<item android:color="#C8E6C9" />
</selector>
+274 -180
View File
@@ -29,210 +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" />
<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:checked="true" android:minWidth="56dp"
android:minWidth="56dp" android:text="5m"
android:text="10s" android:textColor="@color/chip_duration_text"
app:chipBackgroundColor="@color/chip_duration_bg" /> app:chipBackgroundColor="@color/chip_duration_bg"
app:chipStrokeColor="@color/chip_duration_stroke"
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:chipBackgroundColor="@color/chip_duration_bg"
app:chipStrokeColor="@color/chip_duration_stroke"
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"
</com.google.android.material.chip.ChipGroup> app:chipBackgroundColor="@color/chip_duration_bg"
</HorizontalScrollView> app:chipStrokeColor="@color/chip_duration_stroke"
app:chipStrokeWidth="2dp" />
<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>
+4 -4
View File
@@ -1,12 +1,12 @@
<resources> <resources>
<string name="app_name">Countdown Timer</string> <string name="app_name">Countdown Timer</string>
<string name="start_timer">Start Timer</string> <string name="start_timer">Start Timer</string>
<string name="countdown_text">!!! HOLD !!!</string> <string name="countdown_text">0</string>
<string name="timer_zero">EXHALE!</string> <string name="timer_zero">AGAIN!</string>
<string name="instruction_title">BONG HIT TIMER!</string> <string name="instruction_title">BONG HIT TIMER!</string>
<string name="instruction_volume">Light your bowl</string> <string name="instruction_volume">When the timer elapses</string>
<string name="instruction_position">Take your hit</string> <string name="instruction_position">Take your hit</string>
<string name="instruction_distractions">Press Start to time how long to hold it!</string> <string name="instruction_distractions">Press Start to start the countdown to a bong load!</string>
<string name="pick_duration">Pick Duration</string> <string name="pick_duration">Pick Duration</string>
<string name="settings">Settings</string> <string name="settings">Settings</string>
<string name="vibration">Vibration</string> <string name="vibration">Vibration</string>
-88
View File
@@ -1,88 +0,0 @@
# Goal: Redesign all buttons into bold, square, visually striking interactive elements
## Objective
Replace every basic/rectangular button in the app with larger, square-aspect-ratio buttons that have eye-catching visual design. The goal is to transform the UI from a generic Material 3 look into a distinctive, personality-rich interface that matches the playful "BONG HIT TIMER" brand.
## Current State
The app uses 4 visible buttons across 2 screens:
**MainActivity (home screen):**
- `btnDurationLabel` — outlined button showing selected duration (e.g. "10s") with a dropdown arrow. Uses `Widget.Material3.Button.OutlinedButton` with 20dp corners.
- `btnStart` — filled button labeled "Start Timer" with a play icon. Uses `Widget.Material3.Button` with 20dp corners.
**CountdownDialog (timer screen):**
- `btnStop` — plain Android `Button` labeled "Stop" (default styling, small)
- `btnRestart` — plain Android `Button` labeled "Restart" (default styling, small)
- `btnDismiss` — plain Android `Button` labeled "Dismiss" (currently gone, shows after timer completes)
## Design Requirements
### 1. Square Aspect Ratio
- Change all buttons from `wrap_content` rectangles to a fixed square size (e.g., `120dp` x `120dp` or `100dp` x `100dp`)
- Use `app:layout_constraintDimensionRatio="1:1"` where dynamic sizing is needed
- Buttons should feel substantial and tappable, not compact
### 2. Visual Interest — Each button should have its own personality
Create custom drawable backgrounds for each button type. Design ideas (pick the best approach):
- **Circular/spherical buttons** — Buttons that look like physical 3D balls or pucks. Use gradient fills with highlights/shadows to create a convex "pressable" look.
- **Neon/glow effect** — Since the app is a bong hit timer, a subtle neon glow that pulses or intensifies during countdown would be thematically appropriate.
- **Retro/gaming arcade style** — Bold colors, thick borders, pressed/active states that simulate physical button depression.
- **Gradient + icon centering** — For the timer screen, center the stop/restart icons prominently in the square buttons with bold gradient backgrounds.
### 3. Interactive States
Every button must have rich feedback:
- **Pressed/active state** — noticeable visual change (shrink, darker shade, or inner glow)
- **Default state** — visually engaging with gradients, shadows, or subtle animations
- **Disabled state** — reduced opacity but still recognizable
### 4. Icon Integration
Square buttons are perfect for icon-first design:
- **Stop button** — large centered pause/stop icon
- **Restart button** — large centered refresh/replay icon
- **Start button** — center the play icon, consider removing the text label since the icon is universal
- **Duration label** — consider a pill-shaped or circular chip that pulses when tapped
### 5. Color Palette
Stay within the existing green theme (`#2E7D32` primary, `#4C6842` secondary, `#E8F5E9`/`#C8E6C9` gradient) but push it further:
- Use the primary green for the start/positive actions
- A red/coral accent for stop
- A neutral or blue for restart/duration selection
- Consider gradient transitions within each button (e.g., green to darker green top-to-bottom for a 3D effect)
### 6. Shadow & Elevation
- Add drop shadows to all buttons for depth (use `app:strokeWidth="0dp"` and custom `rippleColor` for Material buttons)
- Create a `btn_square_default.xml` and `btn_square_pressed.xml` in `res/drawable/` for each button type
- The pressed state should visually simulate "pressing in" (reduced shadow, lighter/darker fill)
## Technical Implementation
### New drawable files to create (in `res/drawable/`):
- `btn_start_square.xml` — gradient + shadow drawable for start button
- `btn_start_square_pressed.xml` — pressed state variant
- `btn_stop_square.xml` — gradient + shadow drawable for stop button
- `btn_stop_square_pressed.xml` — pressed state variant
- `btn_restart_square.xml` — gradient + shadow drawable for restart button
- `btn_restart_square_pressed.xml` — pressed state variant
- `btn_duration_chip.xml` — drawable for duration selector
### Layout changes needed:
- `activity_main.xml`:
- `btnDurationLabel`: change to square dimensions, wrap text/icon in center, apply new drawable
- `btnStart`: change to square, center play icon, apply new drawable
- `dialog_countdown.xml`:
- `btnStop`: change from `Button` to `com.google.android.material.button.MaterialButton`, set square dimensions, apply drawable
- `btnRestart`: same treatment
- `btnDismiss`: same treatment, keep as gone by default
- Adjust the ConstraintLayout chain to accommodate square buttons (may need a horizontal chain with proper spacing)
### Code changes needed:
- `CountdownDialog.kt`: Set button backgrounds programmatically if needed, ensure clickable/pressable states work
- No changes to button click logic — only visual overhaul
## Constraints
- Use only Material 3 components already in the dependency (`com.google.android.material:material:1.11.0`)
- Traditional XML layouts — no Compose
- Minimum SDK 24 — no API 30+ features
- Keep the app's existing green aesthetic but make it bolder
- Maintain accessibility (minimum 48dp touch target, sufficient contrast)