「今日の社員食堂の予約、A定食だっけ?B定食だっけ……?」
お昼時、食堂の券売機やカウンターの前で立ち尽くした経験はありませんか?
私の会社の食堂は予約制なのですが、数日前に予約したメニューを忘れてしまい、食堂の列に並びながら焦ることがよくありました。
そこで、**「予約したランチをカレンダー感覚でメモし、お昼になると通知してくれる」**超シンプルなAndroidアプリ 「Lunch Memo」 を開発しました!
本記事では、このアプリの設計、Jetpack Composeによるアニメーション付きUI、そしてWorkManagerを用いた定期通知の実装について解説します。
📱 アプリの主な機能
- 直感的なカレンダーUI: スワイプで日付を移動しながら、サクッとランチをメモ。
- お昼のスマート通知: 毎日指定した時間(設定変更可能)に、今日のランチメニューを自動通知。
- 自動クリーンアップ: 過去の不要なメモはアプリ起動時に自動でクリーンアップされ、端末のストレージを無駄に使いません。
- マテリアル3対応: 洗練された Material 3 UI と滑らかなグラデーション背景。
- 英数字入力制限モード: 必要に応じて英数字とスペースのみの入力に制限する機能を搭載。
🛠️ 技術スタック
- 言語: Kotlin
- UI: Jetpack Compose (Material 3)
- データベース: Room (SQLite)
- 非同期処理: Coroutines & Flow
- バックグラウンドタスク: WorkManager
- アーキテクチャ: MVVM
- 依存関係管理: Gradle Version Catalog (libs.versions.toml) & KSP
💡 こだわった実装ポイント
1. Jetpack Composeによる滑らかなカードアニメーション UI
カレンダー感覚で日付をめくる楽しさを表現するため、HorizontalPager を使ったカードスワイプUIを構築しました。
ただカードを並べるだけでなく、現在アクティブな中央のカードが強調され、左右のカードは少し小さく・半透明になるようにスクロールオフセットを利用した動的なアニメーション(グラフィックスレイヤー制御)を実装しています。
@Composable
fun LunchMemoContent(
memos: Map<LocalDate, String>,
settings: AppSettingsEntity?,
onMemoChange: (LocalDate, String) -> Unit
) {
val pageCount = 2000
val initialPage = pageCount / 2
val pagerState = rememberPagerState(pageCount = { pageCount }, initialPage = initialPage)
val today = LocalDate.now()
HorizontalPager(
state = pagerState,
contentPadding = PaddingValues(horizontal = 44.dp),
pageSpacing = 16.dp,
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) { page ->
val date = today.plusDays((page - initialPage).toLong())
val isSelected = pagerState.currentPage == page
// スクロール時のオフセットを計算
val pageOffset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction).absoluteValue
LunchMemoCard(
date = date,
memo = memos[date] ?: "",
isEditable = isSelected,
alphanumericOnly = settings?.alphanumericOnly ?: false,
onMemoChange = { onMemoChange(date, it) },
modifier = Modifier.graphicsLayer {
// スクロール位置に応じてアルファ(不透明度)とスケール(拡大率)を変化させる
val fraction = 1f - pageOffset.coerceIn(0f, 1f)
alpha = lerp(start = 0.5f, stop = 1f, fraction = fraction)
scaleX = lerp(start = 0.85f, stop = 1f, fraction = fraction)
scaleY = lerp(start = 0.85f, stop = 1f, fraction = fraction)
}
)
}
}
2. WorkManagerを用いた「毎日指定時刻」のバックグラウンド通知
毎日決まった時間に今日のランチメニューを通知するため、Android推奨のバックグラウンド処理ライブラリである WorkManager を活用しました。
特に以下の2点に注意して実装しています:
-
初回実行までのディレイ(
setInitialDelay)の計算:PeriodicWorkRequestは周期実行(24時間ごと)を指定できますが、最初の実行タイミングを「本日の指定時刻(過ぎている場合は明日の指定時刻)」に調整する必要があります。 -
安全な再スケジュール: ユーザーが設定画面で通知時間を変更した際、タスクが重複登録されないように
ExistingPeriodicWorkPolicy.UPDATEを使用して安全にジョブを更新しています。
object NotificationScheduler {
private const val WORK_NAME = "lunch_notification_work"
fun scheduleDailyNotification(context: Context, hour: Int = 12, minute: Int = 0) {
val calendar = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
// 指定した時刻がすでに過ぎている場合は、翌日から開始
if (before(Calendar.getInstance())) {
add(Calendar.DATE, 1)
}
}
val delay = calendar.timeInMillis - System.currentTimeMillis()
// 24時間周期で実行するPeriodicWorkRequestを構築
val workRequest = PeriodicWorkRequestBuilder<LunchNotificationWorker>(24, TimeUnit.HOURS)
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
.build()
// UniquePeriodicWorkとして登録(既存のタスクがある場合はUPDATEする)
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
workRequest
)
}
}
バックグラウンドで実際に動作する CoroutineWorker では、非同期に Room から今日のメモを取得し、内容が存在する場合にのみシステム通知(NotificationChannel を利用)を表示します。
class LunchNotificationWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val database = LunchMemoDatabase.getDatabase(applicationContext)
val dao = database.lunchMemoDao()
val today = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE)
// データベースからすべてのメモをFlowで取得して、今日の日付のものを検索
val memos = dao.getAllMemos().first()
val todayMemo = memos.find { it.date == today }?.memo
if (!todayMemo.isNullOrBlank()) {
showNotification(todayMemo)
}
return Result.success()
}
// showNotification() 内で NotificationCompat.Builder を使ってシステム通知を発行...
}
3. Room データのクリーンアップとシームレスな状態フロー
「古いメモは自動的に消える」という仕様をシンプルに満たすため、Roomエンティティの主キーに date (yyyy-MM-dd) 形式の文字列を採用しました。
これにより、日付比較での古いデータ削除クエリが非常にシンプルに記述できます。
@Dao
interface LunchMemoDao {
@Query("SELECT * FROM lunch_memos")
fun getAllMemos(): Flow<List<LunchMemoEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMemo(memo: LunchMemoEntity)
// 指定日付(昨日)より古いメモを削除する
@Query("DELETE FROM lunch_memos WHERE date < :date")
suspend fun deleteMemosOlderThan(date: String)
}
ViewModel側では、アプリ起動時に cleanOldMemos() を非同期に呼び出し、昨日より前の不要になったデータを削除します。また、UI側で使いやすいように Flow を介して Map<LocalDate, String> に変換し、stateIn を用いて状態を保持しています。
class LunchMemoViewModel(application: Application) : AndroidViewModel(application) {
private val dao = LunchMemoDatabase.getDatabase(application).lunchMemoDao()
private val formatter = DateTimeFormatter.ISO_LOCAL_DATE
// DBのリストデータをマップに変換し、StateFlowとしてUIに提供
val memos = dao.getAllMemos().map { list ->
list.associate { LocalDate.parse(it.date, formatter) to it.memo }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
// 起動時に呼び出され、昨日より前のメモをクリーンアップ
fun cleanOldMemos() {
viewModelScope.launch {
val yesterday = LocalDate.now().minusDays(1).format(formatter)
dao.deleteMemosOlderThan(yesterday)
}
}
}
🏆 開発を通じて得られた知見
-
Jetpack Composeのアニメーションの手軽さ:
lerpやgraphicsLayerを用いることで、複雑に見えるスワイプ時のインタラクティブなアニメーションが驚くほど少ないコード量で実装できました。 - WorkManagerの確実性: アプリが終了していても、OSによってバックグラウンドで確実にタスクがスケジュールされる WorkManager は、今回の「毎日のランチ通知」のようなユースケースに非常にマッチしていると実感しました。
- 状態(State)の単方向フロー: Room (Flow) ➔ ViewModel (StateFlow) ➔ Compose (State) という王道の MVI/MVVM パターンで設計したことで、データ保存からUIの再描画までバグが入り込みにくいクリーンな実装になりました。
📝 まとめ
小さな日常の課題(食堂のメニュー忘れ)を解決するために作成したアプリですが、Jetpack Compose や WorkManager、Room といった現在の Android 推奨技術をシンプルに凝縮した良いサンプルプロジェクトになりました。
もし「お昼ごはん何予約してたっけ?」となりがちな方がいれば、ぜひこの構成を参考に自分好みのランチメモアプリを作ってみてください!
最後までお読みいただきありがとうございました。