Compare commits

...

10 commits

Author SHA1 Message Date
2c850dbd0b
feat: show date in alarm card when set
Display formatted date (e.g. 'Do., 26.02.2026') in alarm list card
when alarm has a specific date. Date shown in primary color between
time and label.
2026-02-24 16:27:44 +01:00
f498ecdef4
fix: correct Room migration - use DEFAULT '' to match entity schema
Migration 1→2 had DEFAULT NULL but @ColumnInfo(defaultValue="") expects
DEFAULT ''. Room schema validation failed on startup.

Since SQLite can't alter column defaults, add migration 2→3 that recreates
the alarms table with the correct DEFAULT '' for the date column.
2026-02-24 16:01:07 +01:00
c78cb8a77d
feat: add optional date field to schedule alarms on specific dates
Add YYYY-MM-DD date parameter to /set endpoint. When provided, the alarm
fires at the exact date instead of the next occurrence of hour:minute.

- AlarmEntity: add nullable date column
- AlarmDatabase: bump to version 2, add migration 1→2
- AppModule: register migration
- KtorService: parse date, validate it's not in the past
- MainViewModel: accept optional date in createAlarm()
- README: document new date parameter
2026-02-24 15:48:05 +01:00
5793af5772
feat: shrink bell icon to 60% with padding 2026-02-23 14:26:07 +01:00
16ed756e4f
ci: sign with consistent release keystore 2026-02-23 14:15:29 +01:00
ad602f48c3
ci: add one-time keystore generator 2026-02-23 14:14:35 +01:00
fe3800866f
feat: update app icon to fire bell 2026-02-23 14:05:43 +01:00
8a30aac16e
fix: make gradlew executable 2026-02-22 02:41:02 +01:00
311821b58e
ci: add GitHub Actions build workflow 2026-02-22 02:39:03 +01:00
Helios
6b4ee77cba feat: add /last-alarm endpoint
Saves the last fired alarm (hour, minute, label, timestamp) to
SharedPreferences when an alarm fires. New GET /last-alarm endpoint
returns the last alarm info or 404 if none has fired yet.
2026-02-18 16:01:51 +01:00
28 changed files with 231 additions and 15 deletions

