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).
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
```bash

View file

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

View file

@ -2,12 +2,45 @@ package com.example.helios_alarm_clock.data
import androidx.room.Database
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 fun alarmDao(): AlarmDao
companion object {
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
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
@ -12,5 +13,7 @@ data class AlarmEntity(
val hour: Int,
val minute: Int,
val label: String,
val triggerTimeMillis: Long
val triggerTimeMillis: Long,
@ColumnInfo(defaultValue = "")
val date: String? = null
)

View file

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

View file

@ -22,6 +22,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.Calendar
import javax.inject.Inject
@AndroidEntryPoint
@ -55,13 +56,17 @@ class AlarmRingService : Service() {
startSound()
// Delete the fired alarm from the database
// Save as last alarm and delete from the database
if (alarmId.isNotEmpty()) {
scope.launch {
try {
val alarm = alarmDao.getById(alarmId)
if (alarm != null) {
saveLastAlarm(alarm.hour, alarm.minute, alarm.label)
}
alarmDao.deleteById(alarmId)
} 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()
}
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 {
private const val TAG = "AlarmRingService"
const val NOTIFICATION_ID = 2

View file

@ -36,6 +36,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
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.UUID
import javax.inject.Inject
@ -126,13 +129,40 @@ class KtorService : Service() {
}
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 trigger = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, req.hour)
set(Calendar.MINUTE, req.minute)
set(Calendar.SECOND, 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(
@ -140,7 +170,8 @@ class KtorService : Service() {
hour = req.hour,
minute = req.minute,
label = req.label,
triggerTimeMillis = trigger.timeInMillis
triggerTimeMillis = trigger.timeInMillis,
date = req.date
)
alarmDao.insert(entity)
@ -181,6 +212,29 @@ class KtorService : Service() {
val alarms = alarmDao.getAll()
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) }
}
@ -218,7 +272,7 @@ class KtorService : Service() {
}
@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
data class RemoveAlarmRequest(val id: String)
@ -231,3 +285,6 @@ data class StatusResponse(val status: String)
@Serializable
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.DialogProperties
import androidx.core.content.ContextCompat
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Calendar
import java.util.Locale
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.helios_alarm_clock.data.AlarmEntity
@ -262,6 +265,17 @@ fun AlarmCard(
fontWeight = FontWeight.Light,
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()) {
Text(
text = alarm.label,

View file

@ -30,7 +30,7 @@ class MainViewModel @Inject constructor(
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 {
val now = Calendar.getInstance()
val trigger = Calendar.getInstance().apply {
@ -38,14 +38,22 @@ class MainViewModel @Inject constructor(
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 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(
id = UUID.randomUUID().toString(),
hour = hour,
minute = minute,
label = label,
triggerTimeMillis = trigger.timeInMillis
triggerTimeMillis = trigger.timeInMillis,
date = date
)
alarmDao.insert(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