Compare commits

..
2 Commits
8 changed files with 210 additions and 23 deletions
+1 -2
View File
@@ -1,6 +1,6 @@
<?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" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/ic_timer" android:icon="@drawable/ic_timer"
@@ -16,5 +16,4 @@
</intent-filter> </intent-filter>
</activity> </activity>
</application> </application>
</manifest> </manifest>
@@ -4,6 +4,7 @@ import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
@@ -14,12 +15,24 @@ class CountdownCanvasView @JvmOverloads constructor(
) : View(context, attrs, defStyleAttr) { ) : View(context, attrs, defStyleAttr) {
private var textToDraw: String = "" private var textToDraw: String = ""
private var progressFraction: Float = 1.0f
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#6750A4") // Using the same primary purple color = Color.parseColor("#6750A4")
textSize = 180f // Roughly equivalent to 72sp depending on density textSize = 180f
textAlign = Paint.Align.CENTER textAlign = Paint.Align.CENTER
isFakeBoldText = false isFakeBoldText = false
} }
private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#6750A4")
style = Paint.Style.STROKE
strokeWidth = 16f
}
private val ringBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#D0BCFF")
style = Paint.Style.STROKE
strokeWidth = 16f
}
private val rectF = RectF()
fun setText(text: String) { fun setText(text: String) {
if (textToDraw != text) { if (textToDraw != text) {
@@ -28,15 +41,34 @@ class CountdownCanvasView @JvmOverloads constructor(
} }
} }
fun setProgress(fraction: Float) {
progressFraction = fraction.coerceIn(0f, 1f)
invalidate()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
val inset = ringPaint.strokeWidth / 2f + 12f
rectF.set(inset, inset, w - inset, h - inset)
}
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
super.onDraw(canvas) super.onDraw(canvas)
if (textToDraw.isEmpty()) return if (textToDraw.isEmpty() || rectF.width() <= 0f) return
val cx = rectF.centerX()
val cy = rectF.centerY()
// Draw full background ring (always visible track)
val bgRect = RectF(rectF.left, rectF.top, rectF.right, rectF.bottom)
canvas.drawArc(bgRect, -90f, 360f, false, ringBgPaint)
// Draw progress arc on top (remaining time, starts at 12 o'clock)
canvas.drawArc(rectF, -90f, 360f * progressFraction, false, ringPaint)
// Draw text
val fontMetrics = paint.fontMetrics val fontMetrics = paint.fontMetrics
val x = width / 2f val textY = (cy - (fontMetrics.descent + fontMetrics.ascent) / 2f)
val y = (height / 2f) - ((fontMetrics.descent + fontMetrics.ascent) / 2f) canvas.drawText(textToDraw, cx, textY, paint)
canvas.drawText(textToDraw, x, y, paint)
} }
} }
@@ -2,21 +2,28 @@ package com.example.countdowntimer
import android.app.Dialog import android.app.Dialog
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.media.AudioManager import android.media.AudioManager
import android.media.ToneGenerator import android.media.ToneGenerator
import android.os.Bundle import android.os.Bundle
import android.os.CountDownTimer import android.os.CountDownTimer
import android.os.Vibrator
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.Button import android.widget.Button
class CountdownDialog(private val context: Context, private val durationMillis: Long) : Dialog(context) { class CountdownDialog(
private val context: Context,
private val durationMillis: Long,
private val prefs: SharedPreferences
) : Dialog(context) {
private lateinit var cvCountdown: CountdownCanvasView private lateinit var cvCountdown: CountdownCanvasView
private lateinit var btnStop: Button private lateinit var btnStop: Button
private lateinit var btnRestart: Button private lateinit var btnRestart: Button
private lateinit var btnDismiss: Button private lateinit var btnDismiss: Button
private var timer: CountDownTimer? = null private var timer: CountDownTimer? = null
private var toneGenerator: ToneGenerator? = null private var toneGenerator: ToneGenerator? = null
private var vibrator: Vibrator? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -38,6 +45,7 @@ class CountdownDialog(private val context: Context, private val durationMillis:
btnRestart = findViewById(R.id.btnRestart) btnRestart = findViewById(R.id.btnRestart)
btnDismiss = findViewById(R.id.btnDismiss) btnDismiss = findViewById(R.id.btnDismiss)
toneGenerator = ToneGenerator(AudioManager.STREAM_ALARM, 100) toneGenerator = ToneGenerator(AudioManager.STREAM_ALARM, 100)
vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
btnStop.setOnClickListener { cancelTimer() } btnStop.setOnClickListener { cancelTimer() }
btnRestart.setOnClickListener { restartTimer() } btnRestart.setOnClickListener { restartTimer() }
@@ -47,22 +55,34 @@ class CountdownDialog(private val context: Context, private val durationMillis:
} }
private fun startTimer() { private fun startTimer() {
cvCountdown.setProgress(1f)
timer = object : CountDownTimer(durationMillis, 1000) { timer = object : CountDownTimer(durationMillis, 1000) {
override fun onTick(millisUntilFinished: Long) { override fun onTick(millisUntilFinished: Long) {
val secondsLeft = (millisUntilFinished / 1000).toInt() + 1 val progress = millisUntilFinished.toFloat() / durationMillis
if (secondsLeft < 10) { cvCountdown.setProgress(progress)
cvCountdown.setText(secondsLeft.toString())
} else { if (millisUntilFinished >= durationMillis) {
cvCountdown.setText(context.getString(R.string.countdown_text)) cvCountdown.setText(context.getString(R.string.countdown_text))
} else {
val secondsLeft = (millisUntilFinished / 1000).toInt() + 1
cvCountdown.setText(secondsLeft.toString())
} }
if (secondsLeft in 1..3) {
if (millisUntilFinished <= 3000 && millisUntilFinished > 1000) {
playTone(ToneGenerator.TONE_PROP_BEEP) playTone(ToneGenerator.TONE_PROP_BEEP)
if (prefs.getBoolean("vibration", true)) {
vibrator?.vibrate(50)
}
} }
} }
override fun onFinish() { override fun onFinish() {
cvCountdown.setText(context.getString(R.string.timer_zero)) cvCountdown.setText(context.getString(R.string.timer_zero))
cvCountdown.setProgress(0f)
playTone(ToneGenerator.TONE_SUP_CONFIRM, 500) playTone(ToneGenerator.TONE_SUP_CONFIRM, 500)
if (prefs.getBoolean("vibration", true)) {
vibrator?.vibrate(200)
}
btnStop.visibility = View.GONE btnStop.visibility = View.GONE
btnRestart.visibility = View.GONE btnRestart.visibility = View.GONE
btnDismiss.visibility = View.VISIBLE btnDismiss.visibility = View.VISIBLE
@@ -1,18 +1,88 @@
package com.example.countdowntimer package com.example.countdowntimer
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.view.View
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
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private val TIMER_DURATION_MS = 10000L
companion object {
private const val PREFS_NAME = "timer_prefs"
private const val KEY_SELECTED_DURATION = "selected_duration"
private const val DEFAULT_DURATION = 10000L
}
private lateinit var prefs: SharedPreferences
private lateinit var btnDurationLabel: Button
private lateinit var hsvDurationOptions: View
private lateinit var chipGroup: ChipGroup
private var selectedDuration: Long = DEFAULT_DURATION
private fun durationToChipId(duration: Long): Int = when (duration) {
5000L -> R.id.chip5s
15000L -> R.id.chip15s
30000L -> R.id.chip30s
else -> R.id.chip10s
}
private fun chipIdToDuration(chipId: Int): Long = when (chipId) {
R.id.chip5s -> 5000L
R.id.chip10s -> 10000L
R.id.chip15s -> 15000L
R.id.chip30s -> 30000L
else -> DEFAULT_DURATION
}
private fun updateDurationLabel() {
btnDurationLabel.text = "${selectedDuration / 1000}s"
}
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)
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
selectedDuration = prefs.getLong(KEY_SELECTED_DURATION, DEFAULT_DURATION)
btnDurationLabel = findViewById(R.id.btnDurationLabel)
hsvDurationOptions = findViewById(R.id.hsvDurationOptions)
chipGroup = findViewById(R.id.chipGroup)
updateDurationLabel()
// Restore selected chip
val chipId = durationToChipId(selectedDuration)
chipGroup.check(chipId)
btnDurationLabel.setOnClickListener {
if (hsvDurationOptions.visibility == View.GONE) {
hsvDurationOptions.visibility = View.VISIBLE
} else {
hsvDurationOptions.visibility = View.GONE
(currentFocus ?: btnDurationLabel).let { focused ->
focused.clearFocus()
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(hsvDurationOptions.windowToken, 0)
}
}
}
chipGroup.setOnCheckedChangeListener { _, checkedId ->
if (checkedId != -1) {
selectedDuration = chipIdToDuration(checkedId)
prefs.edit().putLong(KEY_SELECTED_DURATION, selectedDuration).apply()
updateDurationLabel()
hsvDurationOptions.visibility = View.GONE
}
}
findViewById<Button>(R.id.btnStart).setOnClickListener { findViewById<Button>(R.id.btnStart).setOnClickListener {
CountdownDialog(this, TIMER_DURATION_MS).show() CountdownDialog(this, selectedDuration, prefs).show()
} }
} }
} }
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#6750A4"
android:pathData="M7.41,8.59L12,13.17l4.59,-4.58L18,10l-6,6 -6,-6z"/>
</vector>
+56 -1
View File
@@ -110,12 +110,67 @@
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDurationLabel"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="@string/start_timer"
android:drawableEnd="@drawable/ic_keyboard_arrow_down"
app:cornerRadius="20dp" />
<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">
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:singleSelection="true">
<com.google.android.material.chip.Chip
android:id="@+id/chip5s"
style="@style/Widget.Material3.Chip.Suggestion.Elevated"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="5s" />
<com.google.android.material.chip.Chip
android:id="@+id/chip10s"
style="@style/Widget.Material3.Chip.Suggestion.Elevated"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:text="10s" />
<com.google.android.material.chip.Chip
android:id="@+id/chip15s"
style="@style/Widget.Material3.Chip.Suggestion.Elevated"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="15s" />
<com.google.android.material.chip.Chip
android:id="@+id/chip30s"
style="@style/Widget.Material3.Chip.Suggestion.Elevated"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="30s" />
</com.google.android.material.chip.ChipGroup>
</HorizontalScrollView>
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnStart" android:id="@+id/btnStart"
style="@style/Widget.Material3.Button" style="@style/Widget.Material3.Button"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="32dp" android:layout_marginTop="16dp"
android:text="@string/start_timer" android:text="@string/start_timer"
app:cornerRadius="20dp" app:cornerRadius="20dp"
app:icon="@drawable/ic_play_arrow" /> app:icon="@drawable/ic_play_arrow" />
+3 -5
View File
@@ -18,16 +18,14 @@
<com.example.countdowntimer.CountdownCanvasView <com.example.countdowntimer.CountdownCanvasView
android:id="@+id/cvCountdown" android:id="@+id/cvCountdown"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
app:layout_constraintHeight_default="wrap"
app:layout_constrainedHeight="true"
app:layout_constraintBottom_toTopOf="@id/btnStop" app:layout_constraintBottom_toTopOf="@id/btnStop"
app:layout_constraintDimensionRatio="1:1"
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_toTopOf="parent"
android:minWidth="120dp" android:layout_margin="16dp" />
android:minHeight="120dp" />
<Button <Button
android:id="@+id/btnStop" android:id="@+id/btnStop"
+4
View File
@@ -7,4 +7,8 @@
<string name="instruction_volume">Ensure your bong is loaded</string> <string name="instruction_volume">Ensure your bong is loaded</string>
<string name="instruction_position">Find a comfortable position</string> <string name="instruction_position">Find a comfortable position</string>
<string name="instruction_distractions">Light your bowl and take your hit before you press the Start Timer button. HOLD YOUR HIT IN!</string> <string name="instruction_distractions">Light your bowl and take your hit before you press the Start Timer button. HOLD YOUR HIT IN!</string>
<string name="pick_duration">Pick Duration</string>
<string name="settings">Settings</string>
<string name="vibration">Vibration</string>
<string name="sounds">Sounds</string>
</resources> </resources>