🐴
ポモドーロタイマーを作る【Jetpack Compose】
はじめに
今回は、Create a Pomodoro Timer using Jetpack Compose and MVVM | by Vaibhav Jain | Mediumを参考にして、Jetpack Composeでポモドーロタイマーを作ります。
手順
Step1: 新規プロジェクトの作成
- 「Empty Activiy」を選択し、「PomodoroTimerSample」という名前でプロジェクトを作成
※プロジェクト名は自由に設定OK - 【任意】git管理する
- 以下のようにGit リポジトリを新たに作成する
git init
- gitignoreを編集する
gitignore/Android.gitignore at main · github/gitignoreからそのままコピペする
※app/gitignoreではなく、プロジェクト直下の方のgitignoreです。
Step2: UIを作成する
- Step1で作成したファイルをシンプルにする
現状、Greetingなどがあるはずなので、一旦シンプルにしておきます。
package com.example.pomodorotimersample
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.Surface
import com.example.pomodorotimersample.ui.theme.PomodoroTimerSampleTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PomodoroTimerSampleTheme {
Surface{}
}
}
}
}
-
参考サイトからコピペする
※参考サイトで誤っている部分があったので修正済み
この段階では、以下2箇所でエラーが出ていますが、大丈夫です。
progress = progress
baseline_pause_24
コード(長いため折りたたみにしています)
package com.example.pomodorotimersample import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.example.pomodorotimersample.ui.theme.PomodoroTimerSampleTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { PomodoroTimerSampleTheme { Surface{ Home() } } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun Home(){ Scaffold( topBar = { CenterAlignedTopAppBar( title = { Text(text = stringResource(id = R.string.app_name)) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer, titleContentColor = MaterialTheme.colorScheme.primary ) ) } ) { paddingValues -> Box( modifier = Modifier .padding(paddingValues) .fillMaxSize(), contentAlignment = Alignment.Center, ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Box( modifier = Modifier.padding(bottom = 16.dp), contentAlignment = Alignment.Center ) { CircularProgressIndicator( modifier = Modifier .width(256.dp) .height(256.dp), progress = progress ) Text( text = "Timer", modifier = Modifier.padding(bottom = 8.dp), textAlign = TextAlign.Center ) } Row( verticalAlignment = Alignment.CenterVertically ) { IconButton( modifier = Modifier.padding(bottom = 8.dp), onClick = {} ) { Icon( imageVector = ImageVector.vectorResource( id = R.drawable.baseline_pause_24 ), contentDescription = "" ) } IconButton( modifier = Modifier.padding(bottom = 8.dp), onClick = {} ) { Icon(imageVector = Icons.Filled.Refresh, contentDescription = null) } } } } } }
- Google Fontsからアイコンをダウンロードする
-
Material Symbols & Icons - Google Fonts
にアクセスする - 「pause」と検索して、Androidの「download」をクリックして、xmlファイルをダウンロードする
- ダウンロードした「pause_24px.xml」を、「drawable」ファイルに格納する
-
id = R.drawable.baseline_pause_24
をid = R.drawable.pause_24px
に変更する
-
Material Symbols & Icons - Google Fonts
Step3: Data クラスと ViewModel を作成する
- データクラス TimerState を作成する
※同じpackageにしてください。私の場合、package com.example.pomodorotimersample
です。
data class TimerState( val remainingSeconds: Long = 0L, val isPaused: Boolean = true, val lastTimer: TimerType = TimerType.POMODORO, ) enum class TimerType { POMODORO, REST, }
remainingSeconds
: タイマーがオフになるまでの残り秒数を格納するために使用されます。isPaused
: タイマーが一時停止しているかどうかを格納するために使用されます。lastTimer
:次のタイマーの時間を決定できる最後のタイマーのタイプを保存するために使用されます。
- ViewModelクラスのTimerViewModelを作成する
この時点では、以下の2点がエラーになっていますが、大丈夫です。
※同じpackageにしてください。私の場合、package com.example.pomodorotimersample
です。
POMODORO_TIMER_SECONDS
REST_TIMER_SECONDS
コード(長いため折りたたみにしています)
class TimerViewModel() : ViewModel() { private val _timerState = MutableStateFlow(TimerState()) val timerState = _timerState.asStateFlow() private var pomodoroTimer: CountDownTimer? = null init { _timerState.update { it.copy( remainingSeconds = POMODORO_TIMER_SECONDS, lastTimer = TimerType.POMODORO ) } } fun startTimer(seconds: Long) { _timerState.update { it.copy( isPaused = false ) } pomodoroTimer = object : CountDownTimer( seconds * MILLISECONDS_IN_A_SECOND, MILLISECONDS_IN_A_SECOND ) { override fun onTick(millisUntilFinished: Long) { _timerState.update { it.copy( remainingSeconds = millisUntilFinished / MILLISECONDS_IN_A_SECOND ) } } override fun onFinish() { pomodoroTimer?.cancel() _timerState.update { it.copy( isPaused = true, remainingSeconds = if (it.lastTimer == TimerType.POMODORO) REST_TIMER_SECONDS else POMODORO_TIMER_SECONDS, lastTimer = if (it.lastTimer == TimerType.POMODORO) TimerType.REST else TimerType.POMODORO ) } } } pomodoroTimer?.start() } fun stopTimer() { _timerState.update { it.copy( isPaused = true ) } pomodoroTimer?.cancel() } fun resetTimer(seconds: Long) { pomodoroTimer?.cancel() _timerState.update { it.copy( isPaused = true, remainingSeconds = seconds, lastTimer = TimerType.POMODORO ) } } } const val MILLISECONDS_IN_A_SECOND = 1000L
- まず、
TimerState
のMutableStateFlow()
を作成し、init()
ブロック内のさまざまなパラメータの値を初期化します。startTimer()
: このメソッドは、CountDownTimer
を使用してpomodoroTimer
変数を初期化するために使用されます。パラメータとして、numberOfSeconds
とcountDownInterval
を渡します。また、CountDownTimer
には2つのオーバーライドメソッドがあります。
onTick
: このメソッドは、残りの秒数を更新するために使用します。onFinish
: このメソッドは、タイマーをポモドーロ/休憩タイマーにリセットするために使用します。stopTimer()
: このメソッドは、タイマーを一時停止するために使用されます。CountDownTimer
には一時停止メソッドがないため、stop
を使用して一時停止し、remainingSeconds
から再度再開します。resetTimer()
: このメソッドは、pomodoroTimer
を初期値にリセットするために使用されます。
Step4: メソッドをMainActivityに統合する
- 以下コードをコピペする
Step2の通り、baseline_pause_24
はpause_24px
にしています。
- メインアクティビティでは、
viewModel
クラスを初期化し、すべてのメソッドを使用しました。collectAsState()
メソッドを使用して、状態変数を初期化します。- また、タイマーが切れたときにサウンドを再生する
MediaPlayer
を追加しました。私は同じために無料のオープンソースオーディオを使用しました。お好きなオーディオを自由にお使いください。
コード(長いため折りたたみにしています)
@OptIn(ExperimentalMaterial3Api::class) @Composable fun Home( timerViewModel: TimerViewModel = viewModel() ) { val timerState by timerViewModel.timerState.collectAsState() val timerSeconds = if (timerState.lastTimer == TimerType.POMODORO) POMODORO_TIMER_SECONDS else REST_TIMER_SECONDS val mediaPlayer = MediaPlayer.create(LocalContext.current, R.raw.sound) LaunchedEffect(key1 = timerState.remainingSeconds) { if (timerState.remainingSeconds == 0L) mediaPlayer.start() } Scaffold( topBar = { CenterAlignedTopAppBar( title = { Text(text = stringResource(id = R.string.app_name)) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer, titleContentColor = MaterialTheme.colorScheme.primary ) ) } ) { paddingValues -> Box( modifier = Modifier .padding(paddingValues) .fillMaxSize(), contentAlignment = Alignment.Center, ) { val progress = timerState.remainingSeconds.toFloat() / (timerSeconds.toFloat()) val minutes = timerState.remainingSeconds / SECONDS_IN_A_MINUTE val seconds = timerState.remainingSeconds - (minutes * SECONDS_IN_A_MINUTE) Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Box( modifier = Modifier.padding(bottom = 16.dp), contentAlignment = Alignment.Center ) { CircularProgressIndicator( modifier = Modifier .width(256.dp) .height(256.dp), progress = progress ) Text( text = "Timer\n$minutes : $seconds", modifier = Modifier.padding(bottom = 8.dp), textAlign = TextAlign.Center ) } Row( verticalAlignment = Alignment.CenterVertically ) { IconButton( modifier = Modifier.padding(bottom = 8.dp), onClick = { if (timerState.isPaused) { timerViewModel.startTimer(timerState.remainingSeconds) } else { timerViewModel.stopTimer() } } ) { Icon( imageVector = if (timerState.isPaused) ImageVector.vectorResource( id = R.drawable.pause_24px ) else Icons.Filled.PlayArrow, contentDescription = null ) } IconButton( modifier = Modifier.padding(bottom = 8.dp), onClick = { timerViewModel.resetTimer(POMODORO_TIMER_SECONDS) } ) { Icon(imageVector = Icons.Filled.Refresh, contentDescription = null) } } } } } } const val POMODORO_TIMER_SECONDS = 1500L const val REST_TIMER_SECONDS = 300L const val SECONDS_IN_A_MINUTE = 60L
-
res
フォルダにraw
ディレクトリを作成 - 好きな音楽を格納
元コードのように、sound.mp3
とするとよいでしょう。
私は、OtoLogicの以下からベルアクセント07のHigh-Delayを使用しました。
なお、これはカウントが0秒になったときに鳴ります。
手っ取り早く動作確認したい人は、const val POMODORO_TIMER_SECONDS = 1500L
を2L
などにするとよいでしょう。
https://otologic.jp/free/se/bell-accent01.html - 【任意】
viewModel()
でエラーが出る場合- build.gradleに以下を追加
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0")
- MainActiviyに以下を追加
import androidx.lifecycle.viewmodel.compose.viewModel
- 【任意】開始中と停止中のアイコンを変える
現在は開始中にIcons.Filled.PlayArrow
、停止中にR.drawable.pause_24px
となっていて違和感があるため、逆にします。
Icon(
imageVector = if (timerState.isPaused) {
Icons.Filled.PlayArrow
} else {
ImageVector.vectorResource(id = R.drawable.pause_24px)
},
contentDescription = null
)
Step5: 実行する
-
実行してみる
-
【任意】エラーが出た場合の解決
- pause_24px.xmlで
colorControlNormal
でエラーが出た場合、colorControlNormal
を以下に変更すればOK
@android:color/white
- 以下のようなエラーの場合
compileSdk = 35
のように34から35にすればO
エラーが出た場合
5 issues were found when checking AAR metadata: 1. Dependency 'androidx.activity:activity:1.10.1' requires libraries and applications that depend on it to compile against version 35 or later of the Android APIs. :app is currently compiled against android-34. Recommended action: Update this project to use a newer compileSdk of at least 35, for example 35. Note that updating a library or application's compileSdk (which allows newer APIs to be used) can be done separately from updating targetSdk (which opts the app in to new runtime behavior) and minSdk (which determines which devices the app can be installed on). 2. Dependency 'androidx.activity:activity-ktx:1.10.1' requires libraries and applications that depend on it to compile against version 35 or later of the Android APIs. :app is currently compiled against android-34. Recommended action: Update this project to use a newer compileSdk of at least 35, for example 35. Note that updating a library or application's compileSdk (which allows newer APIs to be used) can be done separately from updating targetSdk (which opts the app in to new runtime behavior) and minSdk (which determines which devices the app can be installed on). 3. Dependency 'androidx.activity:activity-compose:1.10.1' requires libraries and applications that depend on it to compile against version 35 or later of the Android APIs. :app is currently compiled against android-34. Recommended action: Update this project to use a newer compileSdk of at least 35, for example 35. Note that updating a library or application's compileSdk (which allows newer APIs to be used) can be done separately from updating targetSdk (which opts the app in to new runtime behavior) and minSdk (which determines which devices the app can be installed on). 4. Dependency 'androidx.core:core:1.16.0' requires libraries and applications that depend on it to compile against version 35 or later of the Android APIs. :app is currently compiled against android-34. Recommended action: Update this project to use a newer compileSdk of at least 35, for example 35. Note that updating a library or application's compileSdk (which allows newer APIs to be used) can be done separately from updating targetSdk (which opts the app in to new runtime behavior) and minSdk (which determines which devices the app can be installed on). 5. Dependency 'androidx.core:core-ktx:1.16.0' requires libraries and applications that depend on it to compile against version 35 or later of the Android APIs. :app is currently compiled against android-34. Recommended action: Update this project to use a newer compileSdk of at least 35, for example 35. Note that updating a library or application's compileSdk (which allows newer APIs to be used) can be done separately from updating targetSdk (which opts the app in to new runtime behavior) and minSdk (which determines which devices the app can be installed on).
- pause_24px.xmlで
アレンジ
ポモドーロタイマーが減っているときにBGMを流す
- コードを以下のように書く。
なお、Scaffold
以下はそのままなので、省略しています。
val timerState by timerViewModel.timerState.collectAsState()
val timerSeconds =
if (timerState.lastTimer == TimerType.POMODORO)
POMODORO_TIMER_SECONDS
else
REST_TIMER_SECONDS
val mediaPlayer = MediaPlayer.create(LocalContext.current, R.raw.sound)
val context = LocalContext.current
val bgmPlayer = remember {
MediaPlayer.create(context, R.raw.bgm_loop).apply {
isLooping = true
}
}
var previousSeconds by remember { mutableStateOf(timerState.remainingSeconds) }
DisposableEffect(Unit) {
onDispose {
bgmPlayer.release()
}
}
LaunchedEffect(key1 = timerState.remainingSeconds, key2 = timerState.isPaused) {
if (timerState.remainingSeconds == 0L) {
mediaPlayer.start()
}
val isPomodoro = timerState.lastTimer == TimerType.POMODORO
val isCountingDown = !timerState.isPaused && timerState.remainingSeconds < previousSeconds
if (isCountingDown && isPomodoro) {
if (!bgmPlayer.isPlaying) {
bgmPlayer.start()
}
} else {
if (bgmPlayer.isPlaying) {
bgmPlayer.pause()
bgmPlayer.seekTo(0)
}
}
previousSeconds = timerState.remainingSeconds
}
- BGMを
raw
ディレクトリ直下に置く。
私は、https://dova-s.jp/bgm/download20954.html からダウンロードしました。
自分で選ぶときもループ再生可のものがおすすめです!
終わりに
今回は、Jetpack Composeで、ポモドーロタイマーを作ってみました。
アレンジ以外、ほぼもともとのサイトそのままですが、勉強になりました。
ぜひ参考にしてみてください。
参考
Discussion