feat(app): a Pomodoro timer on Home, in the notification and in the top bar

Home has a Pomodoro timer (decided 2026-10-07, after Goodtime): the
profile it runs, chosen there while no timer is set, the time left of
the phase, and Start, Pause or Resume, Skip and Stop. While studying, a
small countdown sits in the study screen's header, and a tap pauses it
or goes on. A phase that ends leads to the next, which starts by itself
where the profile says so and otherwise waits, paused at its full
length.

Profiles follow the person to every device, in their settings (schema
4, still written as schema 3 while the profiles are as they come, so
older apps go on reading the settings): Classic, 25 minutes of focus,
5 of break and 15 after four, and 52/17 without long breaks, built in;
both can be changed, more added and deleted, from Home's card or from
Settings. A profile can start breaks and focus by themselves, and keep
the device quiet while focusing. The running timer is the device's own,
kept in the person's folder: closed and opened again, the app catches
up with the phases that ran out meanwhile.

Android: the app's own foreground service (Kotlin, specialUse, as
Goodtime does) shows an ongoing notification counting the phase down,
with Pause or Resume, Skip and Stop; on Android 16 it is a Live Update,
its progress bar the profile's cycle, each phase a segment, focus in
cobalt and breaks in copper. An exact alarm (USE_EXACT_ALARM) alerts
with sound and vibration when the phase ends, also with the app
closed, and Do Not Disturb goes on while focusing once the person
allowed it, the filter before given back afterwards. No notification
plugin: flutter_local_notifications has Live Updates only in a
prerelease that needs compileSdk 37; androidx.core 1.18, which the
build resolved already, has every API needed.

Linux, over the session bus (package:dbus, already in the build): where
the desktop shows icons in its top bar (GNOME with the AppIndicator
extension, which Ubuntu has built in; KDE) the timer counts down there,
the buttons in the icon's menu, as a StatusNotifierItem written in Dart
on the names and paths the snap's desktop plug allows; elsewhere a
notification with the buttons says until when the phase runs. An alert
with sound ends each phase while the app runs, and GNOME's banners stay
off while focusing, through gsettings. xdg-desktop-portal's Background
status, the planned fallback, serves Flatpaks only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-07 02:54:52 +02:00
commit 918e10bb2b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
47 changed files with 4517 additions and 49 deletions

View file

@ -81,3 +81,10 @@ kotlin {
flutter {
source = "../.."
}
dependencies {
// NotificationCompat's ProgressStyle and promoted notifications (Android 16's
// Live Updates) for the Pomodoro timer, from v1.17 on; the build resolves this
// version already. v1.19 would need compileSdk 37.
implementation("androidx.core:core:1.18.0")
}

View file

@ -2,6 +2,19 @@
<!-- The app syncs with its server: release builds need this too (Flutter's
template grants it to debug and profile builds only). -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- The Pomodoro timer (PomodoroService): its notification, a foreground
service while it runs (no type covers timers: specialUse, as Goodtime),
Android 16's Live Update, the exact alarm at a phase's end (USE_EXACT_ALARM
is granted on install; Google Play would restrict it, F-Droid doesn't), and
Do Not Disturb while focusing, once the person allows it. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.USE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<application
android:label="Halkyon Learn"
android:name="${applicationName}"
@ -40,6 +53,17 @@
<data android:scheme="app.halkyon.learn" />
</intent-filter>
</activity>
<service
android:name=".PomodoroService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="A Pomodoro timer the person started, counting down its phase" />
</service>
<receiver
android:name=".PomodoroAlarmReceiver"
android:exported="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View file

@ -1,5 +1,69 @@
package app.halkyon.learn
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
private var askedToNotify = false
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val messenger = flutterEngine.dartExecutor.binaryMessenger
// The Pomodoro timer's side of the system (PomodoroService).
MethodChannel(messenger, "app.halkyon.learn/pomodoro").setMethodCallHandler { call, result ->
when (call.method) {
"show" -> {
askToNotify()
PomodoroService.show(this, call.arguments as String)
result.success(null)
}
"clear" -> {
PomodoroService.clear(this)
result.success(null)
}
"mayKeepQuiet" -> {
val allowed = Quiet.allowed(this)
if (!allowed && call.arguments == true) {
startActivity(Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS))
}
result.success(allowed)
}
else -> result.notImplemented()
}
}
EventChannel(messenger, "app.halkyon.learn/pomodoro/buttons").setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
PomodoroService.buttons = { button -> runOnUiThread { events.success(button) } }
}
override fun onCancel(arguments: Any?) {
PomodoroService.buttons = null
}
},
)
}
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
PomodoroService.buttons = null
super.cleanUpFlutterEngine(flutterEngine)
}
/** Asks once per launch for leave to notify (Android 13 on), which the timer needs. */
private fun askToNotify() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
!askedToNotify &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
askedToNotify = true
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
}
}
}

View file

