🐴

ポモドーロタイマーを作る【Jetpack Compose】

に公開

はじめに

今回は、Create a Pomodoro Timer using Jetpack Compose and MVVM | by Vaibhav Jain | Mediumを参考にして、Jetpack Composeでポモドーロタイマーを作ります。

手順

Step1: 新規プロジェクトの作成

  1. 「Empty Activiy」を選択し、「PomodoroTimerSample」という名前でプロジェクトを作成
    ※プロジェクト名は自由に設定OK
  2. 【任意】git管理する
    1. 以下のようにGit リポジトリを新たに作成する
    git init
    
    1. gitignoreを編集する
      gitignore/Android.gitignore at main · github/gitignoreからそのままコピペする
      ※app/gitignoreではなく、プロジェクト直下の方のgitignoreです。

Step2: UIを作成する

  1. 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{}
            }
        }
    }
}
  1. 参考サイトからコピペする
    ※参考サイトで誤っている部分があったので修正済み
    この段階では、以下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)
                    }
                }
            }
        }
    }
}
  1. Google Fontsからアイコンをダウンロードする
    1. Material Symbols & Icons - Google Fonts
      にアクセスする
    2. 「pause」と検索して、Androidの「download」をクリックして、xmlファイルをダウンロードする
    3. ダウンロードした「pause_24px.xml」を、「drawable」ファイルに格納する
    4. id = R.drawable.baseline_pause_24id = R.drawable.pause_24pxに変更する

Step3: Data クラスと ViewModel を作成する

  1. データクラス 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 :次のタイマーの時間を決定できる最後のタイマーのタイプを保存するために使用されます。
  1. 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
  • まず、TimerStateMutableStateFlow()を作成し、init()ブロック内のさまざまなパラメータの値を初期化します。
  • startTimer() : このメソッドは、CountDownTimer を使用して pomodoroTimer 変数を初期化するために使用されます。パラメータとして、numberOfSecondscountDownInterval を渡します。また、CountDownTimerには2つのオーバーライドメソッドがあります。
    • onTick: このメソッドは、残りの秒数を更新するために使用します。
    • onFinish: このメソッドは、タイマーをポモドーロ/休憩タイマーにリセットするために使用します。
  • stopTimer() : このメソッドは、タイマーを一時停止するために使用されます。CountDownTimerには一時停止メソッドがないため、stopを使用して一時停止し、remainingSecondsから再度再開します。
  • resetTimer() : このメソッドは、pomodoroTimer を初期値にリセットするために使用されます。

Step4: メソッドをMainActivityに統合する

  1. 以下コードをコピペする
    Step2の通り、baseline_pause_24pause_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
  1. resフォルダにrawディレクトリを作成
  2. 好きな音楽を格納
    元コードのように、sound.mp3とするとよいでしょう。
    私は、OtoLogicの以下からベルアクセント07のHigh-Delayを使用しました。
    なお、これはカウントが0秒になったときに鳴ります。
    手っ取り早く動作確認したい人は、const val POMODORO_TIMER_SECONDS = 1500L2Lなどにするとよいでしょう。
    https://otologic.jp/free/se/bell-accent01.html
  3. 【任意】viewModel()でエラーが出る場合
    1. build.gradleに以下を追加
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0")
    
    1. MainActiviyに以下を追加
    import androidx.lifecycle.viewmodel.compose.viewModel
    
  4. 【任意】開始中と停止中のアイコンを変える
    現在は開始中に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: 実行する

  1. 実行してみる

  2. 【任意】エラーが出た場合の解決

    • 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).
    
    

アレンジ

ポモドーロタイマーが減っているときにBGMを流す

  1. コードを以下のように書く。
    なお、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
    }
  1. BGMをrawディレクトリ直下に置く。
    私は、https://dova-s.jp/bgm/download20954.html からダウンロードしました。
    自分で選ぶときもループ再生可のものがおすすめです!

終わりに

今回は、Jetpack Composeで、ポモドーロタイマーを作ってみました。
アレンジ以外、ほぼもともとのサイトそのままですが、勉強になりました。
ぜひ参考にしてみてください。

参考

https://medium.com/@vaibhavjain30699/create-a-pomodoro-timer-using-jetpack-compose-and-mvvm-edbeca55e4e9

Discussion