38
.github/workflows/build-apk.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: Build Release APK
on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Decode keystore
run: |
echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > $RUNNER_TEMP/helios-release.jks
- name: Build release APK
env:
RELEASE_KEYSTORE_PATH: ${{ runner.temp }}/helios-release.jks
RELEASE_KEYSTORE_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
RELEASE_KEY_ALIAS: helios
RELEASE_KEY_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
run: ./gradlew assembleRelease
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: helios-alarm-clock-debug
path: app/build/outputs/apk/release/*.apk
retention-days: 7

35
.github/workflows/generate-keystore.yml vendored Normal file
View file

@ -0,0 +1,35 @@
name: Generate Keystore (run once)
on:
workflow_dispatch:
jobs:
generate:
runs-on: ubuntu-latest
steps:
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Generate keystore
run: |
keytool -genkeypair \
-keystore helios-release.jks \
-alias helios \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-storepass helios123 \
-keypass helios123 \
-dname "CN=Helios, OU=Helios, O=Helios, L=Berlin, ST=Berlin, C=DE"
echo "KEYSTORE_B64=$(base64 -w 0 helios-release.jks)" >> $GITHUB_OUTPUT
id: keygen
- name: Upload keystore
uses: actions/upload-artifact@v4
with:
name: helios-release-keystore
path: helios-release.jks
retention-days: 1

View file

@ -31,6 +31,14 @@ Response: `201 Created`
`hour` must be 0-23, `minute` must be 0-59. `label` is optional (defaults to empty). `hour` must be 0-23, `minute` must be 0-59. `label` is optional (defaults to empty).
To set an alarm for a specific future date, add an optional `date` field in `YYYY-MM-DD` format. Without `date`, the alarm fires at the next occurrence of the given time (today or tomorrow).
```bash
curl -X POST http://PHONE_IP:8080/set \
-H "Content-Type: application/json" \
-d '{"hour": 8, "minute": 0, "label": "Vacation start", "date": "2026-10-08"}'
```
### Remove an alarm ### Remove an alarm
```bash ```bash

View file

@ -26,10 +26,12 @@ android {
signingConfigs { signingConfigs {
create("release") { create("release") {
storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore") val keystorePath = System.getenv("RELEASE_KEYSTORE_PATH")
storePassword = "android" storeFile = if (keystorePath != null) file(keystorePath)
keyAlias = "androiddebugkey" else file(System.getProperty("user.home") + "/.android/debug.keystore")
keyPassword = "android" storePassword = System.getenv("RELEASE_KEYSTORE_PASS") ?: "android"
keyAlias = System.getenv("RELEASE_KEY_ALIAS") ?: "androiddebugkey"
keyPassword = System.getenv("RELEASE_KEY_PASS") ?: "android"
} }
} }
buildTypes { buildTypes {

View file

@ -2,12 +2,45 @@ package com.example.helios_alarm_clock.data
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(entities = [AlarmEntity::class], version = 1, exportSchema = true) @Database(entities = [AlarmEntity::class], version = 3, exportSchema = true)
abstract class AlarmDatabase : RoomDatabase() { abstract class AlarmDatabase : RoomDatabase() {
abstract fun alarmDao(): AlarmDao abstract fun alarmDao(): AlarmDao
companion object { companion object {
const val NAME = "helios_alarms.db" const val NAME = "helios_alarms.db"
// Fresh install path: add date column with correct default
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE alarms ADD COLUMN date TEXT DEFAULT ''")
}
}
// Fix path: first install had DEFAULT NULL instead of DEFAULT ''
// SQLite can't alter column defaults, so recreate the table
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE alarms_new (
id TEXT NOT NULL PRIMARY KEY,
hour INTEGER NOT NULL,
minute INTEGER NOT NULL,
label TEXT NOT NULL,
triggerTimeMillis INTEGER NOT NULL,
date TEXT DEFAULT ''
)
""".trimIndent())
db.execSQL("""
INSERT INTO alarms_new (id, hour, minute, label, triggerTimeMillis, date)
SELECT id, hour, minute, label, triggerTimeMillis, COALESCE(date, '')
FROM alarms
""".trimIndent())
db.execSQL("DROP TABLE alarms")
db.execSQL("ALTER TABLE alarms_new RENAME TO alarms")
}
}
} }
} }

View file

@ -1,5 +1,6 @@
package com.example.helios_alarm_clock.data package com.example.helios_alarm_clock.data
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -12,5 +13,7 @@ data class AlarmEntity(
val hour: Int, val hour: Int,
val minute: Int, val minute: Int,
val label: String, val label: String,
val triggerTimeMillis: Long val triggerTimeMillis: Long,
@ColumnInfo(defaultValue = "")
val date: String? = null
) )

View file

@ -22,7 +22,9 @@ object AppModule {
context, context,
AlarmDatabase::class.java, AlarmDatabase::class.java,
AlarmDatabase.NAME AlarmDatabase.NAME
).build() )
.addMigrations(AlarmDatabase.MIGRATION_1_2, AlarmDatabase.MIGRATION_2_3)
.build()
@Provides @Provides
@Singleton @Singleton

View file

@ -22,6 +22,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Calendar
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -55,13 +56,17 @@ class AlarmRingService : Service() {
startSound() startSound()
// Delete the fired alarm from the database // Save as last alarm and delete from the database
if (alarmId.isNotEmpty()) { if (alarmId.isNotEmpty()) {
scope.launch { scope.launch {
try { try {
val alarm = alarmDao.getById(alarmId)
if (alarm != null) {
saveLastAlarm(alarm.hour, alarm.minute, alarm.label)
}
alarmDao.deleteById(alarmId) alarmDao.deleteById(alarmId)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to delete alarm $alarmId", e) Log.e(TAG, "Failed to process alarm $alarmId", e)
} }
} }
} }
@ -142,6 +147,17 @@ class AlarmRingService : Service() {
.build() .build()
} }
private fun saveLastAlarm(hour: Int, minute: Int, label: String) {
val prefs = getSharedPreferences("last_alarm", Context.MODE_PRIVATE)
val now = Calendar.getInstance()
prefs.edit()
.putInt("hour", hour)
.putInt("minute", minute)
.putString("label", label)
.putLong("firedAt", now.timeInMillis)
.apply()
}
companion object { companion object {
private const val TAG = "AlarmRingService" private const val TAG = "AlarmRingService"
const val NOTIFICATION_ID = 2 const val NOTIFICATION_ID = 2

View file

@ -36,6 +36,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.util.Calendar import java.util.Calendar
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -126,13 +129,40 @@ class KtorService : Service() {
} }
val id = UUID.randomUUID().toString() val id = UUID.randomUUID().toString()
// Parse date outside apply block so return@post works at the correct scope
val parsedDate: LocalDate? = if (req.date != null) {
try {
LocalDate.parse(req.date, DateTimeFormatter.ISO_LOCAL_DATE)
} catch (e: DateTimeParseException) {
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("date must be in YYYY-MM-DD format (e.g. 2026-10-08)")
)
return@post
}
} else null
val now = Calendar.getInstance() val now = Calendar.getInstance()
val trigger = Calendar.getInstance().apply { val trigger = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, req.hour) set(Calendar.HOUR_OF_DAY, req.hour)
set(Calendar.MINUTE, req.minute) set(Calendar.MINUTE, req.minute)
set(Calendar.SECOND, 0) set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0) set(Calendar.MILLISECOND, 0)
if (before(now)) add(Calendar.DAY_OF_YEAR, 1) if (parsedDate != null) {
set(Calendar.YEAR, parsedDate.year)
set(Calendar.MONTH, parsedDate.monthValue - 1)
set(Calendar.DAY_OF_MONTH, parsedDate.dayOfMonth)
} else {
if (before(now)) add(Calendar.DAY_OF_YEAR, 1)
}
}
if (parsedDate != null && trigger.before(now)) {
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("Alarm time is in the past")
)
return@post
} }
val entity = AlarmEntity( val entity = AlarmEntity(
@ -140,7 +170,8 @@ class KtorService : Service() {
hour = req.hour, hour = req.hour,
minute = req.minute, minute = req.minute,
label = req.label, label = req.label,
triggerTimeMillis = trigger.timeInMillis triggerTimeMillis = trigger.timeInMillis,
date = req.date
) )
alarmDao.insert(entity) alarmDao.insert(entity)
@ -181,6 +212,29 @@ class KtorService : Service() {
val alarms = alarmDao.getAll() val alarms = alarmDao.getAll()
call.respond(alarms) call.respond(alarms)
} }
get("/last-alarm") {
val prefs = this@KtorService.getSharedPreferences(
"last_alarm",
Context.MODE_PRIVATE
)
val firedAt = prefs.getLong("firedAt", -1)
if (firedAt == -1L) {
call.respond(
HttpStatusCode.NotFound,
ErrorResponse("No alarm has fired yet")
)
} else {
call.respond(
LastAlarmResponse(
hour = prefs.getInt("hour", 0),
minute = prefs.getInt("minute", 0),
label = prefs.getString("label", "") ?: "",
firedAt = firedAt
)
)
}
}
} }
}.also { it.start(wait = false) } }.also { it.start(wait = false) }
} }
@ -218,7 +272,7 @@ class KtorService : Service() {
} }
@Serializable @Serializable
data class SetAlarmRequest(val hour: Int, val minute: Int, val label: String = "") data class SetAlarmRequest(val hour: Int, val minute: Int, val label: String = "", val date: String? = null)
@Serializable @Serializable
data class RemoveAlarmRequest(val id: String) data class RemoveAlarmRequest(val id: String)
@ -231,3 +285,6 @@ data class StatusResponse(val status: String)
@Serializable @Serializable
data class ErrorResponse(val error: String) data class ErrorResponse(val error: String)
@Serializable
data class LastAlarmResponse(val hour: Int, val minute: Int, val label: String, val firedAt: Long)

View file

@ -55,7 +55,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Calendar import java.util.Calendar
import java.util.Locale
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.helios_alarm_clock.data.AlarmEntity import com.example.helios_alarm_clock.data.AlarmEntity
@ -262,6 +265,17 @@ fun AlarmCard(
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
if (!alarm.date.isNullOrBlank()) {
val formattedDate = runCatching {
val parsed = LocalDate.parse(alarm.date, DateTimeFormatter.ISO_LOCAL_DATE)
parsed.format(DateTimeFormatter.ofPattern("EEE, dd.MM.yyyy", Locale.GERMAN))
}.getOrElse { alarm.date }
Text(
text = formattedDate,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
if (alarm.label.isNotBlank()) { if (alarm.label.isNotBlank()) {
Text( Text(
text = alarm.label, text = alarm.label,

View file

@ -30,7 +30,7 @@ class MainViewModel @Inject constructor(
val port: Int = KtorService.PORT val port: Int = KtorService.PORT
fun createAlarm(hour: Int, minute: Int, label: String) { fun createAlarm(hour: Int, minute: Int, label: String, date: String? = null) {
viewModelScope.launch { viewModelScope.launch {
val now = Calendar.getInstance() val now = Calendar.getInstance()
val trigger = Calendar.getInstance().apply { val trigger = Calendar.getInstance().apply {
@ -38,14 +38,22 @@ class MainViewModel @Inject constructor(
set(Calendar.MINUTE, minute) set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0) set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0) set(Calendar.MILLISECOND, 0)
if (before(now)) add(Calendar.DAY_OF_YEAR, 1) if (date != null) {
val parsedDate = java.time.LocalDate.parse(date)
set(Calendar.YEAR, parsedDate.year)
set(Calendar.MONTH, parsedDate.monthValue - 1)
set(Calendar.DAY_OF_MONTH, parsedDate.dayOfMonth)
} else {
if (before(now)) add(Calendar.DAY_OF_YEAR, 1)
}
} }
val entity = AlarmEntity( val entity = AlarmEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
hour = hour, hour = hour,
minute = minute, minute = minute,
label = label, label = label,
triggerTimeMillis = trigger.timeInMillis triggerTimeMillis = trigger.timeInMillis,
date = date
) )
alarmDao.insert(entity) alarmDao.insert(entity)
alarmScheduler.schedule(entity) alarmScheduler.schedule(entity)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 658 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 658 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 414 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 414 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 992 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 992 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

0
gradlew vendored Normal file → Executable file
View file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 343 KiB

Before After
Before After