@ -0,0 +1,312 @@
package app.halkyon.learn
import android.Manifest
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import org.json.JSONObject
// The Pomodoro timer's side of the system (decided 2026-10-07): an ongoing
// notification counting the phase down, with Pause or Resume, Skip and Stop;
// on Android 16 a Live Update whose progress bar is the profile's cycle, the
// phases as segments; an exact alarm that alerts with sound and vibration when
// the phase ends, also with the app closed; and Do Not Disturb while focusing.
//
// The app (Dart) keeps the timer and tells this service what to show (a
// notice, as JSON, already in the app's language). A foreground service keeps
// the process, and with it the app's timer, alive while a phase runs; the
// buttons go to the service, which hands them to the app.
/** Shows the timer as the app's notices say, in the foreground. */
class PomodoroService : Service() {
companion object {
private const val ACTION_SHOW = "app.halkyon.learn.pomodoro.SHOW"
private const val ACTION_BUTTON = "app.halkyon.learn.pomodoro.BUTTON"
private const val EXTRA_NOTICE = "notice"
private const val EXTRA_BUTTON = "button"
const val TIMER_CHANNEL = "pomodoro_timer"
const val ALERT_CHANNEL = "pomodoro_alerts"
private const val TIMER_ID = 4701
const val ALERT_ID = 4702
/** Hands a button pressed (pause, resume, skip, stop) to the app; null while no app listens. */
@Volatile var buttons: ((String) -> Unit)? = null
/** The service while it runs. */
@Volatile private var running: PomodoroService? = null
/** Shows [notice] (JSON), starting the service if it doesn't run yet. */
fun show(context: Context, notice: String) {
val service = running
if (service != null) {
service.present(notice)
} else {
ContextCompat.startForegroundService(
context,
Intent(context, PomodoroService::class.java)
.setAction(ACTION_SHOW)
.putExtra(EXTRA_NOTICE, notice),
)
}
}
/** Takes the timer away: the notification, the alarm, the quiet. */
fun clear(context: Context) {
PomodoroAlarm.cancel(context)
Quiet.restore(context)
running?.let {
ServiceCompat.stopForeground(it, ServiceCompat.STOP_FOREGROUND_REMOVE)
it.stopSelf()
}
NotificationManagerCompat.from(context).cancel(TIMER_ID)
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
running = this
}
override fun onDestroy() {
running = null
super.onDestroy()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_SHOW -> intent.getStringExtra(EXTRA_NOTICE)?.let(::present)
ACTION_BUTTON -> {
val button = intent.getStringExtra(EXTRA_BUTTON) ?: return START_NOT_STICKY
val app = buttons
when {
app != null -> app(button)
// The app is gone (swiped away): Stop needs it not; the others
// open it, where the timer catches up and goes on.
button == "stop" -> clear(this)
else -> packageManager.getLaunchIntentForPackage(packageName)?.let {
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(it)
}
}
}
}
return START_NOT_STICKY
}
/** Shows [json], schedules its phase's end and keeps the device quiet as it says. */
fun present(json: String) {
val notice = JSONObject(json)
channels(this, notice)
ServiceCompat.startForeground(
this,
TIMER_ID,
notification(notice),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
} else {
0
},
)
PomodoroAlarm.schedule(this, notice)
if (notice.optBoolean("quiet")) Quiet.on(this) else Quiet.restore(this)
}
private fun notification(notice: JSONObject): android.app.Notification {
val endsAt = if (notice.isNull("endsAt")) null else notice.getLong("endsAt")
val actions = notice.getJSONObject("actions")
val builder =
NotificationCompat.Builder(this, TIMER_CHANNEL)
.setSmallIcon(R.drawable.ic_stat_pomodoro)
.setContentTitle(notice.getString("title"))
.setContentText(notice.getString("text"))
.setOngoing(true)
.setSilent(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_STOPWATCH)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setRequestPromotedOngoing(true)
.setContentIntent(openApp(this))
.setStyle(progress(notice))
if (endsAt != null) {
builder.setUsesChronometer(true).setChronometerCountDown(true).setWhen(endsAt).setShowWhen(true)
builder.addAction(0, actions.getString("pause"), button("pause"))
} else {
builder.setShowWhen(false).setShortCriticalText(countdown(notice.getLong("leftMs")))
builder.addAction(0, actions.getString("resume"), button("resume"))
}
builder.addAction(0, actions.getString("skip"), button("skip"))
builder.addAction(0, actions.getString("stop"), button("stop"))
return builder.build()
}
/** The cycle as a progress bar: each phase a segment, focus in cobalt, breaks in copper. */
private fun progress(notice: JSONObject): NotificationCompat.ProgressStyle {
val cycle = notice.getJSONArray("cycle")
val segments =
(0 until cycle.length()).map { i ->
val phase = cycle.getJSONObject(i)
NotificationCompat.ProgressStyle.Segment((phase.getLong("lengthMs") / 1000).toInt())
.setColor(if (phase.getString("phase") == "focus") 0xFF273EB2.toInt() else 0xFFC44800.toInt())
}
return NotificationCompat.ProgressStyle()
.setProgressSegments(segments)
.setProgress((notice.getLong("elapsedMs") / 1000).toInt())
.setStyledByProgress(true)
}
private fun button(name: String): PendingIntent =
PendingIntent.getService(
this,
name.hashCode(),
Intent(this, PomodoroService::class.java).setAction(ACTION_BUTTON).putExtra(EXTRA_BUTTON, name),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
/** What is left as a countdown, as the status bar chip shows it while paused. */
private fun countdown(leftMs: Long): String {
val seconds = (leftMs + 999) / 1000
return "%d:%02d".format(seconds / 60, seconds % 60)
}
/** Opens the app where it was. */
internal fun openApp(context: Context): PendingIntent? =
context.packageManager.getLaunchIntentForPackage(context.packageName)?.let {
PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
/** The timer's channels, named in the app's language: the countdown, silent; the alerts, with sound and vibration. */
internal fun channels(context: Context, notice: JSONObject) {
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(
PomodoroService.TIMER_CHANNEL,
notice.optString("timerName", "Pomodoro"),
NotificationManager.IMPORTANCE_LOW,
).apply { setShowBadge(false) },
)
manager.createNotificationChannel(
NotificationChannel(
PomodoroService.ALERT_CHANNEL,
notice.optString("alertsName", "Pomodoro"),
NotificationManager.IMPORTANCE_HIGH,
).apply { enableVibration(true) },
)
}
/** The alarm at the end of a phase, which alerts even with the app closed. */
internal object PomodoroAlarm {
private fun intent(context: Context, notice: JSONObject? = null): PendingIntent {
val intent = Intent(context, PomodoroAlarmReceiver::class.java)
if (notice != null) {
intent
.putExtra("title", notice.getString("alertTitle"))
.putExtra("text", notice.getString("alertText"))
.putExtra("alertsName", notice.optString("alertsName", "Pomodoro"))
}
return PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
/** Alerts at the end of [notice]'s phase, if it runs; else alerts never. */
fun schedule(context: Context, notice: JSONObject) {
if (notice.isNull("endsAt")) {
cancel(context)
return
}
val alarms = context.getSystemService(AlarmManager::class.java)
val at = notice.getLong("endsAt")
val pending = intent(context, notice)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()) {
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, pending)
} else {
// Without exact alarms, Android picks a moment close to it.
alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, pending)
}
}
fun cancel(context: Context) {
context.getSystemService(AlarmManager::class.java).cancel(intent(context))
}
}
/** Alerts that a phase ended: sound and vibration, the device's quiet over first. */
class PomodoroAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Quiet.restore(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
return
}
channels(context, JSONObject().put("alertsName", intent.getStringExtra("alertsName")))
NotificationManagerCompat.from(context)
.notify(
PomodoroService.ALERT_ID,
NotificationCompat.Builder(context, PomodoroService.ALERT_CHANNEL)
.setSmallIcon(R.drawable.ic_stat_pomodoro)
.setContentTitle(intent.getStringExtra("title"))
.setContentText(intent.getStringExtra("text"))
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setAutoCancel(true)
.setContentIntent(openApp(context))
.build(),
)
}
}
/**
* Do Not Disturb while focusing, where the person allowed it: the device's
* filter before is kept, and given back afterwards, also after a crash (the
* next notice or clear restores it).
*/
internal object Quiet {
private const val PREFERENCES = "pomodoro"
private const val FILTER_BEFORE = "filterBefore"
fun allowed(context: Context): Boolean =
context.getSystemService(NotificationManager::class.java).isNotificationPolicyAccessGranted
fun on(context: Context) {
if (!allowed(context)) return
val manager = context.getSystemService(NotificationManager::class.java)
val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
if (!preferences.contains(FILTER_BEFORE)) {
preferences.edit().putInt(FILTER_BEFORE, manager.currentInterruptionFilter).apply()
}
manager.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_PRIORITY)
}
fun restore(context: Context) {
val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
if (!preferences.contains(FILTER_BEFORE)) return
if (allowed(context)) {
context
.getSystemService(NotificationManager::class.java)
.setInterruptionFilter(preferences.getInt(FILTER_BEFORE, NotificationManager.INTERRUPTION_FILTER_ALL))
}
preferences.edit().remove(FILTER_BEFORE).apply()
}
}

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Pomodoro timer's status bar icon: Lucide's timer, white on nothing,
as Android tints it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#00000000"
android:pathData="M10,2L14,2M12,14L15,11M12,6A8,8 0,1 1,12 22A8,8 0,1 1,12 6Z"
android:strokeWidth="2"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>

View file

@ -790,5 +790,47 @@
"ankiImported": "{count, plural, =1{1 Karteikarte aus Anki importiert.} other{{count} Karteikarten aus Anki importiert.}}",
"ankiImportedSubStudysets": "{count, plural, =1{1 Karte in Unter-Lernsets importiert.} other{{count} Karten in Unter-Lernsets importiert.}}",
"ankiImportedTagged": "{count, plural, =1{1 Karte importiert und nach Unterstapel getaggt.} other{{count} Karten importiert und nach Unterstapel getaggt.}}",
"ankiImportFilesSkipped": "{count, plural, =1{1 Mediendatei konnte nicht importiert werden.} other{{count} Mediendateien konnten nicht importiert werden.}}"
"ankiImportFilesSkipped": "{count, plural, =1{1 Mediendatei konnte nicht importiert werden.} other{{count} Mediendateien konnten nicht importiert werden.}}",
"pomodoroTitle": "Pomodoro",
"pomodoroFocus": "Fokus",
"pomodoroShortBreak": "Kurze Pause",
"pomodoroLongBreak": "Lange Pause",
"pomodoroFocusOf": "Fokus {done} von {total}",
"pomodoroStart": "Starten",
"pomodoroPause": "Pausieren",
"pomodoroResume": "Fortsetzen",
"pomodoroSkip": "Überspringen",
"pomodoroStop": "Beenden",
"pomodoroPaused": "Pausiert",
"pomodoroPausedProfile": "Pausiert · {profile}",
"pomodoroClassic": "Klassisch",
"pomodoroUnnamed": "Profil ohne Namen",
"pomodoroProfiles": "Pomodoro-Profile",
"pomodoroChooseProfile": "Profil wählen",
"pomodoroAddProfile": "Profil hinzufügen",
"pomodoroEditProfile": "Profil bearbeiten",
"pomodoroName": "Name",
"pomodoroFocusLength": "Fokus",
"pomodoroShortBreakLength": "Kurze Pause",
"pomodoroLongBreakLength": "Lange Pause",
"pomodoroMinutes": "{count} Min.",
"pomodoroNoLongBreak": "Keine",
"pomodoroLongBreakAfter": "Fokusphasen vor einer langen Pause",
"pomodoroAutoStartBreaks": "Pausen von selbst starten",
"pomodoroAutoStartFocus": "Fokus nach einer Pause von selbst starten",
"pomodoroDoNotDisturb": "Nicht stören während des Fokus",
"pomodoroDoNotDisturbHint": "Das Gerät bleibt still, während du dich konzentrierst; die Meldung an seinem Ende ist trotzdem zu hören.",
"pomodoroDoNotDisturbDenied": "Die App darf dieses Gerät nicht stummschalten. Erlaube es in den Systemeinstellungen.",
"pomodoroDeleteProfile": "Profil löschen",
"pomodoroSummary": "{focus} Min. Fokus, {shortBreak} Min. Pause",
"pomodoroSummaryLong": "{focus} Min. Fokus, {shortBreak} Min. Pause, {longBreak} Min. nach {after}",
"pomodoroAlertFocusDone": "Zeit für eine Pause",
"pomodoroAlertBreakDone": "Zurück zum Fokus",
"pomodoroAlertStarts": "{phase} hat begonnen.",
"pomodoroAlertWaits": "{phase} wartet auf dich.",
"homeWidgetPomodoro": "Pomodoro-Timer",
"pomodoroAlerts": "Pomodoro-Meldungen",
"pomodoroUntil": "Bis {time}",
"pomodoroLeft": "Noch {left}"
}

View file

@ -946,5 +946,56 @@
"placeholders": {
"count": {"type": "int"}
}
}
},
"pomodoroTitle": "Pomodoro",
"pomodoroFocus": "Focus",
"pomodoroShortBreak": "Short break",
"pomodoroLongBreak": "Long break",
"pomodoroFocusOf": "Focus {done} of {total}",
"@pomodoroFocusOf": {"placeholders": {"done": {"type": "int"}, "total": {"type": "int"}}},
"pomodoroStart": "Start",
"pomodoroPause": "Pause",
"pomodoroResume": "Resume",
"pomodoroSkip": "Skip",
"pomodoroStop": "Stop",
"pomodoroPaused": "Paused",
"pomodoroPausedProfile": "Paused · {profile}",
"@pomodoroPausedProfile": {"placeholders": {"profile": {"type": "String"}}},
"pomodoroClassic": "Classic",
"pomodoroUnnamed": "Unnamed profile",
"pomodoroProfiles": "Pomodoro profiles",
"pomodoroChooseProfile": "Choose a profile",
"pomodoroAddProfile": "Add a profile",
"pomodoroEditProfile": "Edit profile",
"pomodoroName": "Name",
"pomodoroFocusLength": "Focus",
"pomodoroShortBreakLength": "Short break",
"pomodoroLongBreakLength": "Long break",
"pomodoroMinutes": "{count} min",
"@pomodoroMinutes": {"placeholders": {"count": {"type": "int"}}},
"pomodoroNoLongBreak": "None",
"pomodoroLongBreakAfter": "Focus phases before a long break",
"pomodoroAutoStartBreaks": "Start breaks by themselves",
"pomodoroAutoStartFocus": "Start focus by itself after a break",
"pomodoroDoNotDisturb": "Do Not Disturb while focusing",
"pomodoroDoNotDisturbHint": "The device keeps quiet while you focus; the alert at its end still sounds.",
"pomodoroDoNotDisturbDenied": "The app may not keep this device quiet. Allow it in the system's settings.",
"pomodoroDeleteProfile": "Delete profile",
"pomodoroSummary": "{focus} min focus, {shortBreak} min break",
"@pomodoroSummary": {"placeholders": {"focus": {"type": "int"}, "shortBreak": {"type": "int"}}},
"pomodoroSummaryLong": "{focus} min focus, {shortBreak} min break, {longBreak} min after {after}",
"@pomodoroSummaryLong": {"placeholders": {"focus": {"type": "int"}, "shortBreak": {"type": "int"}, "longBreak": {"type": "int"}, "after": {"type": "int"}}},
"pomodoroAlertFocusDone": "Time for a break",
"pomodoroAlertBreakDone": "Back to focus",
"pomodoroAlertStarts": "{phase} has started.",
"@pomodoroAlertStarts": {"placeholders": {"phase": {"type": "String"}}},
"pomodoroAlertWaits": "{phase} is ready when you are.",
"@pomodoroAlertWaits": {"placeholders": {"phase": {"type": "String"}}},
"homeWidgetPomodoro": "Pomodoro timer",
"pomodoroAlerts": "Pomodoro alerts",
"pomodoroUntil": "Until {time}",
"@pomodoroUntil": {"placeholders": {"time": {"type": "String"}}},
"pomodoroLeft": "{left} left",
"@pomodoroLeft": {"placeholders": {"left": {"type": "String"}}}
}

View file

@ -4737,6 +4737,257 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{count, plural, =1{1 media file couldn\'t be imported.} other{{count} media files couldn\'t be imported.}}'**
String ankiImportFilesSkipped(int count);
/// No description provided for @pomodoroTitle.
///
/// In en, this message translates to:
/// **'Pomodoro'**
String get pomodoroTitle;
/// No description provided for @pomodoroFocus.
///
/// In en, this message translates to:
/// **'Focus'**
String get pomodoroFocus;
/// No description provided for @pomodoroShortBreak.
///
/// In en, this message translates to:
/// **'Short break'**
String get pomodoroShortBreak;
/// No description provided for @pomodoroLongBreak.
///
/// In en, this message translates to:
/// **'Long break'**
String get pomodoroLongBreak;
/// No description provided for @pomodoroFocusOf.
///
/// In en, this message translates to:
/// **'Focus {done} of {total}'**
String pomodoroFocusOf(int done, int total);
/// No description provided for @pomodoroStart.
///
/// In en, this message translates to:
/// **'Start'**
String get pomodoroStart;
/// No description provided for @pomodoroPause.
///
/// In en, this message translates to:
/// **'Pause'**
String get pomodoroPause;
/// No description provided for @pomodoroResume.
///
/// In en, this message translates to:
/// **'Resume'**
String get pomodoroResume;
/// No description provided for @pomodoroSkip.
///
/// In en, this message translates to:
/// **'Skip'**
String get pomodoroSkip;
/// No description provided for @pomodoroStop.
///
/// In en, this message translates to:
/// **'Stop'**
String get pomodoroStop;
/// No description provided for @pomodoroPaused.
///
/// In en, this message translates to:
/// **'Paused'**
String get pomodoroPaused;
/// No description provided for @pomodoroPausedProfile.
///
/// In en, this message translates to:
/// **'Paused · {profile}'**
String pomodoroPausedProfile(String profile);
/// No description provided for @pomodoroClassic.
///
/// In en, this message translates to:
/// **'Classic'**
String get pomodoroClassic;
/// No description provided for @pomodoroUnnamed.
///
/// In en, this message translates to:
/// **'Unnamed profile'**
String get pomodoroUnnamed;
/// No description provided for @pomodoroProfiles.
///
/// In en, this message translates to:
/// **'Pomodoro profiles'**
String get pomodoroProfiles;
/// No description provided for @pomodoroChooseProfile.
///
/// In en, this message translates to:
/// **'Choose a profile'**
String get pomodoroChooseProfile;
/// No description provided for @pomodoroAddProfile.
///
/// In en, this message translates to:
/// **'Add a profile'**
String get pomodoroAddProfile;
/// No description provided for @pomodoroEditProfile.
///
/// In en, this message translates to:
/// **'Edit profile'**
String get pomodoroEditProfile;
/// No description provided for @pomodoroName.
///
/// In en, this message translates to:
/// **'Name'**
String get pomodoroName;
/// No description provided for @pomodoroFocusLength.
///
/// In en, this message translates to:
/// **'Focus'**
String get pomodoroFocusLength;
/// No description provided for @pomodoroShortBreakLength.
///
/// In en, this message translates to:
/// **'Short break'**
String get pomodoroShortBreakLength;
/// No description provided for @pomodoroLongBreakLength.
///
/// In en, this message translates to:
/// **'Long break'**
String get pomodoroLongBreakLength;
/// No description provided for @pomodoroMinutes.
///
/// In en, this message translates to:
/// **'{count} min'**
String pomodoroMinutes(int count);
/// No description provided for @pomodoroNoLongBreak.
///
/// In en, this message translates to:
/// **'None'**
String get pomodoroNoLongBreak;
/// No description provided for @pomodoroLongBreakAfter.
///
/// In en, this message translates to:
/// **'Focus phases before a long break'**
String get pomodoroLongBreakAfter;
/// No description provided for @pomodoroAutoStartBreaks.
///
/// In en, this message translates to:
/// **'Start breaks by themselves'**
String get pomodoroAutoStartBreaks;
/// No description provided for @pomodoroAutoStartFocus.
///
/// In en, this message translates to:
/// **'Start focus by itself after a break'**
String get pomodoroAutoStartFocus;
/// No description provided for @pomodoroDoNotDisturb.
///
/// In en, this message translates to:
/// **'Do Not Disturb while focusing'**
String get pomodoroDoNotDisturb;
/// No description provided for @pomodoroDoNotDisturbHint.
///
/// In en, this message translates to:
/// **'The device keeps quiet while you focus; the alert at its end still sounds.'**
String get pomodoroDoNotDisturbHint;
/// No description provided for @pomodoroDoNotDisturbDenied.
///
/// In en, this message translates to:
/// **'The app may not keep this device quiet. Allow it in the system\'s settings.'**
String get pomodoroDoNotDisturbDenied;
/// No description provided for @pomodoroDeleteProfile.
///
/// In en, this message translates to:
/// **'Delete profile'**
String get pomodoroDeleteProfile;
/// No description provided for @pomodoroSummary.
///
/// In en, this message translates to:
/// **'{focus} min focus, {shortBreak} min break'**
String pomodoroSummary(int focus, int shortBreak);
/// No description provided for @pomodoroSummaryLong.
///
/// In en, this message translates to:
/// **'{focus} min focus, {shortBreak} min break, {longBreak} min after {after}'**
String pomodoroSummaryLong(
int focus,
int shortBreak,
int longBreak,
int after,
);
/// No description provided for @pomodoroAlertFocusDone.
///
/// In en, this message translates to:
/// **'Time for a break'**
String get pomodoroAlertFocusDone;
/// No description provided for @pomodoroAlertBreakDone.
///
/// In en, this message translates to:
/// **'Back to focus'**
String get pomodoroAlertBreakDone;
/// No description provided for @pomodoroAlertStarts.
///
/// In en, this message translates to:
/// **'{phase} has started.'**
String pomodoroAlertStarts(String phase);
/// No description provided for @pomodoroAlertWaits.
///
/// In en, this message translates to:
/// **'{phase} is ready when you are.'**
String pomodoroAlertWaits(String phase);
/// No description provided for @homeWidgetPomodoro.
///
/// In en, this message translates to:
/// **'Pomodoro timer'**
String get homeWidgetPomodoro;
/// No description provided for @pomodoroAlerts.
///
/// In en, this message translates to:
/// **'Pomodoro alerts'**
String get pomodoroAlerts;
/// No description provided for @pomodoroUntil.
///
/// In en, this message translates to:
/// **'Until {time}'**
String pomodoroUntil(String time);
/// No description provided for @pomodoroLeft.
///
/// In en, this message translates to:
/// **'{left} left'**
String pomodoroLeft(String left);
}
class _AppLocalizationsDelegate

View file

@ -2868,4 +2868,153 @@ class AppLocalizationsDe extends AppLocalizations {
);
return '$_temp0';
}
@override
String get pomodoroTitle => 'Pomodoro';
@override
String get pomodoroFocus => 'Fokus';
@override
String get pomodoroShortBreak => 'Kurze Pause';
@override
String get pomodoroLongBreak => 'Lange Pause';
@override
String pomodoroFocusOf(int done, int total) {
return 'Fokus $done von $total';
}
@override
String get pomodoroStart => 'Starten';
@override
String get pomodoroPause => 'Pausieren';
@override
String get pomodoroResume => 'Fortsetzen';
@override
String get pomodoroSkip => 'Überspringen';
@override
String get pomodoroStop => 'Beenden';
@override
String get pomodoroPaused => 'Pausiert';
@override
String pomodoroPausedProfile(String profile) {
return 'Pausiert · $profile';
}
@override
String get pomodoroClassic => 'Klassisch';
@override
String get pomodoroUnnamed => 'Profil ohne Namen';
@override
String get pomodoroProfiles => 'Pomodoro-Profile';
@override
String get pomodoroChooseProfile => 'Profil wählen';
@override
String get pomodoroAddProfile => 'Profil hinzufügen';
@override
String get pomodoroEditProfile => 'Profil bearbeiten';
@override
String get pomodoroName => 'Name';
@override
String get pomodoroFocusLength => 'Fokus';
@override
String get pomodoroShortBreakLength => 'Kurze Pause';
@override
String get pomodoroLongBreakLength => 'Lange Pause';
@override
String pomodoroMinutes(int count) {
return '$count Min.';
}
@override
String get pomodoroNoLongBreak => 'Keine';
@override
String get pomodoroLongBreakAfter => 'Fokusphasen vor einer langen Pause';
@override
String get pomodoroAutoStartBreaks => 'Pausen von selbst starten';
@override
String get pomodoroAutoStartFocus =>
'Fokus nach einer Pause von selbst starten';
@override
String get pomodoroDoNotDisturb => 'Nicht stören während des Fokus';
@override
String get pomodoroDoNotDisturbHint =>
'Das Gerät bleibt still, während du dich konzentrierst; die Meldung an seinem Ende ist trotzdem zu hören.';
@override
String get pomodoroDoNotDisturbDenied =>
'Die App darf dieses Gerät nicht stummschalten. Erlaube es in den Systemeinstellungen.';
@override
String get pomodoroDeleteProfile => 'Profil löschen';
@override
String pomodoroSummary(int focus, int shortBreak) {
return '$focus Min. Fokus, $shortBreak Min. Pause';
}
@override
String pomodoroSummaryLong(
int focus,
int shortBreak,
int longBreak,
int after,
) {
return '$focus Min. Fokus, $shortBreak Min. Pause, $longBreak Min. nach $after';
}
@override
String get pomodoroAlertFocusDone => 'Zeit für eine Pause';
@override
String get pomodoroAlertBreakDone => 'Zurück zum Fokus';
@override
String pomodoroAlertStarts(String phase) {
return '$phase hat begonnen.';
}
@override
String pomodoroAlertWaits(String phase) {
return '$phase wartet auf dich.';
}
@override
String get homeWidgetPomodoro => 'Pomodoro-Timer';
@override
String get pomodoroAlerts => 'Pomodoro-Meldungen';
@override
String pomodoroUntil(String time) {
return 'Bis $time';
}
@override
String pomodoroLeft(String left) {
return 'Noch $left';
}
}

View file

@ -2839,6 +2839,154 @@ class AppLocalizationsEn extends AppLocalizations {
);
return '$_temp0';
}
@override
String get pomodoroTitle => 'Pomodoro';
@override
String get pomodoroFocus => 'Focus';
@override
String get pomodoroShortBreak => 'Short break';
@override
String get pomodoroLongBreak => 'Long break';
@override
String pomodoroFocusOf(int done, int total) {
return 'Focus $done of $total';
}
@override
String get pomodoroStart => 'Start';
@override
String get pomodoroPause => 'Pause';
@override
String get pomodoroResume => 'Resume';
@override
String get pomodoroSkip => 'Skip';
@override
String get pomodoroStop => 'Stop';
@override
String get pomodoroPaused => 'Paused';
@override
String pomodoroPausedProfile(String profile) {
return 'Paused · $profile';
}
@override
String get pomodoroClassic => 'Classic';
@override
String get pomodoroUnnamed => 'Unnamed profile';
@override
String get pomodoroProfiles => 'Pomodoro profiles';
@override
String get pomodoroChooseProfile => 'Choose a profile';
@override
String get pomodoroAddProfile => 'Add a profile';
@override
String get pomodoroEditProfile => 'Edit profile';
@override
String get pomodoroName => 'Name';
@override
String get pomodoroFocusLength => 'Focus';
@override
String get pomodoroShortBreakLength => 'Short break';
@override
String get pomodoroLongBreakLength => 'Long break';
@override
String pomodoroMinutes(int count) {
return '$count min';
}
@override
String get pomodoroNoLongBreak => 'None';
@override
String get pomodoroLongBreakAfter => 'Focus phases before a long break';
@override
String get pomodoroAutoStartBreaks => 'Start breaks by themselves';
@override
String get pomodoroAutoStartFocus => 'Start focus by itself after a break';
@override
String get pomodoroDoNotDisturb => 'Do Not Disturb while focusing';
@override
String get pomodoroDoNotDisturbHint =>
'The device keeps quiet while you focus; the alert at its end still sounds.';
@override
String get pomodoroDoNotDisturbDenied =>
'The app may not keep this device quiet. Allow it in the system\'s settings.';
@override
String get pomodoroDeleteProfile => 'Delete profile';
@override
String pomodoroSummary(int focus, int shortBreak) {
return '$focus min focus, $shortBreak min break';
}
@override
String pomodoroSummaryLong(
int focus,
int shortBreak,
int longBreak,
int after,
) {
return '$focus min focus, $shortBreak min break, $longBreak min after $after';
}
@override
String get pomodoroAlertFocusDone => 'Time for a break';
@override
String get pomodoroAlertBreakDone => 'Back to focus';
@override
String pomodoroAlertStarts(String phase) {
return '$phase has started.';
}
@override
String pomodoroAlertWaits(String phase) {
return '$phase is ready when you are.';
}
@override
String get homeWidgetPomodoro => 'Pomodoro timer';
@override
String get pomodoroAlerts => 'Pomodoro alerts';
@override
String pomodoroUntil(String time) {
return 'Until $time';
}
@override
String pomodoroLeft(String left) {
return '$left left';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -12,6 +12,8 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_presenter.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_button_block.dart';
import 'package:material_ui/material_ui.dart';
@ -74,13 +76,26 @@ class _PersonAppState() extends State<PersonApp> {
builder: (context, child) => MaterialUiCompatibilityBridge(
child: PersonScope(
person: widget.person,
// Where sync stands: the trouble dot, Settings and Home show it.
child: BlocProvider(
create: (context) =>
SyncStatusBloc(PersonScope.of(context).sync)
..add(const SyncStatusEventStarted()),
// The editors know the features' blocks too.
child: EditorBlocksScope(blocks: _editorBlocks, child: child!),
child: MultiBlocProvider(
providers: [
// Where sync stands: the trouble dot, Settings and Home show it.
BlocProvider(
create: (context) =>
SyncStatusBloc(PersonScope.of(context).sync)
..add(const SyncStatusEventStarted()),
),
// The Pomodoro timer goes on across every screen.
BlocProvider(
create: (context) => PomodoroBloc(
PersonScope.of(context).settings,
PersonScope.of(context).pomodoroRuns,
)..add(const PomodoroEventStarted()),
),
],
child: PomodoroPresenter(
// The editors know the features' blocks too.
child: EditorBlocksScope(blocks: _editorBlocks, child: child!),
),
),
),
),

View file

@ -39,6 +39,9 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dar
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdfrx_engine.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/sync_pdf_source_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platform.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platforms.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_store.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
@ -103,6 +106,9 @@ class Dependencies({
/// Which release of the app this is.
required final AppVersion appVersion,
/// What the system shows of the Pomodoro timer, and its alerts.
required final PomodoroPlatform pomodoro,
final Future<void> Function()? _onDispose,
}) {
/// Builds the production graph around one Serverpod client.
@ -154,6 +160,7 @@ class Dependencies({
deviceSettingsStore: deviceSettingsStore,
deviceSettings: await deviceSettingsStore.read(),
appVersion: appVersion,
pomodoro: pomodoroPlatform(support),
openPerson: (person) async {
await previousClosed.timeout(
const Duration(seconds: 10),
@ -269,6 +276,9 @@ Future<PersonDependencies> _openPerson(
pathDeletions: pathDeletions,
stepDeletions: stepDeletions,
pdfDeletions: pdfDeletions,
pomodoroRuns: FilePomodoroRunStore(
File(p.join(folder.path, 'pomodoro.json')),
),
onDispose: () async {
try {
// Deletions first: those still waiting are dropped, those under way

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart'
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_store.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
@ -67,6 +68,9 @@ class PersonDependencies({
/// The PDF deletions that can still be undone.
required final PdfDeletions pdfDeletions,
/// Where this device keeps the person's Pomodoro timer.
required final PomodoroRunStore pomodoroRuns,
final Future<void> Function()? _onDispose,
}) {
/// Closes what was opened for the person.

View file

@ -2,6 +2,9 @@ import 'package:equatable/equatable.dart';
/// A widget of the home dashboard.
enum HomeWidget() {
/// The Pomodoro timer.
pomodoro,
/// The study goal.
goal,

View file

@ -16,6 +16,7 @@ import 'package:halkyon_learn_flutter/src/feature/home/widget/goal_card.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/streak.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/week_overview.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/reading_deadlines_card.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_card.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -134,6 +135,7 @@ class _HomeState() extends State<_Home> {
final l10n = AppLocalizations.of(context);
final selected = _selected ?? today;
Widget section(HomeWidget widget) => switch (widget) {
HomeWidget.pomodoro => const PomodoroCard(),
HomeWidget.goal => GoalCard(
goal: state.goal,
progress: state.goalProgress,
@ -228,6 +230,7 @@ class const _Arranging({
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
String label(HomeWidget widget) => switch (widget) {
HomeWidget.pomodoro => l10n.homeWidgetPomodoro,
HomeWidget.goal => l10n.homeWidgetGoal,
HomeWidget.deadlines => l10n.homeWidgetDeadlines,
HomeWidget.week => l10n.homeWidgetWeek,

View file

@ -0,0 +1,209 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_store.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_run.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
/// The Pomodoro timer of this device and the person's profiles: starts,
/// pauses, skips and stops the timer, ends its phases as they run out, and
/// keeps it on the device while the app is closed (it catches up when the
/// app opens or comes back); changes the profiles in the person's settings,
/// a change to the profile running applying from then on.
///
/// What the system shows of it (notifications, the top bar) follows the
/// state, through `PomodoroPresenter`.
class PomodoroBloc(
final SettingsRepository _settings,
final PomodoroRunStore _runs,
) extends Bloc<PomodoroEvent, PomodoroState> {
this : super(const PomodoroState()) {
on<PomodoroEventStarted>(_onStarted);
on<PomodoroEventSettingsChanged>((event, emit) async {
final run = state.run;
final profile = event.settings.profiles
.where((each) => each.id == run?.profile.id)
.firstOrNull;
await _emit(
emit,
settings: event.settings,
run: run == null || profile == null ? run : run.withProfile(profile),
);
});
on<PomodoroEventStartRequested>(
(event, emit) => _emit(
emit,
run: (state.run ?? PomodoroRun.ready(state.settings.selected)).started(
clock.now().toUtc(),
),
),
);
on<PomodoroEventPauseRequested>(
(event, emit) => _emit(emit, run: state.run?.paused(clock.now().toUtc())),
);
on<PomodoroEventResumeRequested>(
(event, emit) =>
_emit(emit, run: state.run?.started(clock.now().toUtc())),
);
on<PomodoroEventSkipRequested>(
(event, emit) => _emit(emit, run: state.run?.next(clock.now().toUtc())),
);
on<PomodoroEventStopRequested>((event, emit) => _emit(emit, run: null));
on<PomodoroEventCaughtUp>((event, emit) => _catchUp(emit));
on<PomodoroEventProfileChosen>(
(event, emit) => _settings.update(
(settings) =>
settings.withPomodoro(settings.pomodoro.withSelected(event.id)),
),
);
on<PomodoroEventProfileSaved>(
(event, emit) => _settings.update(
(settings) =>
settings.withPomodoro(settings.pomodoro.withProfile(event.profile)),
),
);
on<PomodoroEventProfileDeleted>(
(event, emit) => _settings.update(
(settings) =>
settings.withPomodoro(settings.pomodoro.withoutProfile(event.id)),
),
);
}
StreamSubscription<void>? _settingsChanges;
Timer? _phaseEnd;
Future<void> _onStarted(
PomodoroEventStarted event,
Emitter<PomodoroState> emit,
) async {
final settings = await _settings.read();
final kept = await _runs.read();
await _emit(emit, settings: settings.pomodoro, run: kept);
await _catchUp(emit);
_settingsChanges = _settings.watch().listen(
(settings) => add(PomodoroEventSettingsChanged(settings.pomodoro)),
// A read that failed leaves the profiles as they were.
onError: (Object _) {},
);
}
/// Ends the phases that ran out by now.
Future<void> _catchUp(Emitter<PomodoroState> emit) async {
if (state.run case final run?) {
final (caughtUp, ended) = run.caughtUpTo(clock.now().toUtc());
if (ended != null) {
await _emit(emit, run: caughtUp);
}
}
}
/// Emits the state with what changed, keeps the timer on the device and
/// waits for the end of its phase.
Future<void> _emit(
Emitter<PomodoroState> emit, {
PomodoroSettings? settings,
Object? run = _unchanged,
}) async {
final next = PomodoroState(
settings: settings ?? state.settings,
run: identical(run, _unchanged) ? state.run : run as PomodoroRun?,
loaded: true,
);
final runChanged = next.run != state.run;
emit(next);
if (runChanged) {
await _runs.write(next.run);
}
_phaseEnd?.cancel();
if (next.run?.endsAt case final endsAt?) {
_phaseEnd = Timer(
endsAt.difference(clock.now().toUtc()) +
const Duration(milliseconds: 1),
() => add(const PomodoroEventCaughtUp()),
);
}
}
@override
Future<void> close() async {
_phaseEnd?.cancel();
await _settingsChanges?.cancel();
await super.close();
}
}
const _unchanged = Object();
/// The person's profiles and this device's timer.
final class const PomodoroState({
/// The profiles, and the one chosen.
final PomodoroSettings settings = const PomodoroSettings(),
/// The timer; null when none is set.
final PomodoroRun? run,
/// Whether the profiles and the timer were read.
final bool loaded = false,
}) with Equatable {
@override
List<Object?> get props => [settings, run, loaded];
}
/// What happens to the timer and the profiles.
sealed class const PomodoroEvent() with Equatable {
@override
List<Object?> get props => [];
}
/// The app opened: read the profiles and the timer kept.
final class const PomodoroEventStarted() extends PomodoroEvent;
/// Start the timer: the one paused, or a new one on the chosen profile.
final class const PomodoroEventStartRequested() extends PomodoroEvent;
/// Pause the timer.
final class const PomodoroEventPauseRequested() extends PomodoroEvent;
/// Go on with the paused timer.
final class const PomodoroEventResumeRequested() extends PomodoroEvent;
/// End the phase now, for the next.
final class const PomodoroEventSkipRequested() extends PomodoroEvent;
/// Take the timer away.
final class const PomodoroEventStopRequested() extends PomodoroEvent;
/// A phase may have run out (its end came, or the app came back): end it.
final class const PomodoroEventCaughtUp() extends PomodoroEvent;
/// Choose the profile [id] for the next timer.
final class const PomodoroEventProfileChosen(final String id)
extends PomodoroEvent {
@override
List<Object?> get props => [id];
}
/// Keep [profile], in place of the one with its id or as a new one.
final class const PomodoroEventProfileSaved(final PomodoroProfile profile)
extends PomodoroEvent {
@override
List<Object?> get props => [profile];
}
/// Delete the profile [id].
final class const PomodoroEventProfileDeleted(final String id)
extends PomodoroEvent {
@override
List<Object?> get props => [id];
}
/// The person's profiles changed, here or on another device.
final class const PomodoroEventSettingsChanged(final PomodoroSettings settings)
extends PomodoroEvent {
@override
List<Object?> get props => [settings];
}

View file

@ -0,0 +1,54 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platform.dart';
/// The Pomodoro timer on Android, through the app's own `PomodoroService`
/// (Kotlin, `android/app/src/main/kotlin/`): an ongoing notification with
/// its buttons, on Android 16 a Live Update, an exact alarm at the phase's
/// end, and Do Not Disturb while focusing.
class AndroidPomodoroPlatform() implements PomodoroPlatform {
static const _channel = MethodChannel('app.halkyon.learn/pomodoro');
static const _buttons = EventChannel('app.halkyon.learn/pomodoro/buttons');
@override
Future<void> show(PomodoroNotice notice) =>
_channel.invokeMethod<void>('show', jsonEncode(noticeJson(notice)));
@override
Future<void> clear() => _channel.invokeMethod<void>('clear');
@override
late final Stream<PomodoroAction> actions = _buttons
.receiveBroadcastStream()
.map((button) => PomodoroAction.values.byName(button as String));
@override
Future<bool> mayKeepQuiet({bool ask = false}) async =>
await _channel.invokeMethod<bool>('mayKeepQuiet', ask) ?? false;
}
/// [notice] as the service reads it: times in milliseconds, since 1970 for
/// a moment.
Map<String, Object?> noticeJson(PomodoroNotice notice) => {
'title': notice.title,
'text': notice.text,
'phase': notice.phase.name,
'endsAt': notice.endsAt?.millisecondsSinceEpoch,
'leftMs': notice.left.inMilliseconds,
'cycle': [
for (final (:phase, :length) in notice.cycle)
{'phase': phase.name, 'lengthMs': length.inMilliseconds},
],
'index': notice.indexInCycle,
'elapsedMs': notice.elapsedInCycle.inMilliseconds,
'alertTitle': notice.alertTitle,
'alertText': notice.alertText,
'actions': {
for (final MapEntry(:key, :value) in notice.actions.entries)
key.name: value,
},
'quiet': notice.quiet,
'timerName': notice.timerName,
'alertsName': notice.alertsName,
};

View file

@ -0,0 +1,294 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:dbus/dbus.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platform.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/status_notifier_item.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_run.dart';
/// Runs a command, as [Process.run] does; given for tests.
typedef RunCommand = Future<ProcessResult> Function(
String command,
List<String> arguments,
);
/// The Pomodoro timer on a Linux desktop, through the session bus's
/// notification server (`org.freedesktop.Notifications`, which the snap's
/// `desktop` plug lets it use): a notification with the timer's buttons,
/// kept up to date; an alert with sound when a phase ends, while the app
/// runs (a desktop app's timer lives no longer than the app); and GNOME's
/// Do Not Disturb (`show-banners` off) while focusing, where `gsettings` can
/// change it. The value before is kept in [_quietBefore], so that it comes
/// back also after a crash.
///
/// Where the desktop shows icons in its top bar (GNOME with the AppIndicator
/// extension, which Ubuntu has built in; KDE), the timer counts down there,
/// with its buttons in the icon's menu; elsewhere a notification says until
/// when the phase runs, since GNOME shows no countdown in one.
class LinuxPomodoroPlatform(
final DBusClient _bus,
final File _quietBefore, {
RunCommand run = Process.run,
}) implements PomodoroPlatform {
/// The timer in the top bar, where the desktop shows such icons; the
/// notification stands in elsewhere.
late final _indicator = StatusNotifierItem(_bus, id: 'halkyon-learn')
..chosen.listen((key) {
if (PomodoroAction.values.asNameMap()[key] case final action?) {
_actions.add(action);
}
});
Timer? _ticks;
static const _interface = 'org.freedesktop.Notifications';
late final _server = DBusRemoteObject(
_bus,
name: _interface,
path: DBusObjectPath('/org/freedesktop/Notifications'),
);
final RunCommand _run = run;
final _actions = StreamController<PomodoroAction>.broadcast();
StreamSubscription<DBusSignal>? _invoked;
StreamSubscription<DBusSignal>? _closed;
/// The id of the timer's notification while one shows.
int? _id;
Timer? _alert;
/// The desktop file the notifications belong to, for their icon and name.
static String get _desktopEntry =>
switch (Platform.environment['SNAP_NAME']) {
final snap? => '${snap}_halkyon-learn',
null => 'halkyon-learn',
};
void _listen() {
_invoked ??=
DBusRemoteObjectSignalStream(
object: _server,
interface: _interface,
name: 'ActionInvoked',
signature: DBusSignature('us'),
).listen((signal) {
if (signal.values
case [
DBusUint32(:final value),
DBusString(value: final key),
]
when value == _id) {
if (PomodoroAction.values.asNameMap()[key] case final action?) {
_actions.add(action);
}
}
});
_closed ??=
DBusRemoteObjectSignalStream(
object: _server,
interface: _interface,
name: 'NotificationClosed',
signature: DBusSignature('uu'),
).listen((signal) {
if ((signal.values.first as DBusUint32).value == _id) {
_id = null;
}
});
}
Future<int> _notify({
required int replaces,
required String summary,
required String body,
List<String> actions = const [],
required Map<String, DBusValue> hints,
int expireMs = -1,
}) async {
final reply = await _server.callMethod(
_interface,
'Notify',
[
const DBusString('Halkyon Learn'),
DBusUint32(replaces),
const DBusString(''),
DBusString(summary),
DBusString(body),
DBusArray.string(actions),
DBusDict.stringVariant({
'desktop-entry': DBusString(_desktopEntry),
...hints,
}),
DBusInt32(expireMs),
],
replySignature: DBusSignature('u'),
);
return (reply.values.single as DBusUint32).value;
}
@override
Future<void> show(PomodoroNotice notice) async {
_listen();
final labels = notice.actions;
final buttons = [
if (notice.isRunning) PomodoroAction.pause else PomodoroAction.resume,
PomodoroAction.skip,
PomodoroAction.stop,
];
String left() => formatPomodoroLeft(switch (notice.endsAt) {
final endsAt? => endsAt.difference(clock.now()),
null => notice.left,
});
_ticks?.cancel();
var inTopBar = false;
try {
inTopBar = await _indicator.show(
title: notice.title,
label: left(),
entries: [
(key: '', label: '${notice.title} · ${notice.text}', enabled: false),
for (final action in buttons)
(
key: action.name,
label: labels[action] ?? action.name,
enabled: true,
),
],
);
} on Exception catch (error) {
log(
"Couldn't show the timer in the top bar",
name: 'Pomodoro',
error: error,
);
}
if (inTopBar) {
await _closeNotification();
if (notice.isRunning) {
_ticks = Timer.periodic(
const Duration(seconds: 1),
(_) => unawaited(_indicator.label(left())),
);
}
} else {
try {
_id = await _notify(
replaces: _id ?? 0,
summary: notice.title,
body: '${notice.text} · ${notice.status}',
actions: [
for (final action in buttons) ...[
action.name,
labels[action] ?? action.name,
],
],
hints: {
// It stays when a button is pressed, and keeps quiet when
// updated.
'resident': const DBusBoolean(true),
'suppress-sound': const DBusBoolean(true),
'urgency': const DBusByte(0),
},
expireMs: 0,
);
} on Exception catch (error) {
// No notification server, or none on the bus: the app shows it
// alone.
log("Couldn't show the timer", name: 'Pomodoro', error: error);
}
}
_alert?.cancel();
if (notice.endsAt case final endsAt?) {
_alert = Timer(endsAt.difference(clock.now()), () => _alertEnd(notice));
}
await (notice.quiet ? _keepQuiet() : _restoreQuiet());
}
Future<void> _alertEnd(PomodoroNotice notice) async {
// The alert is heard, the quiet over first.
await _restoreQuiet();
try {
await _notify(
replaces: 0,
summary: notice.alertTitle,
body: notice.alertText,
hints: {
'sound-name': const DBusString('complete'),
'urgency': const DBusByte(1),
},
);
} on Exception catch (error) {
log("Couldn't alert", name: 'Pomodoro', error: error);
}
}
@override
Future<void> clear() async {
_alert?.cancel();
_ticks?.cancel();
try {
await _indicator.hide();
} on Exception catch (error) {
log("Couldn't take the timer away", name: 'Pomodoro', error: error);
}
await _closeNotification();
await _restoreQuiet();
}
Future<void> _closeNotification() async {
if (_id case final id?) {
_id = null;
try {
await _server.callMethod(_interface, 'CloseNotification', [
DBusUint32(id),
], replySignature: DBusSignature(''));
} on Exception catch (error) {
log("Couldn't take the timer away", name: 'Pomodoro', error: error);
}
}
}
@override
Stream<PomodoroAction> get actions => _actions.stream;
static const _banners = ['org.gnome.desktop.notifications', 'show-banners'];
@override
Future<bool> mayKeepQuiet({bool ask = false}) async {
try {
return (await _run('gsettings', ['get', ..._banners])).exitCode == 0;
} on ProcessException {
return false;
}
}
Future<void> _keepQuiet() async {
try {
if (!_quietBefore.existsSync()) {
final before = await _run('gsettings', ['get', ..._banners]);
if (before.exitCode != 0) {
return;
}
_quietBefore.writeAsStringSync((before.stdout as String).trim());
}
await _run('gsettings', ['set', ..._banners, 'false']);
} on ProcessException catch (error) {
log("Couldn't keep quiet", name: 'Pomodoro', error: error);
}
}
Future<void> _restoreQuiet() async {
if (!_quietBefore.existsSync()) {
return;
}
try {
await _run('gsettings', [
'set',
..._banners,
_quietBefore.readAsStringSync(),
]);
_quietBefore.deleteSync();
} on ProcessException catch (error) {
log("Couldn't end the quiet", name: 'Pomodoro', error: error);
}
}
}

View file

@ -0,0 +1,125 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_run.dart';
/// What the person can do with the timer from outside the app: the buttons
/// of its notification or top-bar indicator.
enum PomodoroAction() {
pause,
resume,
skip,
stop,
}
/// A phase of the timer's cycle, as the system shows the cycle: its kind and
/// length.
typedef PomodoroSegment = ({PomodoroPhase phase, Duration length});
/// What the system shows of a running or paused timer, in the app's
/// language: in Android's ongoing notification (on Android 16 a Live Update
/// with the cycle as segments), in GNOME's top bar.
final class const PomodoroNotice({
/// The phase under way, as "Focus".
required final String title,
/// What else to say, as the profile's name, or "Paused".
required final String text,
/// Where the phase stands, for systems that can't count down: "Until
/// 10:25", or "12:34 left" while paused.
required final String status,
/// The phase under way.
required final PomodoroPhase phase,
/// When the phase ends; null while paused.
required final DateTime? endsAt,
/// What is left of the phase while paused.
required final Duration left,
/// The phases of the cycle, in order.
required final List<PomodoroSegment> cycle,
/// Where in [cycle] the timer is.
required final int indexInCycle,
/// How much of [cycle] had passed when the notice was made.
required final Duration elapsedInCycle,
/// The alert when the phase ends, as "Time for a break".
required final String alertTitle,
/// What the alert says besides, as what comes next.
required final String alertText,
/// The buttons' labels.
required final Map<PomodoroAction, String> actions,
/// Whether the device keeps quiet now (Do Not Disturb while focusing).
required final bool quiet,
/// The timer's name in the system, as Android's notification settings
/// list it.
required final String timerName,
/// The name of its alerts there.
required final String alertsName,
}) with Equatable {
/// Whether the phase is running.
bool get isRunning => endsAt != null;
@override
List<Object?> get props => [
title,
text,
status,
phase,
endsAt,
left,
cycle,
indexInCycle,
elapsedInCycle,
alertTitle,
alertText,
actions,
quiet,
timerName,
alertsName,
];
}
/// The system's side of the timer: showing it outside the app, alerting
/// when a phase ends (with sound and vibration, also while the app is in
/// the background), the buttons pressed there, and keeping the device quiet
/// while focusing.
abstract interface class PomodoroPlatform() {
/// Shows [notice] in place of what was shown, and alerts when its phase
/// ends unless another notice comes first.
Future<void> show(PomodoroNotice notice);
/// Takes the timer away from the system, and lets the device make noise
/// again.
Future<void> clear();
/// The buttons pressed outside the app.
Stream<PomodoroAction> get actions;
/// Whether the person allowed the app to keep the device quiet; asks
/// them (the system's settings may open) when [ask].
Future<bool> mayKeepQuiet({bool ask = false});
}
/// A platform without any of it: the timer shows in the app alone.
class const NoPomodoroPlatform() implements PomodoroPlatform {
@override
Future<void> show(PomodoroNotice notice) async {}
@override
Future<void> clear() async {}
@override
Stream<PomodoroAction> get actions => const Stream.empty();
@override
Future<bool> mayKeepQuiet({bool ask = false}) async => false;
}

View file

@ -0,0 +1,20 @@
import 'dart:io';
import 'package:dbus/dbus.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/android_pomodoro_platform.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/linux_pomodoro_platform.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platform.dart';
import 'package:path/path.dart' as p;
/// The platform side of the Pomodoro timer for the platform the app runs
/// on, keeping what it must in [supportFolder]; none where there is none
/// yet (Windows, macOS, iOS).
PomodoroPlatform pomodoroPlatform(String supportFolder) =>
switch (Platform.operatingSystem) {
'android' => AndroidPomodoroPlatform(),
'linux' => LinuxPomodoroPlatform(
DBusClient.session(),
File(p.join(supportFolder, 'pomodoro_quiet_before')),
),
_ => const NoPomodoroPlatform(),
};

Some files were not shown because too many files have changed in this diff Show more