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:
parent
d42afb078a
commit
918e10bb2b
47 changed files with 4517 additions and 49 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}}}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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!),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue