Hoofdscherm omgebouwd naar wekker-UI: klok, geplande wekkers, dienstinfo; testwekker verplaatst uit de UI naar launch-intent

This commit is contained in:
eddy 2026-10-14 20:19:23 +02:00
commit 6c0fa97e50
5 changed files with 146 additions and 70 deletions

View file

@ -26,16 +26,14 @@ automatisch zet op basis van de Nextcloud CalDAV-agenda **EBS-Diensten**.
1. Bouw de APK: `./gradlew :app:assembleDebug` 1. Bouw de APK: `./gradlew :app:assembleDebug`
→ `app/build/outputs/apk/debug/app-debug.apk` → `app/build/outputs/apk/debug/app-debug.apk`
2. Zet de APK op de telefoon en installeer (sideload). 2. Zet de APK op de telefoon en installeer (sideload).
3. Open de app en vul bij **Instellingen** in: 3. Open de app → **Instellingen** → vul in:
- Agenda-URL: `https://nextcloud.eddydevink.nl/remote.php/dav/calendars/eddy/ebs_diensten-1/` - Agenda-URL: `https://nextcloud.eddydevink.nl/remote.php/dav/calendars/eddy/ebs_diensten-1/`
- Gebruikersnaam: `eddy` - Gebruikersnaam: `eddy`
- App-wachtwoord: een Nextcloud-app-wachtwoord - App-wachtwoord: een Nextcloud-app-wachtwoord
(Nextcloud → Beveiliging → App-wachtwoorden; zie de Obsidian-vault voor (Nextcloud → Beveiliging → App-wachtwoorden; zie de Obsidian-vault voor
de bestaande app-wachtwoorden — niet in de app hardcoden) de bestaande app-wachtwoorden — niet in de app hardcoden)
4. Druk op **Opslaan** en daarna op **Nu controleren** om te testen. 4. **Opslaan**, terug op het hoofdscherm **Nu controleren** om te testen.
5. Verleen de gevraagde machtigingen via de knoppen onder **Machtigingen**: 5. Verleen de meldingenmachtiging bij de eerste start (systeemdialoog).
meldingen, exacte alarmen, batterijoptimalisatie uitzonderen en (Android 14+)
volledig scherm.
## Machtigingen ## Machtigingen
@ -52,7 +50,8 @@ automatisch zet op basis van de Nextcloud CalDAV-agenda **EBS-Diensten**.
## Architectuur ## Architectuur
``` ```
MainActivity status + instellingen, knop "Nu controleren" MainActivity hoofdscherm: status + "Nu controleren" + "Wekker testen" + link naar instellingen
SettingsActivity instellingen: CalDAV-url, gebruiker, app-wachtwoord (+ uitleg)
AlarmActivity volledig-scherm wekker (geluid, trillen, snooze/dismiss) AlarmActivity volledig-scherm wekker (geluid, trillen, snooze/dismiss)
App kanalen + dagelijkse 12:00-controle bij opstart App kanalen + dagelijkse 12:00-controle bij opstart
daily check: daily check:
@ -69,6 +68,10 @@ BootReceiver na reboot: 12:00-controle + geplande wekkers herstellen
Prefs instellingen/status, versleuteld (EncryptedSharedPreferences) Prefs instellingen/status, versleuteld (EncryptedSharedPreferences)
``` ```
Hoofdscherm en instellingen zijn bewust gescheiden; installatiehulp via
launch-intent-extra's (`--es url/user/pass`) slaat de config op en draait
direct een controle, zonder UI-interactie.
Details per bestand: Details per bestand:
- `core/ShiftPlanner.kt` — pure logica: kwartier-afronding en wekkerplanning - `core/ShiftPlanner.kt` — pure logica: kwartier-afronding en wekkerplanning

View file

@ -5,6 +5,8 @@ import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@ -15,14 +17,27 @@ import kotlinx.coroutines.launch
import nl.eddydevink.buschauffeurswekker.core.AlarmScheduler import nl.eddydevink.buschauffeurswekker.core.AlarmScheduler
import nl.eddydevink.buschauffeurswekker.core.Prefs import nl.eddydevink.buschauffeurswekker.core.Prefs
import nl.eddydevink.buschauffeurswekker.core.ShiftCheck import nl.eddydevink.buschauffeurswekker.core.ShiftCheck
import java.time.Instant import nl.eddydevink.buschauffeurswekker.core.ShiftPlanner
import java.time.ZoneId import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
/** Hoofdscherm: status + acties. Instellingen staan in [SettingsActivity]. */ /**
* Hoofdscherm: klok + de geplande wekkers van morgen. Instellingen zitten in
* [SettingsActivity]; de testwekker is alleen bereikbaar via launch-intent
* (--ez test true), niet via de productie-UI.
*/
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var prefs: Prefs private lateinit var prefs: Prefs
private val handler = Handler(Looper.getMainLooper())
private val clockTick = object : Runnable {
override fun run() {
updateClock()
handler.postDelayed(this, 1000)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -32,32 +47,37 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermission() requestNotificationPermission()
findViewById<MaterialButton>(R.id.btn_check).setOnClickListener { runCheck() } findViewById<MaterialButton>(R.id.btn_check).setOnClickListener { runCheck() }
findViewById<MaterialButton>(R.id.btn_test).setOnClickListener {
AlarmScheduler.scheduleTest(this)
toast("Testwekker gaat over 10 seconden af")
}
findViewById<MaterialButton>(R.id.btn_settings).setOnClickListener { findViewById<MaterialButton>(R.id.btn_settings).setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java)) startActivity(Intent(this, SettingsActivity::class.java))
} }
AlarmScheduler.scheduleNoonCheck(this) AlarmScheduler.scheduleNoonCheck(this)
// Config via launch-intent (installatiehulp): // Installatiehulp/config zonder UI-interactie:
// adb shell am start ... --es url "..." --es user "..." --es pass "..." // --es url "…" --es user "…" --es pass "…" → config opslaan + controleren
// --ez test true → testwekker (debugging)
handleSetupIntent(intent) handleSetupIntent(intent)
if (intent?.getBooleanExtra(EXTRA_TEST, false) == true) {
AlarmScheduler.scheduleTest(this)
toast("Testwekker gaat over 10 seconden af")
}
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
handleSetupIntent(intent) handleSetupIntent(intent)
if (intent.getBooleanExtra(EXTRA_TEST, false)) {
AlarmScheduler.scheduleTest(this)
toast("Testwekker gaat over 10 seconden af")
}
} }
/** Slaat CalDAV-config van intent-extra's op en draait direct een controle. */ /** Slaat CalDAV-config van intent-extra's op en draait direct een controle. */
private fun handleSetupIntent(intent: Intent?) { private fun handleSetupIntent(intent: Intent) {
val url = intent?.getStringExtra(EXTRA_SETUP_URL) val url = intent.getStringExtra(EXTRA_SETUP_URL)
val user = intent?.getStringExtra(EXTRA_SETUP_USER) val user = intent.getStringExtra(EXTRA_SETUP_USER)
val pass = intent?.getStringExtra(EXTRA_SETUP_PASS) val pass = intent.getStringExtra(EXTRA_SETUP_PASS)
if (url == null && user == null && pass == null) return if (url == null && user == null && pass == null) return
if (!url.isNullOrBlank()) prefs.calendarUrl = url.trim() if (!url.isNullOrBlank()) prefs.calendarUrl = url.trim()
if (!user.isNullOrBlank()) prefs.username = user.trim() if (!user.isNullOrBlank()) prefs.username = user.trim()
@ -72,24 +92,40 @@ class MainActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
renderStatus() renderStatus()
updateClock()
handler.removeCallbacks(clockTick)
handler.post(clockTick)
}
override fun onPause() {
handler.removeCallbacks(clockTick)
super.onPause()
}
private fun updateClock() {
val now = LocalTime.now()
findViewById<TextView>(R.id.tv_clock).text = HH_MM.format(now)
findViewById<TextView>(R.id.tv_date).text = DATE.format(java.time.LocalDate.now())
} }
private fun renderStatus() { private fun renderStatus() {
val p = prefs val p = prefs
val last = p.lastCheckedAtMillis.takeIf { it > 0 }?.let { val (_, alarms) = p.loadPlan()
Instant.ofEpochMilli(it) val next = findViewById<TextView>(R.id.tv_next_alarms)
.atZone(ZoneId.systemDefault()) val info = findViewById<TextView>(R.id.tv_shift_info)
.format(DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm"))
} ?: "nooit" if (alarms.isNotEmpty()) {
findViewById<TextView>(R.id.tv_status).text = buildString { next.text = alarms.joinToString("\n") { "${it.name} ${HH_MM.format(it.time)}" }
append("Dagelijkse controle: elke dag om 12:00\n") val shift = p.lastShiftStartIso?.let { runCatching { LocalDateTime.parse(it) }.getOrNull() }
append("Laatst gecontroleerd: $last\n\n") info.text = if (shift != null) {
append(p.lastStatusTxt ?: "Nog niet gecontroleerd.") val rounded = ShiftPlanner.roundDownToQuarter(shift.toLocalTime())
val (day, alarms) = p.loadPlan() "Dienst om ${HH_MM.format(shift.toLocalTime())} · afgerond ${HH_MM.format(rounded)}"
if (day != null && alarms.isNotEmpty()) { } else {
append("\n\nGepland voor $day:") p.lastStatusTxt.orEmpty()
alarms.forEach { append("\n ${it.name} om ${it.time.toLocalTime()}") }
} }
} else {
next.text = "Geen wekkers"
info.text = p.lastStatusTxt ?: "Nog niet gecontroleerd"
} }
} }
@ -125,5 +161,8 @@ class MainActivity : AppCompatActivity() {
const val EXTRA_SETUP_URL = "url" const val EXTRA_SETUP_URL = "url"
const val EXTRA_SETUP_USER = "user" const val EXTRA_SETUP_USER = "user"
const val EXTRA_SETUP_PASS = "pass" const val EXTRA_SETUP_PASS = "pass"
const val EXTRA_TEST = "test"
val HH_MM = DateTimeFormatter.ofPattern("HH:mm")
val DATE = DateTimeFormatter.ofPattern("EEEE d MMMM")
} }
} }

View file

@ -61,6 +61,11 @@ class Prefs(context: Context) {
get() = sp.getString(KEY_NOON_RUN, null) get() = sp.getString(KEY_NOON_RUN, null)
set(value) = sp.edit().putString(KEY_NOON_RUN, value).apply() set(value) = sp.edit().putString(KEY_NOON_RUN, value).apply()
/** Laatste gevonden dienstaanvang (ISO LocalDateTime) — voor de weergave op het hoofdscherm. */
var lastShiftStartIso: String?
get() = sp.getString(KEY_SHIFT_START, null)
set(value) = sp.edit().putString(KEY_SHIFT_START, value).apply()
private var plannedDayIso: String? private var plannedDayIso: String?
get() = sp.getString(KEY_PLAN_DAY, null) get() = sp.getString(KEY_PLAN_DAY, null)
set(value) = sp.edit().putString(KEY_PLAN_DAY, value).apply() set(value) = sp.edit().putString(KEY_PLAN_DAY, value).apply()
@ -114,5 +119,6 @@ class Prefs(context: Context) {
const val KEY_NOON_RUN = "last_noon_run" const val KEY_NOON_RUN = "last_noon_run"
const val KEY_PLAN_DAY = "plan_day" const val KEY_PLAN_DAY = "plan_day"
const val KEY_PLAN_ALARMS = "plan_alarms" const val KEY_PLAN_ALARMS = "plan_alarms"
const val KEY_SHIFT_START = "shift_start"
} }
} }

View file

@ -55,6 +55,7 @@ object ShiftCheck {
if (starts.isEmpty()) { if (starts.isEmpty()) {
AlarmScheduler.cancelAll(context) AlarmScheduler.cancelAll(context)
prefs.clearPlan() prefs.clearPlan()
prefs.lastShiftStartIso = null
val msg = "Morgen ($tomorrow) geen dienst — er zijn geen wekkers gezet" val msg = "Morgen ($tomorrow) geen dienst — er zijn geen wekkers gezet"
prefs.lastStatusTxt = msg prefs.lastStatusTxt = msg
prefs.lastCheckedAtMillis = System.currentTimeMillis() prefs.lastCheckedAtMillis = System.currentTimeMillis()
@ -63,6 +64,7 @@ object ShiftCheck {
} }
val shiftStart = starts.minOrNull()!! val shiftStart = starts.minOrNull()!!
prefs.lastShiftStartIso = shiftStart.toString()
val plan = ShiftPlanner.plan(shiftStart) val plan = ShiftPlanner.plan(shiftStart)
plan.forEach { AlarmScheduler.scheduleAlarm(context, it) } plan.forEach { AlarmScheduler.scheduleAlarm(context, it) }
prefs.storePlan(tomorrow, plan) prefs.storePlan(tomorrow, plan)

View file

@ -1,44 +1,70 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fillViewport="true"> android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="24dp">
<LinearLayout <TextView
android:id="@+id/tv_clock"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:textSize="72sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="?android:attr/textColorSecondary" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="40dp"
android:layout_marginBottom="24dp"
android:background="?android:attr/listDivider" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="VOLGENDE WEKKERS"
android:textSize="12sp"
android:textColor="?android:attr/textColorSecondary" />
<TextView
android:id="@+id/tv_next_alarms"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center"
android:textSize="22sp" />
<TextView
android:id="@+id/tv_shift_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:textSize="14sp"
android:textColor="?android:attr/textColorSecondary" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_check"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:layout_marginTop="40dp"
android:padding="16dp"> android:text="Nu controleren" />
<TextView <com.google.android.material.button.MaterialButton
android:id="@+id/tv_status" android:id="@+id/btn_settings"
android:layout_width="match_parent" style="@style/Widget.Material3.Button.TextButton"
android:layout_height="wrap_content" android:layout_width="match_parent"
android:fontFamily="monospace" android:layout_height="wrap_content"
android:textIsSelectable="true" android:layout_marginTop="4dp"
android:textSize="14sp" /> android:text="Instellingen" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_check"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Nu controleren" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_test"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Wekker testen (over 10 seconden)" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_settings"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Instellingen" />
</LinearLayout>
</ScrollView>