Never Forget What You Ordered: Building "Lunch Memo" with Jetpack Compose, Room, and AlarmManager
Have you ever stood in line at a cafeteria or ticket machine, thinking: "Did I order the Lunch Special A or B today?"
For many developers working at companies with pre-ordered lunch systems, this is a daily micro-friction. You reserve a lunch days in advance, only to stand in front of the cashier trying to recall what you picked.
To solve this, I built Lunch Memo—a sleek, lightweight Android application designed to keep track of daily lunch plans and send a notification right before lunch.
In this article, I’ll walk through the implementation highlights, showcasing:
-
Interactive UI: Creating a fluid calendar card swiping animation with Jetpack Compose
HorizontalPagerandgraphicsLayer. - Modular Architecture: A customizable 3-component input form (Selection, Numeric, Text) backed by Room and serialized JSON.
-
Exact Alarms: Scheduling reliable daily notifications using
AlarmManager,BroadcastReceiver.goAsync(), and Kotlin Coroutines. - Data Stewardship: Auto-cleaning database records to keep the app's footprint tiny.
🛠️ The Tech Stack
- Language: Kotlin
- UI: Jetpack Compose (Material 3)
- Database: Room (SQLite)
- Scheduling: AlarmManager + BroadcastReceiver
- Asynchronous: Coroutines & Flow
- Architecture: MVVM
- Dependency Management: Gradle Version Catalog
🎨 1. Fluid Card Swipe Animation with Jetpack Compose
To make navigating dates feel premium, the app displays lunch memos as cards in a horizontal calendar. Swiping left or right changes the day.
Instead of a static list, I used HorizontalPager and manipulated the cards dynamically via graphicsLayer. The active card stands out, while adjacent cards are scaled down and made translucent based on their scroll offset.
Here is the implementation:
@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
// Calculate scroll offset fraction
val pageOffset = (
(pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
).absoluteValue
LunchMemoCard(
date = date,
memo = memos[date] ?: "",
isEditable = isSelected,
settings = settings,
onMemoChange = { onMemoChange(date, it) },
modifier = Modifier.graphicsLayer {
// Scale and fade cards based on offset fraction
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)
}
)
}
}
Why this works:
-
graphicsLayerapplies changes (like scale and alpha) on the GPU level without triggering recomposition. This keeps animations running at a locked 60/120 FPS. - The
lerp(linear interpolation) function smoothly maps scroll offsets to visual properties.
⚙️ 2. Dynamic 3-Component Input Form
Users need different types of memos. For example, you might want to:
- Select a pre-configured lunch package (e.g., "A Lunch", "B Lunch").
- Type in a table or reservation number (Numeric).
- Write custom notes (Text).
To support this without over-complicating the schema, the app represents the memo as a single string composed of three space-separated parts ("Part1 Part2 Part3"). In the UI, these are rendered as three configurable components:
@Composable
fun LunchMemoCard(
date: LocalDate,
memo: String,
isEditable: Boolean,
settings: AppSettingsEntity?,
onMemoChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
// Parse the space-separated parts
val parts = remember(memo) {
val p = memo.split(" ")
mutableStateListOf(
if (p.isNotEmpty()) p[0] else "",
if (p.size > 1) p[1] else "",
if (p.size > 2) p.subList(2, p.size).joinToString(" ") else ""
)
}
fun updateMemo() {
onMemoChange(parts.joinToString(" ").trim())
}
// ... Layout and Header ...
Column(modifier = Modifier.weight(1f).fillMaxWidth()) {
if (isEditable) {
ComponentInput(1, config1, parts[0]) { parts[0] = it; updateMemo() }
Spacer(modifier = Modifier.height(12.dp))
ComponentInput(2, config2, parts[1]) { parts[1] = it; updateMemo() }
Spacer(modifier = Modifier.height(12.dp))
ComponentInput(3, config3, parts[2]) { parts[2] = it; updateMemo() }
} else {
// Display consolidated text view
}
}
}
Each ComponentInput displays a different UI depending on its active configuration:
-
SELECTIONrenders custom tags as a flow ofFilterChips. -
NUMERICrestricts input to a specified number of digits. -
TEXTdisplays a simple text input.
@Composable
fun ComponentInput(
index: Int,
config: ComponentConfig,
value: String,
onValueChange: (String) -> Unit
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text("Component $index", style = MaterialTheme.typography.labelSmall)
when (config.type) {
ComponentType.SELECTION -> {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
config.options.forEach { option ->
FilterChip(
selected = value == option,
onClick = { onValueChange(option) },
label = { Text(option) }
)
}
}
}
ComponentType.NUMERIC -> {
OutlinedTextField(
value = value,
onValueChange = {
if (it.length <= config.digitLimit && it.all { it.isDigit() }) {
onValueChange(it)
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
placeholder = { Text("${config.digitLimit}-digit number") }
)
}
ComponentType.TEXT -> {
OutlinedTextField(value = value, onValueChange = onValueChange)
}
}
}
}
⏰ 3. Precision Local Notifications via AlarmManager & Coroutines
Using WorkManager for a daily alarm is standard practice, but WorkManager is built for deferrable background work. It operates on a sliding window and cannot guarantee execution at an exact minute (like exactly 12:00 PM).
To wake the device and post the notification at the exact minute, Lunch Memo uses AlarmManager combined with a BroadcastReceiver.
Triggering the Alarm
We calculate the delay until the user's configured notification time (adjusting to the next day if that time has already passed) and schedule the alarm using setExactAndAllowWhileIdle:
object NotificationScheduler {
private const val REQUEST_CODE = 1001
fun scheduleDailyNotification(context: Context, hour: Int, minute: Int) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = "com.kusa.lunchmemo.NOTIFICATION_ALARM"
}
val pendingIntent = PendingIntent.getBroadcast(
context, REQUEST_CODE, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val calendar = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
if (timeInMillis <= System.currentTimeMillis() + 1000) {
add(Calendar.DATE, 1)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, calendar.timeInMillis, pendingIntent)
} else {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, calendar.timeInMillis, pendingIntent)
}
} else {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, calendar.timeInMillis, pendingIntent)
}
}
}
Performing Async Tasks in a BroadcastReceiver with goAsync()
Typically, a BroadcastReceiver lifecycle is extremely short; performing operations like database queries inside onReceive can cause an Application Not Responding (ANR) error.
However, by calling goAsync(), we can safely launch a Coroutine Scope to fetch today's memo asynchronously on a dispatcher thread and keep the receiver alive until our database lookup completes:
class NotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (action == Intent.ACTION_BOOT_COMPLETED || action == "com.kusa.lunchmemo.NOTIFICATION_ALARM") {
// Keep BroadcastReceiver active during asynchronous execution
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
val database = LunchMemoDatabase.getDatabase(context)
val dao = database.lunchMemoDao()
val settings = dao.getSettings().first()
val hour = settings?.notificationHour ?: 12
val minute = settings?.notificationMinute ?: 0
// Reschedule for tomorrow
NotificationScheduler.scheduleDailyNotification(context, hour, minute)
// Show notification if it's the alarm action
if (action == "com.kusa.lunchmemo.NOTIFICATION_ALARM") {
val today = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE)
val memos = dao.getAllMemos().first()
val todayMemo = memos.find { it.date == today }?.memo
if (!todayMemo.isNullOrBlank()) {
showNotification(context, todayMemo)
}
}
} finally {
// Inform the OS we're done
pendingResult.finish()
}
}
}
}
}
🧹 4. Lightweight Storage & Auto-Cleanup
A good utility app shouldn't hoard local storage. Since past lunch memos are no longer relevant, Lunch Memo cleans them up automatically.
In the LunchMemoDao, I defined a simple query to delete memos older than a specified date:
@Query("DELETE FROM lunch_memos WHERE date < :date")
suspend fun deleteMemosOlderThan(date: String)
Inside the LunchMemoViewModel, this is invoked asynchronously on app launch:
fun cleanOldMemos() {
viewModelScope.launch {
val yesterday = LocalDate.now().minusDays(1).format(formatter)
dao.deleteMemosOlderThan(yesterday)
}
}
This keeps the local SQLite database extremely compact, containing only upcoming and today's memos.
🔄 5. Room Database Migrations
As the app grew, its schema evolved to accommodate customization settings. Handling migrations seamlessly is crucial so users do not lose their data.
Here is the migration trajectory of the app's database from Version 1 to 4:
- v1: Basic daily notes.
-
v1 -> v2: Introduced
app_settingsfor customized notification times. -
v2 -> v3: Added
alphanumericOnlysetting. - v3 -> v4: Introduced serialization via GSON to store JSON strings representing custom layouts for the 3 input components.
private val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE `app_settings` ADD COLUMN `component1ConfigJson` TEXT NOT NULL DEFAULT ''")
database.execSQL("ALTER TABLE `app_settings` ADD COLUMN `component2ConfigJson` TEXT NOT NULL DEFAULT ''")
database.execSQL("ALTER TABLE `app_settings` ADD COLUMN `component3ConfigJson` TEXT NOT NULL DEFAULT ''")
// Define default settings layouts
val defaultConfig1 = ComponentConfig(ComponentType.SELECTION, listOf("A Lunch", "B Lunch", "C Lunch")).serialize()
val defaultConfig2 = ComponentConfig(ComponentType.NUMERIC, digitLimit = 2).serialize()
val defaultConfig3 = ComponentConfig(ComponentType.TEXT).serialize()
database.execSQL("UPDATE `app_settings` SET `component1ConfigJson` = '$defaultConfig1'")
database.execSQL("UPDATE `app_settings` SET `component2ConfigJson` = '$defaultConfig2'")
database.execSQL("UPDATE `app_settings` SET `component3ConfigJson` = '$defaultConfig3'")
}
}
By adding these migration blocks to Room.databaseBuilder(...).addMigrations(...), updates are seamless and never crash the app for existing users.
🏆 Key Lessons Learned
- Unidirectional Data Flow: Implementing the Room (Flow) ➔ ViewModel (StateFlow) ➔ Jetpack Compose state cycle made state updates incredibly clean and bug-free.
-
Choosing the Right Notification Tool: Using
WorkManageris great for background syncing or analytics, butAlarmManageris the correct engineering choice when notifications must arrive at a precise local time. - Lightweight Serialized Configurations: Storing custom configurations inside the database as serialized JSON allowed adding flexible setting patterns without having to constantly spin up new Room tables.
📝 Conclusion
Building Lunch Memo was a fun project that solved a real everyday problem. It demonstrates how standard modern Android practices like Jetpack Compose, Room, and AlarmManager can create a tool that is clean, responsive, and incredibly useful.
If you struggle with remembering your cafeteria bookings, try building your own customized version!
Thanks for reading, and happy coding! 🍱
amekusa03
/
androidLunchMemo
ランチ予定をシンプルに管理する Android アプリ
Lunch Memo 🍱
Lunch Memo は、日々のランチの予定や記録をシンプルかつスタイリッシュに管理できる Android アプリです。 モダンな Material 3 デザインを採用し、スムーズな操作感でランチタイムを楽しく彩ります。
主な機能 🌟
- スマートな記録: カレンダー形式のカードをスワイプして、ランチの内容を素早くメモ。
- お昼の通知: 毎日12:00に、その日のランチメモを通知。今日何食べるんだっけ?を解消します。
-
モダンなデザイン:
- Material 3 フル対応。
- スムーズなカードアニメーションとグラデーション背景。
- Android 13 以上の通知権限に完全対応。
- クリーンなデータ保持: 過去のメモは自動的に整理され、アプリを常に軽量に保ちます。
技術スタック 🛠️
- Language: Kotlin
- UI: Jetpack Compose (Material 3)
- Database: Room (SQLite)
- Background Tasks: WorkManager
- Asynchronous: Coroutines & Flow
- Architecture: MVVM
- Dependency Management: Gradle Version Catalog (libs.versions.toml)
- Annotation Processing: KSP (Kotlin Symbol Processing)
セットアップ 🚀
- Android Studio (Ladybug 以降推奨) でプロジェクトを開きます。
- プロジェクトを Gradle Sync します。
- Android デバイスまたはエミュレータで実行します。
更新履歴 📅
[Lunch Memo 2.0] - 2026.07.01
- 選択式入力の導入: 自由入力に加え、あらかじめ登録したアイテムをタップして選択できる機能を追加。
- 3コンポーネント構成: メモを「選択」「数字」「文字」の3つの要素に分割。用途に合わせて自由にカスタマイズ可能に。
- 設定画面の強化: コンポーネントごとの入力モード切替や選択肢の編集機能を実装。
- 内部改善: データベースのマイグレーション(v4)と、設定保存のためのシリアライズ処理(GSON)を導入。
ライセンス 📝
このプロジェクトは MIT ライセンスの下で公開されています。
Top comments (0)