diff --git a/.github/workflows/flutter-android.yml b/.github/workflows/flutter-android.yml
new file mode 100644
index 00000000..0d58c6ce
--- /dev/null
+++ b/.github/workflows/flutter-android.yml
@@ -0,0 +1,61 @@
+name: Flutter Android
+
+on:
+ workflow_dispatch:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-24.04
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v7
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: '17'
+
+ - name: Set up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ flutter-version: '3.47.4'
+ cache: true
+
+ - name: Install Android API and Build Tools 37
+ run: >-
+ "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
+ 'platforms;android-37.0'
+ 'build-tools;37.0.0'
+
+ - name: Verify source, Dart, Flutter, Android, and native plugin tests
+ run: tool/android/verify.sh
+
+ - name: Build explicitly test-signed release APK and AAB
+ run: >-
+ tool/android/build_release.sh --all --test-signing
+ --allow-unconfigured-providers
+
+ - name: Inspect test-signed artifacts
+ run: tool/android/inspect_artifacts.sh
+
+ - name: Upload clearly labeled non-production artifacts
+ uses: actions/upload-artifact@v7
+ with:
+ name: busymax-android-test-signed-not-for-distribution
+ path: |
+ build/app/outputs/flutter-apk/app-release.apk
+ build/app/outputs/bundle/release/app-release.aab
+ build/android/android-artifact-inspection.txt
+ if-no-files-found: error
+ retention-days: 7
diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml
index 73740ede..b59d8bd6 100644
--- a/.github/workflows/flutter-linux.yml
+++ b/.github/workflows/flutter-linux.yml
@@ -32,13 +32,15 @@ jobs:
libsecret-1-dev \
libjsoncpp-dev \
liblzma-dev \
+ locales \
xvfb
+ sudo locale-gen en_US.UTF-8 en_GB.UTF-8
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- flutter-version: '3.47.2'
+ flutter-version: '3.47.4'
cache: true
- name: Verify pinned Flutter and bundled Dart
@@ -50,8 +52,8 @@ jobs:
"$dart_executable" tool/verify_flutter_sdk.dart \
--flutter "$flutter_executable" \
--dart "$dart_executable" \
- --expected-flutter 3.47.2 \
- --expected-dart 3.13.2
+ --expected-flutter 3.47.4 \
+ --expected-dart 3.13.3
printf 'BUSYMAX_FLUTTER_EXECUTABLE=%s\n' "$flutter_executable" >> "$GITHUB_ENV"
printf 'BUSYMAX_DART_EXECUTABLE=%s\n' "$dart_executable" >> "$GITHUB_ENV"
@@ -87,13 +89,36 @@ jobs:
- name: Test
run: '"$BUSYMAX_FLUTTER_EXECUTABLE" test'
- - name: Test native clock patterns and optional GTK time picker
+ - name: Test native clock patterns, weekday locale, and GTK time picker
run: |
mkdir -p build/linux/native-tests
g++ -std=c++17 -Wall -Wextra -Werror \
windows/runner/tests/short_time_pattern_test.cpp \
-o build/linux/native-tests/short_time_pattern_test
build/linux/native-tests/short_time_pattern_test
+ g++ -std=c++17 -Wall -Wextra -Werror \
+ linux/runner/tests/first_weekday_test.cc \
+ -o build/linux/native-tests/first_weekday_test
+ build/linux/native-tests/first_weekday_test
+ mkdir -p build/linux/native-tests/weekday-schemas
+ glib-compile-schemas --strict \
+ --targetdir=build/linux/native-tests/weekday-schemas \
+ linux/runner/tests/schemas
+ g++ -std=c++17 -Wall -Wextra -Werror \
+ linux/runner/tests/first_weekday_preference_test.cc \
+ linux/runner/first_weekday_preference.cc \
+ -o build/linux/native-tests/first_weekday_preference_test \
+ $(pkg-config --cflags --libs gio-2.0)
+ build/linux/native-tests/first_weekday_preference_test \
+ build/linux/native-tests/weekday-schemas
+ LC_ALL=C.UTF-8 MALLOC_PERTURB_=165 \
+ build/linux/native-tests/first_weekday_test
+ g++ -std=c++17 -Wall -Wextra -Werror \
+ -fsanitize=address -fno-omit-frame-pointer \
+ linux/runner/tests/first_weekday_test.cc \
+ -o build/linux/native-tests/first_weekday_test_asan
+ ASAN_OPTIONS=detect_leaks=0 \
+ build/linux/native-tests/first_weekday_test_asan
g++ -std=c++17 -Wall -Wextra -Werror \
linux/runner/tests/time_picker_test.cc \
-o build/linux/native-tests/time_picker_test \
diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml
index 1dbb5bc2..afeb81ec 100644
--- a/.github/workflows/flutter-windows.yml
+++ b/.github/workflows/flutter-windows.yml
@@ -23,7 +23,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: stable
- flutter-version: '3.47.2'
+ flutter-version: '3.47.4'
cache: true
- name: Enable Windows desktop
diff --git a/.metadata b/.metadata
index da084209..ddeab23e 100644
--- a/.metadata
+++ b/.metadata
@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
- revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407"
+ revision: "d3b14c876900e553bc736ca19295fc09e3853e8e"
channel: "stable"
project_type: app
@@ -21,6 +21,9 @@ migration:
- platform: windows
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
+ - platform: android
+ create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
+ base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
# User provided section
diff --git a/.run/BusyMax Android.run.xml b/.run/BusyMax Android.run.xml
new file mode 100644
index 00000000..9c7b364e
--- /dev/null
+++ b/.run/BusyMax Android.run.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/NOTICE b/NOTICE
index 0c888af2..ab1dd665 100644
--- a/NOTICE
+++ b/NOTICE
@@ -9,3 +9,12 @@ Source repository: https://github.com/busystack/busymax
The BusyMax name and logo are identifying marks of BusyStack.
This license does not grant permission to use the BusyMax name or logo to
imply endorsement, sponsorship, or official status for modified versions.
+
+The Android build includes Microsoft Authentication Library for Android
+(MSAL), licensed under the MIT License; Flutter Workmanager, licensed under the
+MIT License; and flutter_local_notifications, licensed under the BSD 3-Clause
+License. It also links Google Play services libraries, which are distributed
+under Google's applicable Android SDK and API terms. Complete Flutter package
+license text is available through Flutter's license registry in built apps;
+Gradle dependency and license records must be retained with distributed
+Android artifacts.
diff --git a/README.md b/README.md
index 2bc727cd..a70f60e4 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,14 @@
# BusyMax
-BusyMax is a beta calendar and task manager for Linux and Windows. It brings
-events, tasks, reminders, and day, week, month, year, and agenda views into one
-desktop application.
+BusyMax is a beta calendar and task manager for Android, Linux, and Windows. It
+brings events, tasks, reminders, and day, week, month, year, and agenda views
+into one application.
Linux uses a Yaru/GTK desktop composition with XDG integrations. Windows uses
a separate Fluent composition with a native Windows title bar, tray,
-notifications, and packaged startup integration. Provider capabilities differ;
+notifications, and packaged startup integration. Android uses a separate
+adaptive Material 3 composition, native account authorization, WorkManager,
+Android notifications, and the Storage Access Framework. Provider capabilities differ;
see the [provider support matrix](docs/provider_support_matrix.md) before
relying on a particular task field or operation.
@@ -29,8 +31,8 @@ relying on a particular task field or operation.
permissions granted for a collection.
- Local caching for offline viewing. Supported changes can be queued for some
providers; offline behavior is not identical across every integration.
-- Desktop reminders, tray actions, import/export, themes, keyboard navigation,
- and localized interfaces.
+- Platform-native reminders, Android document import/export, desktop tray
+ actions, themes, keyboard navigation, and localized interfaces.
More Linux screenshots
@@ -67,7 +69,9 @@ test artifacts, not ordinary user downloads.
Packaged users do not need Flutter, Visual Studio, Linux development libraries,
or their own OAuth registration. In a configured build:
-- Google and Microsoft accounts connect through browser-based sign-in.
+- Desktop Google and Microsoft accounts connect through browser-based sign-in.
+ Android uses Google Play services authorization for Google and MSAL's
+ registered browser redirect for Microsoft.
- Apple iCloud Calendar uses an Apple app-specific password; Apple Reminders is
not supported. See [Connect Apple iCloud Calendar](docs/apple_icloud_setup.md).
- Nextcloud authorizes BusyMax in the default browser and requires a compatible
@@ -80,6 +84,24 @@ use the [Google OAuth registration guide](docs/google_setup.md) or
differences and limitations are summarized in the
[provider support matrix](docs/provider_support_matrix.md).
+### Android development build
+
+Android support targets API 37 with a minimum API of 24 and uses the explicit
+entrypoint `lib/main_android.dart`. It requires Flutter 3.47.4, bundled Dart
+3.13.3, JDK 17, Android platform 37, and Build Tools 37.x.
+
+```sh
+tool/android/check_prerequisites.sh
+cp android/busymax.android.properties.example android/busymax.android.properties
+tool/android/run.sh --device DEVICE_ID
+```
+
+The copied public configuration needs real Microsoft and certificate-specific
+provider registrations before those sign-in flows work. It contains no client
+secret. See [Android setup](docs/android_setup.md), [Android release](docs/android_release.md),
+and the [Android verification report](docs/android_verification.md). Generated
+test-signed artifacts are not production releases.
+
## Documentation
Account, provider, and data information:
@@ -88,10 +110,14 @@ Account, provider, and data information:
- [Apple iCloud Calendar setup](docs/apple_icloud_setup.md)
- [Nextcloud Calendar and Tasks setup](docs/nextcloud_setup.md)
- [Privacy and data map](docs/privacy_data_map.md)
+- [Android setup and OAuth registration](docs/android_setup.md)
+- [Android provider parity](docs/android_provider_parity.md)
Development and maintenance:
- [Development setup for Linux and Windows](docs/development.md)
+- [Android release](docs/android_release.md), [verification](docs/android_verification.md),
+ and [implementation status](docs/android_implementation_status.md)
- [Google developer OAuth registration](docs/google_setup.md) and
[Microsoft developer OAuth registration](docs/microsoft_setup.md)
- [iCalendar and DAV data model](docs/icalendar_data_model.md)
diff --git a/analysis_options.yaml b/analysis_options.yaml
index de5a8d23..e0186ba8 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -6,6 +6,9 @@ analyzer:
- tool/google_tasks_discovery/cache/**
- windows/**
- linux/**
+ - android/**
+ - packages/**/example/**
+ - packages/busymax_android_platform/example/**
linter:
rules:
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 00000000..00743b94
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,16 @@
+/.gradle
+/build
+/captures/
+/local.properties
+/busymax.android.properties
+/key.properties
+*.jks
+*.keystore
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 00000000..d801cc0a
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,163 @@
+import java.util.Properties
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+
+plugins {
+ id("com.android.application")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "io.busystack.busymax"
+ compileSdk = 37
+ ndkVersion = flutter.ndkVersion
+
+ buildFeatures {
+ resValues = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ isCoreLibraryDesugaringEnabled = true
+ }
+
+ val publicConfig = Properties()
+ val publicConfigFile = rootProject.file("busymax.android.properties")
+ if (publicConfigFile.isFile) {
+ publicConfigFile.inputStream().use(publicConfig::load)
+ }
+ val msalClientId = publicConfig.getProperty("microsoft.clientId", "").trim()
+ val msalAuthorityTenant = publicConfig.getProperty("microsoft.authorityTenant", "common").trim()
+ val msalSignatureHash = publicConfig.getProperty("microsoft.signatureHash", "busymax-not-configured").trim()
+ val generatedBusyMaxResources = layout.buildDirectory
+ .dir("generated/busymax/res")
+ .get()
+ .asFile
+ sourceSets.getByName("main").res.srcDir(generatedBusyMaxResources)
+
+ val signingProperties = Properties()
+ val signingPropertiesFile = rootProject.file("key.properties")
+ if (signingPropertiesFile.isFile) {
+ signingPropertiesFile.inputStream().use(signingProperties::load)
+ }
+
+ signingConfigs {
+ if (signingPropertiesFile.isFile) {
+ create("release") {
+ storeFile = file(signingProperties.getProperty("storeFile"))
+ storePassword = signingProperties.getProperty("storePassword")
+ keyAlias = signingProperties.getProperty("keyAlias")
+ keyPassword = signingProperties.getProperty("keyPassword")
+ }
+ }
+ }
+
+ defaultConfig {
+ applicationId = "io.busystack.busymax"
+ minSdk = 24
+ targetSdk = 37
+ // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
+ // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
+ // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
+ // flag during build.
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ manifestPlaceholders["busymaxMsalRedirectHost"] = applicationId!!
+ manifestPlaceholders["busymaxMsalSignatureHash"] = msalSignatureHash
+ resValue("string", "busymax_msal_client_id", msalClientId)
+ resValue("string", "busymax_msal_authority_tenant", msalAuthorityTenant)
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ signingConfig = when {
+ signingPropertiesFile.isFile -> signingConfigs.getByName("release")
+ providers.gradleProperty("busymaxTestSigning").orNull == "true" ->
+ signingConfigs.getByName("debug")
+ else -> null
+ }
+ }
+ }
+}
+
+val generateBusyMaxAndroidConfiguration by tasks.registering {
+ val clientId = providers.provider {
+ val properties = Properties()
+ val file = rootProject.file("busymax.android.properties")
+ if (file.isFile) file.inputStream().use(properties::load)
+ properties.getProperty("microsoft.clientId", "").trim()
+ }
+ val tenant = providers.provider {
+ val properties = Properties()
+ val file = rootProject.file("busymax.android.properties")
+ if (file.isFile) file.inputStream().use(properties::load)
+ properties.getProperty("microsoft.authorityTenant", "common").trim()
+ }
+ val signature = providers.provider {
+ val properties = Properties()
+ val file = rootProject.file("busymax.android.properties")
+ if (file.isFile) file.inputStream().use(properties::load)
+ properties.getProperty("microsoft.signatureHash", "").trim()
+ }
+ val outputDirectory = layout.buildDirectory.dir("generated/busymax/res/raw")
+ inputs.property("microsoftClientId", clientId)
+ inputs.property("microsoftAuthorityTenant", tenant)
+ inputs.property("microsoftSignatureHash", signature)
+ outputs.dir(outputDirectory)
+ doLast {
+ val output = outputDirectory.get().file("busymax_msal_config.json").asFile
+ output.delete()
+ if (clientId.get().isNotEmpty() && signature.get().isNotEmpty()) {
+ output.parentFile.mkdirs()
+ val encodedSignature = URLEncoder.encode(
+ signature.get(),
+ StandardCharsets.UTF_8,
+ )
+ output.writeText(
+ """{
+ "client_id": "${clientId.get()}",
+ "redirect_uri": "msauth://io.busystack.busymax/$encodedSignature",
+ "authorization_user_agent": "BROWSER",
+ "account_mode": "MULTIPLE",
+ "broker_redirect_uri_registered": false,
+ "logging": {"pii_enabled": false, "log_level": "WARNING"},
+ "authorities": [{
+ "type": "AAD",
+ "audience": {
+ "type": "AzureADandPersonalMicrosoftAccount",
+ "tenant_id": "${tenant.get()}"
+ },
+ "default": true
+ }]
+}
+""",
+ )
+ }
+ }
+}
+
+tasks.named("preBuild").configure {
+ dependsOn(generateBusyMaxAndroidConfiguration)
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
+ }
+}
+
+flutter {
+ source = "../.."
+}
+
+dependencies {
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 00000000..f9e24b29
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,18 @@
+# Flutter plugins ship their own consumer rules. Keep only the entrypoints that
+# Android or MSAL invokes by class name after shrinking.
+-keep class io.flutter.plugins.GeneratedPluginRegistrant { *; }
+-keep class com.microsoft.identity.client.BrowserTabActivity { *; }
+
+# Nimbus exposes optional PEM/Bouncy Castle helpers that MSAL's Android token
+# path does not call. Bouncy Castle is intentionally not bundled just to
+# satisfy those unused signatures.
+-dontwarn org.bouncycastle.asn1.ASN1Encodable
+-dontwarn org.bouncycastle.asn1.pkcs.PrivateKeyInfo
+-dontwarn org.bouncycastle.asn1.x509.AlgorithmIdentifier
+-dontwarn org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
+-dontwarn org.bouncycastle.cert.X509CertificateHolder
+-dontwarn org.bouncycastle.cert.jcajce.JcaX509CertificateHolder
+-dontwarn org.bouncycastle.jce.provider.BouncyCastleProvider
+-dontwarn org.bouncycastle.openssl.PEMKeyPair
+-dontwarn org.bouncycastle.openssl.PEMParser
+-dontwarn org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 00000000..399f6981
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..091ebb44
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/io/busystack/busymax/MainActivity.kt b/android/app/src/main/kotlin/io/busystack/busymax/MainActivity.kt
new file mode 100644
index 00000000..fda5349b
--- /dev/null
+++ b/android/app/src/main/kotlin/io/busystack/busymax/MainActivity.kt
@@ -0,0 +1,5 @@
+package io.busystack.busymax
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 00000000..f74085f3
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml
new file mode 100644
index 00000000..7f21ca01
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml
new file mode 100644
index 00000000..8779f57d
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 00000000..304732f8
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..69fc468d
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..69fc468d
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..79a4b405
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..597c3146
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..79a4b405
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..be74f97b
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..c9045d65
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..be74f97b
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..4a2e8bd7
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..7525f4a1
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..4a2e8bd7
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..32218881
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..a1539c5f
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..32218881
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..1a9822d7
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..5b7d5ce3
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..1a9822d7
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 00000000..06952be7
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..2fb4c8c7
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #F8F1E3
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 00000000..cb1ef880
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 00000000..abf894d5
--- /dev/null
+++ b/android/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 00000000..404dc1d3
--- /dev/null
+++ b/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 00000000..399f6981
--- /dev/null
+++ b/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 00000000..8294796c
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,27 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ maven {
+ url = uri("https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1")
+ }
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/android/busymax.android.properties.example b/android/busymax.android.properties.example
new file mode 100644
index 00000000..63052048
--- /dev/null
+++ b/android/busymax.android.properties.example
@@ -0,0 +1,20 @@
+# Public Android registration values. Copy this file to
+# android/busymax.android.properties and never put secrets in it.
+#
+# Google Identity Services uses the package name and certificate fingerprints
+# registered in Google Cloud; it does not use the desktop OAuth client secret.
+google.androidPackage=io.busystack.busymax
+google.debugSha256=
+google.releaseSha256=
+google.playSha256=
+
+# MSAL public-client registration. microsoft.signatureHash is the Base64
+# signer hash used in the msauth redirect URI, not a secret.
+microsoft.clientId=
+microsoft.authorityTenant=common
+microsoft.signatureHash=
+
+# Public links embedded in the Flutter release by tool/android scripts.
+busymax.privacyPolicyUrl=https://busystack.org/privacy
+busymax.supportUrl=https://busystack.org/support
+busymax.homepageUrl=https://busystack.org
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 00000000..fbfdfa1b
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,7 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
+# Flutter 3.47.4 still casts the current AGP extension to the legacy variant
+# API, so this one compatibility flag is required until the pinned Flutter
+# Gradle plugin migrates. Kotlin compilation itself remains AGP 9 built-in.
+android.newDsl=false
+android.builtInKotlin=true
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..13372aef
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..da80f8d1
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 00000000..9d82f789
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn ( ) {
+ echo "$*"
+}
+
+die ( ) {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+esac
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
+function splitJvmOpts() {
+ JVM_OPTS=("$@")
+}
+eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
+JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
+
+exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 00000000..8a0b282a
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,90 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windowz variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+if "%@eval[2+2]" == "4" goto 4NT_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+goto execute
+
+:4NT_args
+@rem Get arguments from the 4NT Shell from JP Software
+set CMD_LINE_ARGS=%$
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/key.properties.example b/android/key.properties.example
new file mode 100644
index 00000000..aa9237c5
--- /dev/null
+++ b/android/key.properties.example
@@ -0,0 +1,5 @@
+# Copy to android/key.properties. Keep this file and the keystore untracked.
+storeFile=/absolute/path/to/busymax-upload.jks
+storePassword=
+keyAlias=
+keyPassword=
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 00000000..1086b33e
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,28 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "9.2.0" apply false
+ // Flutter 3.47.4 validates this minimum even though AGP 9 performs the
+ // actual built-in Kotlin compilation.
+ id("org.jetbrains.kotlin.android") version "2.4.0" apply false
+}
+
+include(":app")
diff --git a/docs/android_implementation_status.md b/docs/android_implementation_status.md
new file mode 100644
index 00000000..c6b3de8d
--- /dev/null
+++ b/docs/android_implementation_status.md
@@ -0,0 +1,57 @@
+# Android implementation status
+
+The Android entrypoint composes a Material 3 Schedule, Tasks, and Settings
+application independently of the Yaru Linux and Fluent Windows entrypoints.
+Compact windows use bottom navigation and wider windows use a navigation rail.
+Schedule supports a virtual agenda, day/week time grids with all-day and
+overlap placement, a civil-date month grid with selected-day agenda, and a
+year of month miniatures. Date navigation, search, calendar/task-list filters,
+details, maps handoff, and contextual creation are Android-owned Material
+flows. Empty periods retain the selected calendar view, and intersecting
+overnight/multiday events appear on every applicable day. Mobile event/task
+editors use concrete provider capabilities, account-qualified destinations,
+discard confirmation, and encrypted draft recovery.
+
+The app-owned `busymax_android_platform` plugin owns Google Identity Services,
+MSAL, account binding, secure cross-engine account gates, Android system time
+settings, document/content URI activation, document-tree export, external URI
+handoff, local-network permission, and cold/warm activation delivery. Desktop
+OAuth loopback flows are not used by Android.
+
+WorkManager registers separate unique 15-minute provider-sync and local
+notification-refill jobs. Provider sync requires a network; reminder refill
+does not, so cached reminders are maintained while offline or after a provider
+failure. Both use battery/storage constraints and bounded exponential backoff,
+and provider mutations can also request a replaceable immediate sync. A
+headless provider container uses no widgets. Foreground and headless mutations
+serialize by account; notification reconciliation has its own process-wide
+gate. Native leases are engine-owned and released both by the worker stop
+callback and native engine teardown, preventing an abandoned worker from
+holding synchronization indefinitely.
+
+Android notification mappings are durable database rows. Reconciliation uses a
+90-day/256-alarm horizon, stable collision-safe IDs, generation checks, exact
+alarms when allowed and inexact fallback otherwise, quiet hours, private text,
+Open/Snooze/Dismiss, due-today deduplication, and sync/conflict status channels.
+Quiet-hour-adjusted delivery time is used for reconciliation, pending overdue
+inexact alarms are retained, and privacy/precision changes replace stale
+registrations. The explicit Open action launches the UI. Ordinary reminder
+creation requests notification permission. Snooze/Dismiss persist and
+reconcile from the background callback without requiring a live UI engine.
+Future scheduling is never recorded as proof that a notification was displayed.
+
+ICS import previews content and destination before mutation; incoming shared
+URIs are bounded `content:` reads. Content-provider reads and document writes
+run off Android's UI thread. Single DAV items and native collections use
+Storage Access Framework output. Android routes expose supported Nextcloud
+sharing, publishing, trash/restore, task hierarchy, advanced VTODO fields, and
+native export. Android 17 local-network permission is requested only for
+contextual hostnames/private addresses and denial stops onboarding. Settings
+persistence uses flush plus atomic rename. Backup/transfer rules exclude the
+secure store, preferences, and local database. The database itself is
+app-private but not described as encrypted.
+
+External console registration, production signing, Play app-signing identity,
+physical-device lifecycle testing, live disposable-provider accounts, and store
+publication cannot be completed from source alone. Their exact verification
+state is maintained in [android_verification.md](android_verification.md).
diff --git a/docs/android_provider_parity.md b/docs/android_provider_parity.md
new file mode 100644
index 00000000..30853f33
--- /dev/null
+++ b/docs/android_provider_parity.md
@@ -0,0 +1,39 @@
+# Android provider parity
+
+Android uses the same repositories, conflict handling, DAV engines, pending
+operation rules, and provider capability models as the desktop applications.
+The Material editors hide or disable fields and operations not supported by the
+selected concrete collection.
+
+| Provider | Authorization | Calendar | Tasks | Administration and limits |
+|---|---|---|---|---|
+| Google | Native Google Identity Services `AuthorizationClient`; explicit chooser; account-specific silent tokens | Read/write, recurrence, reminders, guests, colors and supported calendar mutation | Read/write Google Tasks fields; no invented reminder/time/recurrence support | Calendar/list creation and supported rename/remove/delete are queued through existing cloud mutations. Google alone is disabled when Play services is absent. |
+| Microsoft | Native MSAL multiple-account public client; browser interactive flow; exact native-account silent lookup | Read/write Microsoft Graph calendars and supported recurrence/reminder/attendee fields | Read/write Microsoft To Do including supported due time, reminders, importance, categories, progress, recurrence, and hierarchy | Built-in/non-owner restrictions remain enforced. Signer-specific redirects must be registered. |
+| Apple iCloud | CalDAV with user-supplied app-specific password in secure storage | Calendar-only read/write according to server privileges | Not supported | BusyMax does not imply Apple Reminders support or collection creation where the existing capability matrix denies it. |
+| Nextcloud | Login Flow v2 and secure app-password storage | CalDAV read/write, native ICS, collection permissions, and Android sharing/publishing/trash/restore routes | VTODO read/write with per-collection DAV capabilities; due/start/completion, status/progress/priority, recurrence, multiple alarms, hierarchy, duplicate/move and native export are exposed when supported | Calendar/task-list create, rename, delete/remove, sharing grants, publish/unpublish, trash restore/permanent delete, reminders, and document-tree native export are available only when server privileges allow them. Local hosts use contextual Android 17 LAN permission and denial aborts onboarding. |
+| WebCal | Confirmed subscription URL; embedded credentials remain in credential storage | Read-only subscription refresh and local rename/color metadata | Not supported | No event mutation or provider collection write is presented. |
+
+## Android workflow entry points
+
+| Workflow | Visible route | Capability and permission gate | Input and result behavior |
+|---|---|---|---|
+| Invitation response | Schedule item detail > **Accept**, **Tentative**, or **Decline** | The current user must be an attendee. Google and Microsoft use their existing response mutations; Nextcloud also requires scheduling reply capability and asks for occurrence/series scope when applicable. | Updates the shared local event projection, queues the account-specific calendar sync, and reports a redacted failure without inventing support for Apple or WebCal. |
+| Guest availability | Event editor > **Check guest availability** | A valid start/end and at least one guest are required. Google uses the shared free/busy API and preserves each requested calendar's success, failure, or missing-result state. Nextcloud additionally requires a DAV collection with `canQueryFreeBusy`. Microsoft, Apple, and WebCal do not show the action. | Queries the edited guest set and draft interval, then shows a bounded per-recipient result with refresh and error states. **Free** is shown only for an explicit successful result with no busy intervals; partial failures and omitted results are **Unknown**. It does not mutate the event. |
+| Nextcloud scheduling inbox | Settings > a Nextcloud calendar > **Scheduling inbox** | Requires a Nextcloud DAV calendar; server scheduling capability and responses are enforced by the shared service. | Loads cached then refreshed inbox messages. Acknowledgement is explicitly confirmed and removes the inbox message, not the calendar event. |
+| Calendar administration | Settings > calendar > rename/color/delete | Concrete source capabilities determine which actions appear. Queued Google/Microsoft mutations use the existing account-specific pending-calendar sync requester; online DAV administration stays on its shared service path. | Optimistic local changes retain the normal pending/error state and are not duplicated by a second sync subsystem. |
+| Diagnostics | Settings > **Diagnostics** | Always local; individual retry/discard actions require a concrete blocked operation. | Shows account/auth and last-sync state, reminder precision, and live blocked operations. Retry is explicit; discard requires confirmation; diagnostic errors are redacted according to Settings. |
+| Feedback | Settings > **Send feedback** | Available only when the configured HTTPS feedback endpoint is valid. | Validates category, subject, message, optional reply address, and explicit technical-detail consent; shows stable success/failure state and protects a dirty form from Back. |
+
+These are UI entry points, not live-provider certification. Provider payload,
+projection, and capability tests are deterministic; authorization, delivery,
+server interoperability, and Android lifecycle behavior retain the device/live
+test boundaries in [android_verification.md](android_verification.md).
+
+All five schedule views are available as distinct implementations: virtual
+Agenda, time-axis Day/Week, scrollable Month with selected-day agenda, and Year
+month miniatures. Android defaults independently to Agenda, while the existing
+desktop default remains Week. Creating from the Schedule screen always asks for
+a destination when more than one writable calendar or task list exists and
+shows the account with the collection, preserving identity even when names or
+remote IDs collide. Microsoft's task reminder is edited independently from its
+due/start date and time.
diff --git a/docs/android_release.md b/docs/android_release.md
new file mode 100644
index 00000000..7dd33536
--- /dev/null
+++ b/docs/android_release.md
@@ -0,0 +1,62 @@
+# Android release and signing
+
+Production key material is never tracked. Copy
+`android/key.properties.example` to ignored `android/key.properties`, point
+`storeFile` at the owner-controlled keystore, and fill its passwords and alias
+outside logs and shell history. Do not replace an established upload identity.
+
+With real public provider configuration and owner signing present, build both
+formats with:
+
+```sh
+tool/android/build_release.sh --all
+tool/android/inspect_artifacts.sh
+```
+
+The individual release branches are `tool/android/build_release.sh --apk` and
+`tool/android/build_release.sh --aab`. To install the already-built APK without
+starting a debug session, use the Android SDK platform tool explicitly:
+
+```sh
+adb -s DEVICE_ID install -r build/app/outputs/flutter-apk/app-release.apk
+```
+
+To launch/install from source with the explicit Android entrypoint, use
+`tool/android/run.sh --device DEVICE_ID`. Certificate identity can be inspected
+directly with the Build Tools 37 `apksigner verify --verbose --print-certs`
+command, or as part of `tool/android/inspect_artifacts.sh`. The build and
+inspection commands were exercised with test signing; device install/launch is
+recorded as blocked, not passed, in the verification report.
+
+Outputs are:
+
+- `build/app/outputs/flutter-apk/app-release.apk`
+- `build/app/outputs/bundle/release/app-release.aab`
+- `build/android/android-artifact-inspection.txt`
+
+The script selects `lib/main_android.dart`, enables R8/resource shrinking, and
+fails if Microsoft public registration or production signing is absent. For CI
+or development only, `--test-signing --allow-unconfigured-providers` produces
+an explicitly labeled debug-identity release-mode artifact. That artifact is
+not a finished distribution build and its unavailable provider flows are
+expected to remain disabled.
+
+`inspect_artifacts.sh` records SHA-256 values, APK certificate identity,
+manifest metadata when `apkanalyzer` is available, ABIs, APK 16 KB zip
+alignment, and ELF LOAD alignment in both APK and AAB. Set `BUNDLETOOL_JAR` to
+an existing official bundletool jar to add its AAB configuration dump; the
+script does not silently download tools.
+
+Three identities must be tracked separately:
+
+1. Debug/test-signed builds use the Android debug certificate.
+2. A locally distributed release APK uses the certificate from
+ `android/key.properties`.
+3. A Play-installed APK normally uses the Play app-signing certificate; the
+ AAB upload certificate may be different.
+
+Register Google and Microsoft for the certificate that signs the installed
+APK. Before release, test a fresh install and upgrade on physical hardware,
+then verify cold notification activation and a real calendar/task round trip.
+Publishing, Play enrollment, store disclosures, and production key use require
+the owner's authorization.
diff --git a/docs/android_setup.md b/docs/android_setup.md
new file mode 100644
index 00000000..37b12bd1
--- /dev/null
+++ b/docs/android_setup.md
@@ -0,0 +1,99 @@
+# Android setup and provider registration
+
+BusyMax Android is a beta application with package ID
+`io.busystack.busymax`, version `0.2.2+2`, minimum API 24, and compile/target
+API 37. The supported toolchain is Flutter 3.47.4, its bundled Dart 3.13.3,
+JDK 17, Android platform 37, and Build Tools 37.x.
+
+The resolved Android build uses Gradle 9.4.1 and Android Gradle Plugin 9.2.0.
+Built-in Kotlin is enabled; the Kotlin plugin declaration is 2.4.0 so Flutter's
+toolchain validation sees the supported language level. Flutter 3.47.4's own
+Gradle integration still casts the Android application extension to the legacy
+type, so this pinned project retains `android.newDsl=false`. Enabling the new
+DSL was exercised and reproducibly fails in Flutter's Gradle plugin with an
+`ApplicationExtensionImpl`/`AbstractAppExtension` class cast before the app is
+configured. This is a narrow pinned-toolchain compatibility setting, not a
+blanket AGP 9 restriction.
+
+Run the prerequisite check before changing the project:
+
+```sh
+tool/android/check_prerequisites.sh
+cp android/busymax.android.properties.example android/busymax.android.properties
+```
+
+`android/busymax.android.properties` is ignored. It contains public
+registration values and public web links, never passwords or client secrets.
+`android/local.properties` remains the normal ignored Android SDK pointer.
+
+The public configuration keys have these exact consumers:
+
+| Key | Consumer |
+|---|---|
+| `google.androidPackage` | Owner/CI registration check record; Google Identity Services validates the compiled application ID directly. |
+| `google.debugSha256`, `google.releaseSha256`, `google.playSha256` | Owner/CI registration records for the three signer contexts; fingerprints are not credentials or embedded OAuth secrets. |
+| `microsoft.clientId` | Gradle-generated MSAL string resource and `raw/busymax_msal_config.json`. |
+| `microsoft.authorityTenant` | Audience tenant in the generated MSAL configuration; defaults to `common`. |
+| `microsoft.signatureHash` | URL-encoded signer component of the generated `msauth` redirect. |
+| `busymax.privacyPolicyUrl`, `busymax.supportUrl`, `busymax.homepageUrl` | `tool/android/build_release.sh` maps these to the corresponding `BUSYMAX_*` Dart defines consumed by `BuildConfig`. |
+
+## Google Identity Services
+
+Create Android OAuth registrations for `io.busystack.busymax`, enable the
+Google Calendar and Google Tasks APIs, and register every certificate context
+that will install the app: debug, locally distributed release, and Play app
+signing. Put their SHA-256 fingerprints in the local configuration as an owner
+record; Gradle does not send these values anywhere. Google authorization uses
+the on-device `AuthorizationClient` with explicit account selection. The
+desktop client ID and desktop client secret are neither required nor embedded
+on Android. An AOSP device without Google Play services intentionally leaves
+only Google unavailable; Microsoft, Apple, Nextcloud, and WebCal remain usable.
+
+## Microsoft MSAL
+
+Register a public/native Android application for personal and organizational
+accounts as required by the product tenant policy. Configure:
+
+- `microsoft.clientId`: the real public client ID;
+- `microsoft.authorityTenant`: normally `common`, or the owner-approved tenant;
+- `microsoft.signatureHash`: the Base64 certificate signature hash used by
+ MSAL's signer-specific Android redirect.
+
+The generated redirect is
+`msauth://io.busystack.busymax/`. The generated MSAL
+resource uses browser authorization and `MULTIPLE` account mode. Debug, local
+release, and Play-installed certificates need their own registered redirect.
+BusyMax binds each domain account to an MSAL native account ID and uses that
+exact ID for silent foreground and WorkManager token requests.
+
+## Other providers and Android permissions
+
+Apple iCloud Calendar continues to require an app-specific password. Nextcloud
+uses Login Flow v2 and stores the returned app password in secure storage.
+WebCal is read-only. On Android 17, a local Nextcloud hostname causes a
+contextual local-network permission request; a public HTTPS server does not.
+Notification and exact-alarm permissions are requested only from the relevant
+Settings controls. Exact access improves reminder precision but is not required
+for synchronization or for inexact reminders.
+
+Run on one explicitly selected device:
+
+```sh
+tool/android/run.sh --device DEVICE_ID
+```
+
+The same entrypoint is available as the checked-in **BusyMax Android** IDE run
+configuration. Live-provider tests must use disposable QA accounts and the
+certificate registration matching the installed build.
+
+On a fresh checkout, the complete non-device verification command is:
+
+```sh
+export JAVA_HOME=/path/to/jdk-17
+export ANDROID_SDK_ROOT=/path/to/android-sdk
+tool/android/verify.sh
+```
+
+It enforces the lockfile, regenerates localization and Drift output, checks
+generated consistency and formatting, runs analysis, platform boundaries, the
+complete Flutter suite, native plugin unit tests, and Android lint.
diff --git a/docs/android_verification.md b/docs/android_verification.md
new file mode 100644
index 00000000..19edfb4c
--- /dev/null
+++ b/docs/android_verification.md
@@ -0,0 +1,174 @@
+# Android verification report
+
+This report records what was actually exercised for the Android implementation
+on September 14, 2026. `PASSED` means the named command or procedure completed
+successfully, `FAILED` means it was exercised and failed, `BLOCKED` means a
+specific external prerequisite was unavailable, and `NOT RUN` means an
+appropriate runner or environment was not available. A skipped live-provider
+test is never counted as a pass.
+
+This is a dated verification snapshot. Its recorded Flutter 3.47.2 values are
+historical evidence, not the current repository requirement; see
+[Development](development.md) for the active toolchain pin.
+
+## Build identity and environment
+
+| Item | Recorded value |
+|---|---|
+| Source baseline | `f14f309f0357712f878b53df7b2643105996684a` |
+| Reviewed Android foundation | The source baseline, including the `BusyMax_Android_a8d0604_Review.md` corrections and the four follow-up correctness fixes described here |
+| Verification host | `skynet3`, Linux x86_64, Ubuntu kernel `7.0.0-31-generic` |
+| Verification time | Current correction pass on `2026-09-14` (`America/Vancouver`) |
+| Flutter / Dart | Flutter `3.47.2` (`d3b14c8769`), bundled Dart `3.13.2` |
+| Java | Eclipse Temurin JDK `17.0.20.1+1`, verified temporary archive used for the current release build |
+| Android toolchain | platform `37.0` revision 2, Build Tools `37.0.0`, Gradle `9.4.1`, AGP `9.2.0` |
+| Kotlin configuration | built-in Kotlin enabled; plugin declaration `2.4.0`; `android.newDsl=false` for the documented Flutter 3.47.2 compatibility requirement |
+| Application | `io.busystack.busymax`, beta `0.2.2+2`, min SDK 24, target/compile SDK 37 |
+| Available Flutter devices | Linux desktop and Chrome only; no Android emulator or physical Android device |
+
+## Automated and build evidence
+
+| Status | Check | Command or procedure | Evidence |
+|---|---|---|---|
+| PASSED | Pinned SDK prerequisites | `tool/android/check_prerequisites.sh` with Temurin JDK 17 | Flutter 3.47.2, Dart 3.13.2, API 37 and Build Tools 37.x accepted |
+| NOT RUN | Localization and Drift generation | `flutter gen-l10n`; bundled `dart run build_runner build --force-jit` | No localization or schema input changed in this correction pass. The earlier result is not relabeled as a current run. |
+| PASSED | Repository-wide formatting | bundled `dart format --output=none --set-exit-if-changed .` | All 719 Dart files were already formatted; the check made no changes. |
+| PASSED | Repository-wide static analysis | `flutter analyze` | `No issues found!` for the current correction working tree. |
+| PASSED | Platform boundary enforcement | bundled `dart run tool/check_platform_boundaries.dart` | Current Android foreground/headless composition contains no prohibited desktop imports. |
+| PASSED | Repository-wide unit and widget suite | `flutter test --concurrency=1 --reporter compact` | 2,275 tests passed and 10 explicitly environment-gated tests were skipped in the current correction working tree. The skipped live-integration cases are not counted as passes. |
+| PASSED | Android platform test suite | `flutter test test/platform/android --concurrency=1` | 69 current Android unit/widget tests passed in one serialized run. This includes the scenario-specific rows below; it is not counted again as an additional 69 scenarios. |
+| PASSED | Affected shared regression set | `flutter test` over calendar collection/repository, pending sync requester, Nextcloud scheduling service/policy, and feedback submission/presentation test files with `--concurrency=1` | 100 tests passed. These verify shared payload/projection/service behavior used by the new Android routes; they do not turn widget navigation into a live-provider round trip. |
+| PASSED | Android adaptive widget matrix | `flutter test test/platform/android/android_adaptive_app_test.dart --concurrency=1` | 31 widget tests passed. The empty shell matrix covers the required sizes/themes/RTL. Populated flows explicitly select Day, Week, Month, and Year, exercise all-day/date-only overflow at compact and wide Week widths, submit and inspect an invitation response, and render Month/Year at 320x500 and 200% text. Long task details retain their full notes and scroll to the Edit action at ordinary-phone, 700x320 landscape, and 200% text constraints. System Back is exercised independently for every named event/task text field. Stateful secure-storage tests restore a new event's saved destination and time even when the editor reopens on another date, and round-trip recurring scope and advanced task recurrence. Google partial free/busy failures and omitted recipient results render Unknown while an explicit successful empty result renders Free. Dirty task save-and-move, cancel, and failure paths inspect real database/recovery state. Settings routes, Apple credential-prompt lifecycle, Google calendar rename/color/delete sync requests, and feedback failure/retry identity are exercised. |
+| PASSED | Android notification policy | `flutter test test/platform/android/android_notification_policy_test.dart --concurrency=1` | 8 helper/contract tests passed: stable IDs, quiet-hours eligibility, pending/displayed retention predicates, privacy/exact/content fingerprinting, explicit Open UI action, and payload parsing. |
+| PASSED | Android notification reconciliation | `flutter test test/platform/android/android_notification_reconciliation_test.dart --concurrency=1` | 8 service tests passed against database rows and a stateful pending/active platform fake: displayed retention, stale generation cancellation, Dismiss, overdue inexact pending retention, Snooze, quiet-hours deferral, content-only pending replacement, and overdue daily-summary privacy/precision replacement. |
+| PASSED | Android authorization regression | `flutter test test/platform/android/android_authorization_broker_test.dart` | Missing native scopes are not promoted to grants, native interaction-required becomes the shared reconnect error, and non-revoking local Google removal clears its native binding |
+| PASSED | Android attendee mutation regression | `flutter test test/platform/android/android_event_attendee_edit_test.dart` | Unchanged guest text preserves the original structured attendee objects and change flag; additions retain existing guest metadata |
+| PASSED | Android contextual LAN regression | `flutter test test/platform/android/android_local_network_host_test.dart --concurrency=1` | 6 tests passed for local suffixes and literals plus injected DNS resolution of ordinary dotted names to private, public, mixed, and failed results. These are resolver/onboarding-policy tests, not an Android 17 socket-permission device test. |
+| PASSED | Android month/year range regression | `flutter test test/platform/android/android_month_grid_range_test.dart --concurrency=1` | 4 tests passed for exact 42-day Sunday/Monday-first display-query parity, civil-date stepping across offset-changing dates, and Year query coverage of every miniature's adjacent-month cells. |
+| PASSED | Android platform channel contract | `flutter test test/platform/android/android_platform_channel_test.dart` | Provider availability, account-specific silent Microsoft request, content-URI behavior, and explicit engine-owned gate release contract |
+| PASSED | Android settings persistence | `flutter test test/platform/android/android_settings_persistence_test.dart` | Android view preference is independent and atomically persisted |
+| PASSED | Native plugin unit tests | `JAVA_HOME=.../java/17.0.18-tem android/gradlew --project-dir android :busymax_android_platform:testDebugUnitTest --no-daemon --max-workers=1` | 3 native unit tests passed, including UI-required versus service/network/configuration MSAL classification and engine-owned lease release. This does not exercise a live MSAL account. |
+| PASSED | Android lint | `JAVA_HOME=.../java/17.0.18-tem android/gradlew --project-dir android :app:lintDebug --no-daemon --max-workers=1` | Current correction tree completed successfully; [lint-results-debug.html](../build/app/reports/lint-results-debug.html). Dependency/build deprecation warnings remain non-fatal and are not reported as app lint findings. |
+| PASSED | Current release packaging | Explicit Temurin 17 path and one-worker `tool/android/build_release.sh --all --test-signing --allow-unconfigured-providers` | Current correction tree produced the APK and AAB below. Both use the local debug identity and unconfigured-provider allowance, so this is engineering packaging evidence, not production readiness. |
+| PASSED | Current artifact inspection | Temurin 17 `tool/android/inspect_artifacts.sh` | Current APK/AAB hashes and ABIs recorded below; APK v2 signature, manifest/SDK/permission values, and packaged ELF 16 KB LOAD-segment compatibility passed. Bundletool metadata remains the separate blocked row. |
+| BLOCKED | End-to-end clean-snapshot wrapper run | Export JDK 17 and `ANDROID_SDK_ROOT`, then run `tool/android/verify.sh` in a clean source snapshot | The earlier clean snapshot entered deterministic generation but the host terminated that duplicate heavy process with signal 9. No current clean-snapshot wrapper pass is claimed; the successful current repository-wide suite and serialized component checks are recorded separately above. |
+| BLOCKED | Bundletool AAB configuration dump | `tool/android/inspect_artifacts.sh` | `BUNDLETOOL_JAR` was not installed. Supply a reviewed official bundletool jar and rerun. AAB ZIP contents and every packaged ELF were still inspected directly. |
+| BLOCKED | Android integration test | `flutter test integration_test/android_platform_smoke_test.dart -d DEVICE_ID` | No Android device was attached. Supply an API 24/36/37 emulator or phone. |
+| NOT RUN | Windows workflow | Existing Windows CI on an appropriate Windows runner | No Windows runner was available from this Linux workspace; no Windows result is claimed. |
+
+Android composition has an explicit `lib/main_android.dart`; the Linux and
+Windows entrypoints remain separate.
+
+## Current test-signed release artifact record
+
+The following artifacts were produced from the current correction working
+tree. They are release-mode artifacts deliberately signed with the local
+Android debug identity and allow unconfigured provider registrations. They are
+suitable for engineering inspection only and must not be distributed or
+uploaded.
+
+| Artifact | Size | SHA-256 |
+|---|---:|---|
+| `build/app/outputs/flutter-apk/app-release.apk` | 86,965,328 bytes | `3ba8ab41fc7d23bad2d44c1d43a078cef7893b42fdb6693e7347d0081eaa7b12` |
+| `build/app/outputs/bundle/release/app-release.aab` | 82,785,235 bytes | `139f69721718bd9bb2a0a603e354eb10a22a4dcfe5afbd3ce5d546d29971ef01` |
+
+Inspection found `arm64-v8a`, `armeabi-v7a`, and `x86_64`, with Flutter,
+application, Dart JNI, and SQLite native libraries in each ABI. Every packaged
+ELF LOAD segment in the APK and AAB passed the 16 KB alignment check, and the
+APK passed 16 KB ZIP alignment. This is binary inspection, not a substitute
+for the blocked 16 KB system-image launch.
+
+The APK uses signature scheme v2. Its test signer is
+`C=US, O=Android, CN=Android Debug`, SHA-256
+`7E:E9:78:47:B9:60:7F:3B:84:51:69:F4:C3:92:BA:EE:7F:8E:C2:C5:2C:AC:33:FE:32:7C:D1:A0:0E:94:9B:68`.
+Manifest inspection confirmed package `io.busystack.busymax`, version name
+`0.2.2`, version code `2`, minimum SDK 24 and target SDK 37. The merged release
+permissions are Internet, network state, notifications, exact alarms, boot,
+wake lock, vibration, foreground service/short-service, Android 17 local
+network, and Android's generated non-exported dynamic-receiver permission. It
+contains no contacts, location, device-calendar, SMS, NFC, broad storage,
+package-enumeration, or full-screen-alarm permission.
+
+## Required functional matrix
+
+The automated rows below verify repository logic, persistence, presentation,
+and platform contracts with deterministic fixtures. The corresponding real-OS
+or live-service row remains blocked where this host lacks a device, disposable
+provider account, or owner registration.
+
+| Status | Area | Result and evidence or exact unblock requirement |
+|---|---|---|
+| PASSED | Bootstrap and navigation — automated | Empty/populated/error/offline repository states, adaptive navigation, selected dates/filters, and cold/warm activation routing are covered by the full suite and Android widget/channel tests. |
+| BLOCKED | Bootstrap and navigation — installed app | Fresh/returning install, process restoration, and cold/warm file/notification launches require an Android device and installed APK. |
+| PASSED | Google — implementation/contract | Native `AuthorizationClient`, explicit selection, exact BusyMax/native binding, provider-reported grant persistence, required-scope validation, reconnect error mapping, local binding removal, Play-services availability, Calendar/Tasks repository reuse, per-calendar free/busy success/failure/missing results, and no Android desktop OAuth imports are implemented and contract-tested. |
+| BLOCKED | Google — live | Two disposable Google accounts, enabled Calendar/Tasks APIs, and Android OAuth registrations for the debug, local-release and Play app-signing certificate fingerprints are required to exercise consent/cancel/deny/expiry/reconnect, real data, removal, and foreground/headless silent acquisition. |
+| PASSED | Microsoft — implementation/contract | MSAL multiple-account browser flow, exact native-account binding, provider-reported grant persistence, account-specific silent acquisition, domain reconnect mapping, independent reminder date/time editing, and removal isolation are implemented. Native unit tests distinguish interaction-required from service/network/configuration failures; live account recovery is not implied. |
+| BLOCKED | Microsoft — live | A real public client ID/tenant, signer-specific debug/local/Play redirects, disposable personal and organizational accounts, and an Android device are required for consent/browser return/silent worker/removal tests. |
+| PASSED | Nextcloud — automated | Existing shared tests cover DAV engines, Login Flow v2 state, capabilities, pending operations, conflicts, and collection administration. Android source exposes capability-gated sharing/publishing/trash/restore, scheduling inbox, guest availability, invitation reply, advanced VTODO/hierarchy, and export routes. Current Android widget coverage verifies route presence and error-safe composition; it does not claim a live Nextcloud round trip. |
+| BLOCKED | Nextcloud — live | A disposable public-HTTPS server account plus an owner-configured LAN endpoint on an Android 17 device are required to verify polling/resume, app-password storage, real calendars/tasks/sharing/publishing/trash/admin and denied-LAN behavior. |
+| PASSED | Apple and WebCal — automated | Calendar-only/read-only capability enforcement, credential separation, subscription refresh state and meaningful account failure state use the existing tested repositories and Android mobile UI. |
+| BLOCKED | Apple and WebCal — live | Disposable iCloud credentials with an app-specific password and disposable WebCal endpoints are required to verify revocation, refresh, and private embedded credentials against real services. |
+| PASSED | Calendar and tasks — automated | Virtual Agenda, time-axis Day/Week with bounded accessible all-day overflow, scrollable civil-date Month with selected-day agenda, and Year miniatures are implemented. Current populated Android fixtures verify overnight intersection, source/list hiding, Week selection, invitation controls, all-day overflow, short/large-text Month/Year, and full scrollable details at phone, short-landscape, and enlarged-text constraints. Editor tests cover read-only controls, account-qualified destinations, every named text field's system-Back guard, versioned recovery identity across changed creation defaults, recurrence/scope, and dirty task moves. Shared provider payload tests remain a separate evidence layer. |
+| BLOCKED | Calendar and tasks — service round trips | Named destructive workflows for Google, Microsoft, Nextcloud, Apple and WebCal require provider registrations and dedicated QA collections. No owner's real collection was modified. |
+| PASSED | Offline and conflicts — automated | Cached browsing, supported queued mutations, replay/backoff, ETags, tombstones, preserved ICS properties, interrupted/ambiguous creates and serialized UI/worker account access are covered by the shared suite and account gate. |
+| BLOCKED | Offline and conflicts — real network | Provider-side state after a dropped create response and reconnect must be observed using disposable remote collections and controlled networking on an Android device. |
+| PASSED | Database and preferences — automated | Schema migration through version 18, foreground/background visibility, atomic settings writes, account removal, engine-owned coordination release and account resurrection protections are covered by database, settings, sync and native gate tests. |
+| BLOCKED | Database and preferences — OS interruption | Kill-during-write, upgrade, and account removal while a real WorkManager job owns the native process require an installed app and device instrumentation. |
+| PASSED | Notifications — automated | Durable mappings and stateful reconciliation tests distinguish pending, displayed, dismissed, and stale-generation notifications. They cover quiet-hours deferral, overdue inexact retention, Snooze/Dismiss, explicit Open UI behavior, content-only pending replacement using the effective public/private notification text, and overdue daily-summary privacy/precision replacement. Other shared tests cover horizon/cap and mutation-driven rebuilds. Platform delivery, permission recovery, and refill lifecycle remain device rows; future scheduling is not labeled delivery. |
+| BLOCKED | Notifications — delivered behavior | A physical phone and signed installed build are required for screen-off timing, process death, permission/channel denial/revocation, reboot, package upgrade, battery idle, exact/inexact observation and cold/warm actions. |
+| PASSED | Time and recurrence — automated | Shared timezone, DST gap/overlap, all-day/date-only/floating fields, recurrence and exception tests pass; Android system timezone/12–24-hour channel updates feed the app scope. |
+| BLOCKED | Time and recurrence — system changes | A device is required to change clock/timezone/locale/12–24-hour settings and verify alarm rescheduling across provider/device timezone differences. |
+| PASSED | Files and links — automated | ICS preview-before-import, no silent mutation, bounded and sanitized content-URI reads, off-main-thread content-provider I/O, SAF create/tree operations, malformed/oversized/cancel/duplicate activation handling, geo/browser fallback and missing-handler feedback are implemented and contract-tested. |
+| BLOCKED | Files and links — document providers | A device with at least two document providers and maps/browser handlers is required for persisted/revoked grants, picker cancellation, native collection export, and cold-start share/open behavior. |
+| PASSED | Security and release — source/artifact | Secret-free public examples, credential store separation, private notification payload design, backup exclusions, release manifest, R8, exact permissions, v2 test signature, and APK/AAB 16 KB binary inspection passed. |
+| BLOCKED | Security and release — production | Owner keystore/CI secret configuration, Play enrollment/app-signing identity, provider console registration, fresh install/upgrade, 16 KB system-image launch, and production-signed inspection are required. |
+
+## Device, lifecycle, visual, and accessibility matrix
+
+| Status | Check | Evidence or unblock requirement |
+|---|---|---|
+| BLOCKED | API 24 device | No API 24 emulator/device was available. Start one with `adb` visibility and run `tool/android/run.sh --device DEVICE_ID` plus the integration and lifecycle procedures. |
+| BLOCKED | API 36 device | No API 36 emulator/device was available. Required for the declared-range permission and lifecycle boundary. |
+| BLOCKED | API 37 Google-services image | Required for Android 17 LAN permission and Google integration; no image/device was available. |
+| BLOCKED | API 37 AOSP image | Required to verify Google-unavailable while Microsoft/DAV/WebCal remain usable; no image/device was available. |
+| BLOCKED | 16 KB system image | Native binaries passed static inspection, but actual launch/runtime requires a configured 16 KB Android image. |
+| BLOCKED | Physical phone | Authentication, signed-release reminder delivery, screen-off/battery-idle timing and cold notification activation require a physical phone. |
+| BLOCKED | Rotation/split screen/background/foreground | Widget constraints passed, but the real Activity lifecycle needs an Android device. |
+| BLOCKED | Ordinary process death/reboot | WorkManager, alarm restoration and database invalidation must be observed after real process removal and reboot. |
+| BLOCKED | Force-stop | A device is required. Expected behavior is explicitly limited: Android does not guarantee work or alarms after user force-stop until the app is started again. |
+| PASSED | Logical viewport rendering | The empty-shell matrix covers required logical sizes, themes, scales, RTL, and every primary destination. The populated matrix separately covers Day/Week data, short 320x500/200% Month/Year, and long scrollable details at 412x915, 700x320, and 200% text without framework exceptions. This is logical widget rendering, not a device screenshot. |
+| PASSED | Automated accessibility | Widget tests verify labelled navigation and accessible all-day overflow, and exercise large text without clamping. Touch-target and color/contrast statements remain source/widget checks rather than TalkBack or physical-device certification. |
+| BLOCKED | Actual Android screenshots | No Android device existed, so no device screenshots were produced. Linux screenshots are intentionally not presented as Android evidence. Required scenes are mixed schedule, selected-day month agenda, timed overlaps, tasks with undated/completed items, keyboard-visible event editor, account selection, settings, and loading/error/offline states using QA data. |
+| BLOCKED | TalkBack/traversal/contrast | Requires a device or emulator with Android accessibility services and manual observation at all named screens. |
+| BLOCKED | Attached keyboard/touch alternatives/predictive Back | Requires an Android Activity and input devices; verify draft preservation and route-first Back behavior. |
+| BLOCKED | Profile/release performance | Requires a named phone with populated QA data. Record cold/warm startup, frame timings, account-switch time, list lengths and acceptance thresholds rather than inventing numbers. |
+
+## Owner/external completion checklist
+
+These items cannot be safely fabricated or performed without owner-controlled
+inputs. They are deployment verification blockers, not unfinished source
+implementation:
+
+1. Copy `android/busymax.android.properties.example` to the ignored local file,
+ enter the real Microsoft public client/authority/signature hash and public
+ product links, and register Google/MSAL for each actual signing context.
+2. Provide owner-controlled production signing through ignored
+ `android/key.properties` or protected CI secrets. Rebuild without
+ `--test-signing`/`--allow-unconfigured-providers`, then record the new
+ fingerprints and artifact hashes.
+3. Supply disposable provider accounts and dedicated QA collections; execute
+ every live-provider and destructive matrix row without touching production
+ data.
+4. Run the API 24, 36, API 37 GMS/AOSP, 16 KB image and physical-phone matrices,
+ including process death, reboot, notification timing, installation and
+ upgrade. Capture the required real-screen evidence.
+5. Run the preserved Windows workflow on a Windows runner, and use a reviewed
+ local `BUNDLETOOL_JAR` for the remaining AAB configuration inspection.
+6. Complete Play Console signing, policy disclosures and publication only with
+ the owner's explicit authorization. Internal test readiness is not
+ authorization to publish.
+
+Reproducible configuration, run, build, install-oriented launch, signing and
+inspection commands are documented in [android_setup.md](android_setup.md) and
+[android_release.md](android_release.md). Provider behavior and limitations are
+enumerated in [android_provider_parity.md](android_provider_parity.md).
diff --git a/docs/development.md b/docs/development.md
index f1fae93e..a6aae1cf 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -5,14 +5,14 @@ and Windows. Release packaging has separate procedures linked below.
## Shared setup
-BusyMax development and CI use Flutter **3.47.2** and the Dart **3.13.2** SDK
+BusyMax development and CI use Flutter **3.47.4** and the Dart **3.13.3** SDK
bundled with that Flutter installation. Use the bundled `dart` executable; do
not mix Flutter with a different global Dart SDK. The `pubspec.yaml` constraint
`sdk: ^3.12.0` is the Dart language/package compatibility constraint. It is not
the repository's build-toolchain version.
The workflows verify the selected SDK with `tool/verify_flutter_sdk.dart`.
-After installing Flutter 3.47.2, prepare the checkout in this order:
+After installing Flutter 3.47.4, prepare the checkout in this order:
```bash
flutter pub get --enforce-lockfile
@@ -95,7 +95,7 @@ x86, ARM64, MSI, and EXE installers are outside the supported target.
Install:
-- Flutter 3.47.2 with Windows desktop enabled;
+- Flutter 3.47.4 with Windows desktop enabled;
- Visual Studio 2022 with **Desktop development with C++**, the x64 MSVC
toolchain, and CMake tools;
- Windows SDK `10.0.26100.0` or newer; and
diff --git a/docs/privacy_data_map.md b/docs/privacy_data_map.md
index 5447f1a2..3455b093 100644
--- a/docs/privacy_data_map.md
+++ b/docs/privacy_data_map.md
@@ -8,7 +8,7 @@ add telemetry or analytics.
| Data | Local handling | Network or external destination |
|---|---|---|
-| Google/Microsoft OAuth tokens, Apple app-specific password, Nextcloud app password, WebCal subscription secret | Operating-system credential storage on unpackaged Linux and Windows; the strict Snap uses an encrypted Secret portal file | Used only with the selected provider, Nextcloud server, or WebCal endpoint |
+| Google/Microsoft OAuth tokens, Apple app-specific password, Nextcloud app password, WebCal subscription secret | Operating-system credential storage on Android, unpackaged Linux, and Windows; the strict Snap uses an encrypted Secret portal file. Android Google/MSAL SDK caches are app-private. | Used only with the selected provider, Nextcloud server, or WebCal endpoint |
| OAuth authorization code, PKCE verifier, and state | Short-lived in memory during browser sign-in; callbacks are restricted to loopback and secrets are redacted from logs | Selected provider's authorization/token endpoints |
| Account, calendar, task-list, event, task, attendee, reminder, recurrence, DAV resource, synchronization, pending-operation, and conflict data | Cached and projected in the Drift database under the platform application-support directory | Selected provider during discovery, synchronization, or a user-requested mutation |
| Local settings | Platform application-support/settings storage | No provider destination |
@@ -54,7 +54,7 @@ viewed, saved, or synchronized.
External opening occurs only after the user chooses **Show on map** or
**Open link**. BusyMax hands the saved address or coordinates to a registered
-`geo:`/maps handler on Linux, with a Google Maps browser fallback, or to the
+`geo:`/maps handler on Android or Linux, with a browser fallback, or to the
default browser on Windows. A complete saved HTTP(S) value opens its own host.
The receiving application or website can receive the destination and normal
network metadata. BusyMax does not retain a separate opening history or mutate
@@ -76,6 +76,38 @@ Click, Open, Snooze, and Dismiss activation data returns through the packaged
Windows activation bridge. Linux notifications use the desktop notification
service to display reminder content and route actions.
+## Android platform data and permissions
+
+Android calendar/task content and coordination metadata are stored in the
+app-private database; credentials use Android-backed secure storage or the
+app-private caches owned by Google Identity Services and MSAL. Android backup
+and device-transfer rules exclude BusyMax preferences, secure-storage files,
+and databases so provider credentials and cached private content are not copied
+by the platform backup service. Cached calendar/task rows are not encrypted by
+BusyMax and remain protected by the Android application sandbox.
+
+BusyMax requests internet and network-state access for provider sync. Android
+13+ notification permission is requested only when a notification feature is
+enabled. Exact-alarm access is optional: if unavailable, BusyMax clearly uses
+inexact Android scheduling. Android 17 local-network permission is requested
+contextually only when the user enters a local Nextcloud host; denial is kept
+distinct from an authentication failure. No broad media or storage permission,
+contacts permission, device-calendar permission, location permission,
+advertising ID, analytics, Firebase, microphone, or camera access is used.
+
+ICS input and output use the Storage Access Framework. BusyMax consumes bounded
+`content:` URI data selected or shared by the user and does not turn a content
+URI into a filesystem path. Collection export writes only to the document tree
+the user selected. Notification payloads contain bounded local identifiers and
+generation data, not event bodies, account addresses, tokens, or credentials.
+The notification body can be made private in Settings.
+
+WorkManager may start a headless Flutter engine to synchronize configured
+accounts and refill notification schedules. Google and Microsoft access tokens
+are acquired silently for the exact stored native account binding; short-lived
+access tokens are not persisted by BusyMax. Periodic work and alarms remain
+subject to Android battery, permission, idle, and explicit force-stop policy.
+
## Feedback
Every explicit feedback submission contains:
diff --git a/docs/provider_support_matrix.md b/docs/provider_support_matrix.md
index a3399f04..526e206c 100644
--- a/docs/provider_support_matrix.md
+++ b/docs/provider_support_matrix.md
@@ -36,6 +36,14 @@ DAV-backed events preserve data BusyMax does not edit, including recurrence
exceptions, alarms, timezones, parameters, and provider extensions. See the
[iCalendar and DAV data model](icalendar_data_model.md).
+On Android, supported invitation responses are visible from an invitation's
+Schedule detail sheet. Google and capability-enabled Nextcloud guest
+availability is visible in the event editor after at least one guest and a
+valid interval are present. Microsoft availability is intentionally absent
+because it is not part of the provider contract. A capability-enabled
+Nextcloud calendar exposes its scheduling inbox from **Settings > calendar >
+Scheduling inbox**.
+
## Task objects
| Capability | Google Tasks | Microsoft To Do | Nextcloud Tasks |
diff --git a/docs/time_format_validation.md b/docs/time_format_validation.md
index eb886bfb..699e9869 100644
--- a/docs/time_format_validation.md
+++ b/docs/time_format_validation.md
@@ -1,6 +1,6 @@
# Time-format correction
-Validated against this checkout with Flutter 3.47.2, Dart 3.13.2,
+Validated against this checkout with Flutter 3.47.4, Dart 3.13.3,
intl 0.20.2, and fluent_ui 4.16.1. The reported Linux picker, parser,
Fluent picker, schedule boundary, and Windows tray-cache defects were present.
diff --git a/docs/windows_packaging.md b/docs/windows_packaging.md
index 8b6fc460..351ccea7 100644
--- a/docs/windows_packaging.md
+++ b/docs/windows_packaging.md
@@ -51,7 +51,7 @@ Use a 64-bit Windows 11 PowerShell prompt:
-ConfigPath config\windows_store.local.json
```
-The script verifies Flutter 3.47.2 and bundled Dart 3.13.2, Visual Studio x64
+The script verifies Flutter 3.47.4 and bundled Dart 3.13.3, Visual Studio x64
C++ tools, and Windows SDK 10.0.26100.0 or newer. It runs source generation,
formatting, analysis, Dart/Flutter and PowerShell tests, platform-boundary and
native tests, builds `lib/main_windows.dart`, stages runtime files, validates
diff --git a/docs/windows_release_checklist.md b/docs/windows_release_checklist.md
index 2a88a8bf..7498fa1f 100644
--- a/docs/windows_release_checklist.md
+++ b/docs/windows_release_checklist.md
@@ -10,7 +10,7 @@ person/date responsible for each result.
## Automated gates
-- [ ] A clean checkout uses Flutter 3.47.2 and its bundled Dart 3.13.2.
+- [ ] A clean checkout uses Flutter 3.47.4 and its bundled Dart 3.13.3.
- [ ] Dependency resolution, localization generation, and Drift generation
follow [Development](development.md) and leave committed outputs clean.
- [ ] The non-writing formatting check, analyzer, normal tests, and
diff --git a/integration_test/android_platform_smoke_test.dart b/integration_test/android_platform_smoke_test.dart
new file mode 100644
index 00000000..f01347ce
--- /dev/null
+++ b/integration_test/android_platform_smoke_test.dart
@@ -0,0 +1,16 @@
+import 'package:busymax_android_platform/busymax_android_platform.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ testWidgets('Android native bridge exposes device settings', (tester) async {
+ final platform = BusyMaxAndroidPlatform.instance;
+
+ expect(await platform.currentTimeZoneId(), isNotEmpty);
+ expect(await platform.uses24HourFormat(), isA());
+ expect(await platform.googleAuthorizationAvailable(), isA());
+ expect(await platform.microsoftAuthorizationAvailable(), isA());
+ });
+}
diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb
index cc071677..7bba1cef 100644
--- a/lib/l10n/app_ar.arb
+++ b/lib/l10n/app_ar.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "تم تشغيل BusyMax من إعدادات بدء التشغيل في سطح المكتب. أزل إدخاله هناك، ثم أنهِ BusyMax وافتحه مجددًا لاستخدام هذا المفتاح.",
"timeFormat": "تنسيق الوقت",
+ "firstDayOfWeek": "أول أيام الأسبوع",
+ "systemDefault": "الإعداد الافتراضي للنظام",
+ "systemDefaultResolved": "الإعداد الافتراضي للنظام ({weekday})",
"timeFormatTwelveHour": "12 ساعة",
"timeFormatTwentyFourHour": "24 ساعة",
"timeEndOfDay": "نهاية اليوم",
@@ -1024,5 +1028,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "عوامل تصفية البحث",
+ "searchType": "النوع",
+ "searchDate": "التاريخ",
+ "searchAnyDate": "أي تاريخ",
+ "searchThisWeek": "هذا الأسبوع",
+ "searchCustomRange": "نطاق مخصص",
+ "searchTaskStatus": "حالة المهمة",
+ "searchTaskDue": "استحقاق المهمة",
+ "searchAnyDueState": "أي حالة استحقاق",
+ "searchNoDueDate": "بلا تاريخ استحقاق",
+ "searchPerson": "الشخص",
+ "searchSources": "المصادر",
+ "searchClearFilters": "إعادة ضبط عوامل التصفية",
+ "searchFiltersAction": "عوامل التصفية",
+ "searchNoSources": "لم يتم تحديد مصادر للبحث",
+ "searchClearText": "مسح النص"
}
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
index dcc96092..f50e4bd9 100644
--- a/lib/l10n/app_de.arb
+++ b/lib/l10n/app_de.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "BusyMax wurde durch die Autostart-Einstellungen Ihres Desktops gestartet. Entfernen Sie dort den Eintrag, beenden Sie BusyMax und öffnen Sie es erneut, um diesen Schalter zu verwenden.",
"timeFormat": "Zeitformat",
+ "firstDayOfWeek": "Erster Wochentag",
+ "systemDefault": "Systemstandard",
+ "systemDefaultResolved": "Systemstandard ({weekday})",
"timeFormatTwelveHour": "12 Stunden",
"timeFormatTwentyFourHour": "24 Stunden",
"timeEndOfDay": "Tagesende",
@@ -1087,5 +1091,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "Suchfilter",
+ "searchType": "Eintragstyp",
+ "searchDate": "Datum",
+ "searchAnyDate": "Beliebiges Datum",
+ "searchThisWeek": "Diese Woche",
+ "searchCustomRange": "Eigener Zeitraum",
+ "searchTaskStatus": "Aufgabenstatus",
+ "searchTaskDue": "Aufgabenfälligkeit",
+ "searchAnyDueState": "Beliebige Fälligkeit",
+ "searchNoDueDate": "Kein Fälligkeitsdatum",
+ "searchPerson": "Beteiligte Person",
+ "searchSources": "Quellen",
+ "searchClearFilters": "Filter zurücksetzen",
+ "searchFiltersAction": "Suchfilter öffnen",
+ "searchNoSources": "Keine Suchquellen ausgewählt",
+ "searchClearText": "Text löschen"
}
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 74d16664..a44eabbf 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -1,5 +1,10 @@
{
+ "launchAtLoginManagedExternally": "BusyMax was launched by your desktop's startup settings. Remove its entry there, then quit and reopen BusyMax to use this switch.",
"timeFormat": "Time format",
+ "firstDayOfWeek": "First day of the week",
+ "systemDefault": "System Default",
+ "systemDefaultResolved": "System Default ({weekday})",
+ "@systemDefaultResolved": {"placeholders": {"weekday": {"type": "String"}}},
"timeFormatTwelveHour": "12-hour",
"timeFormatTwentyFourHour": "24-hour",
"timeEndOfDay": "end of day",
@@ -1563,5 +1568,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "Search filters",
+ "searchType": "Type",
+ "searchDate": "Date",
+ "searchAnyDate": "Any date",
+ "searchThisWeek": "This week",
+ "searchCustomRange": "Custom range",
+ "searchTaskStatus": "Task status",
+ "searchTaskDue": "Task due",
+ "searchAnyDueState": "Any due state",
+ "searchNoDueDate": "No due date",
+ "searchPerson": "Person",
+ "searchSources": "Sources",
+ "searchClearFilters": "Clear filters",
+ "searchFiltersAction": "Filters",
+ "searchNoSources": "No search sources selected",
+ "searchClearText": "Clear text"
}
diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb
index faaec07f..e70e9026 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "BusyMax se inició desde los ajustes de inicio del escritorio. Elimina su entrada allí y luego sal de BusyMax y vuelve a abrirlo para usar este interruptor.",
"timeFormat": "Formato de hora",
+ "firstDayOfWeek": "Primer día de la semana",
+ "systemDefault": "Predeterminado del sistema",
+ "systemDefaultResolved": "Predeterminado del sistema ({weekday})",
"timeFormatTwelveHour": "12 horas",
"timeFormatTwentyFourHour": "24 horas",
"timeEndOfDay": "fin del día",
@@ -1087,5 +1091,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "Filtros de búsqueda",
+ "searchType": "Tipo",
+ "searchDate": "Fecha",
+ "searchAnyDate": "Cualquier fecha",
+ "searchThisWeek": "Esta semana",
+ "searchCustomRange": "Intervalo personalizado",
+ "searchTaskStatus": "Estado de tareas",
+ "searchTaskDue": "Vencimiento de tareas",
+ "searchAnyDueState": "Cualquier vencimiento",
+ "searchNoDueDate": "Sin fecha de vencimiento",
+ "searchPerson": "Persona",
+ "searchSources": "Fuentes",
+ "searchClearFilters": "Restablecer filtros",
+ "searchFiltersAction": "Filtros",
+ "searchNoSources": "No hay fuentes de búsqueda seleccionadas",
+ "searchClearText": "Borrar texto"
}
diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb
index 5e37c343..c454497b 100644
--- a/lib/l10n/app_et.arb
+++ b/lib/l10n/app_et.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "BusyMax käivitati töölaua automaatkäivituse seadete kaudu. Eemalda sealt selle kirje, seejärel sulge BusyMax täielikult ja ava see lüliti kasutamiseks uuesti.",
"timeFormat": "Kellaajavorming",
+ "firstDayOfWeek": "Nädala esimene päev",
+ "systemDefault": "Süsteemi vaikeväärtus",
+ "systemDefaultResolved": "Süsteemi vaikeväärtus ({weekday})",
"timeFormatTwelveHour": "12-tunnine",
"timeFormatTwentyFourHour": "24-tunnine",
"timeEndOfDay": "päeva lõpp",
@@ -1202,5 +1206,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "Otsingufiltrid",
+ "searchType": "Tüüp",
+ "searchDate": "Kuupäev",
+ "searchAnyDate": "Kõik kuupäevad",
+ "searchThisWeek": "See nädal",
+ "searchCustomRange": "Kohandatud vahemik",
+ "searchTaskStatus": "Ülesande olek",
+ "searchTaskDue": "Ülesande tähtaeg",
+ "searchAnyDueState": "Kõik tähtajad",
+ "searchNoDueDate": "Tähtajata",
+ "searchPerson": "Isik",
+ "searchSources": "Allikad",
+ "searchClearFilters": "Lähtesta filtrid",
+ "searchFiltersAction": "Filtrid",
+ "searchNoSources": "Otsinguallikaid pole valitud",
+ "searchClearText": "Tühjenda tekst"
}
diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb
index c35fee64..2d2b1e71 100644
--- a/lib/l10n/app_fa.arb
+++ b/lib/l10n/app_fa.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "BusyMax از تنظیمات راهاندازی خودکار میزکار شما اجرا شده است. ورودی آن را در آنجا حذف کنید، سپس از BusyMax خارج شوید و دوباره آن را باز کنید تا بتوانید از این کلید استفاده کنید.",
"timeFormat": "قالب زمان",
+ "firstDayOfWeek": "روز اول هفته",
+ "systemDefault": "پیشفرض سیستم",
+ "systemDefaultResolved": "پیشفرض سیستم ({weekday})",
"timeFormatTwelveHour": "۱۲ ساعته",
"timeFormatTwentyFourHour": "۲۴ ساعته",
"timeEndOfDay": "پایان روز",
@@ -1098,5 +1102,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "فیلترهای جستجو",
+ "searchType": "نوع",
+ "searchDate": "تاریخ",
+ "searchAnyDate": "هر تاریخی",
+ "searchThisWeek": "این هفته",
+ "searchCustomRange": "بازهٔ دلخواه",
+ "searchTaskStatus": "وضعیت کار",
+ "searchTaskDue": "سررسید کار",
+ "searchAnyDueState": "هر وضعیت سررسید",
+ "searchNoDueDate": "بدون تاریخ سررسید",
+ "searchPerson": "شخص",
+ "searchSources": "منابع",
+ "searchClearFilters": "بازنشانی فیلترها",
+ "searchFiltersAction": "فیلترها",
+ "searchNoSources": "هیچ منبع جستجویی انتخاب نشده است",
+ "searchClearText": "پاک کردن متن"
}
diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb
index a3cb1bdc..ce86ccdd 100644
--- a/lib/l10n/app_fi.arb
+++ b/lib/l10n/app_fi.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "BusyMax käynnistettiin työpöydän automaattisen käynnistyksen asetuksista. Poista sen merkintä sieltä, lopeta BusyMax ja avaa se uudelleen, jotta voit käyttää tätä kytkintä.",
"timeFormat": "Aikamuoto",
+ "firstDayOfWeek": "Viikon ensimmäinen päivä",
+ "systemDefault": "Järjestelmän oletus",
+ "systemDefaultResolved": "Järjestelmän oletus ({weekday})",
"timeFormatTwelveHour": "12 tuntia",
"timeFormatTwentyFourHour": "24 tuntia",
"timeEndOfDay": "vuorokauden loppu",
@@ -1024,5 +1028,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "Hakusuodattimet",
+ "searchType": "Tyyppi",
+ "searchDate": "Päivämäärä",
+ "searchAnyDate": "Kaikki päivämäärät",
+ "searchThisWeek": "Tämä viikko",
+ "searchCustomRange": "Mukautettu aikaväli",
+ "searchTaskStatus": "Tehtävän tila",
+ "searchTaskDue": "Tehtävän erääntyminen",
+ "searchAnyDueState": "Kaikki erääntymistilat",
+ "searchNoDueDate": "Ei eräpäivää",
+ "searchPerson": "Henkilö",
+ "searchSources": "Lähteet",
+ "searchClearFilters": "Nollaa suodattimet",
+ "searchFiltersAction": "Suodattimet",
+ "searchNoSources": "Hakulähteitä ei ole valittu",
+ "searchClearText": "Tyhjennä teksti"
}
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index 5046e9b5..ba1f2146 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "BusyMax a été lancé par les paramètres de démarrage de votre bureau. Supprimez son entrée à cet endroit, puis quittez et rouvrez BusyMax pour utiliser cet interrupteur.",
"timeFormat": "Format de l’heure",
+ "firstDayOfWeek": "Premier jour de la semaine",
+ "systemDefault": "Valeur par défaut du système",
+ "systemDefaultResolved": "Valeur par défaut du système ({weekday})",
"timeFormatTwelveHour": "12 heures",
"timeFormatTwentyFourHour": "24 heures",
"timeEndOfDay": "fin de journée",
@@ -1087,5 +1091,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "Filtres de recherche",
+ "searchType": "Type d’élément",
+ "searchDate": "Date de recherche",
+ "searchAnyDate": "Toutes les dates",
+ "searchThisWeek": "Cette semaine",
+ "searchCustomRange": "Période personnalisée",
+ "searchTaskStatus": "État des tâches",
+ "searchTaskDue": "Échéance des tâches",
+ "searchAnyDueState": "Toute échéance",
+ "searchNoDueDate": "Sans échéance",
+ "searchPerson": "Personne",
+ "searchSources": "Sources de recherche",
+ "searchClearFilters": "Réinitialiser les filtres",
+ "searchFiltersAction": "Filtres",
+ "searchNoSources": "Aucune source de recherche sélectionnée",
+ "searchClearText": "Effacer le texte"
}
diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb
index 37ebe383..a6ae8cd3 100644
--- a/lib/l10n/app_hi.arb
+++ b/lib/l10n/app_hi.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "BusyMax आपके डेस्कटॉप की स्टार्टअप सेटिंग से शुरू हुआ था। वहाँ से इसकी प्रविष्टि हटाएँ, फिर इस स्विच का उपयोग करने के लिए BusyMax से बाहर निकलें और इसे दोबारा खोलें।",
"timeFormat": "समय प्रारूप",
+ "firstDayOfWeek": "सप्ताह का पहला दिन",
+ "systemDefault": "सिस्टम डिफ़ॉल्ट",
+ "systemDefaultResolved": "सिस्टम डिफ़ॉल्ट ({weekday})",
"timeFormatTwelveHour": "12 घंटे",
"timeFormatTwentyFourHour": "24 घंटे",
"timeEndOfDay": "दिन का अंत",
@@ -1024,5 +1028,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "खोज फ़िल्टर",
+ "searchType": "प्रकार",
+ "searchDate": "तारीख",
+ "searchAnyDate": "कोई भी तारीख",
+ "searchThisWeek": "इस सप्ताह",
+ "searchCustomRange": "कस्टम अवधि",
+ "searchTaskStatus": "कार्य स्थिति",
+ "searchTaskDue": "कार्य की नियत तारीख",
+ "searchAnyDueState": "कोई भी नियत स्थिति",
+ "searchNoDueDate": "बिना नियत तारीख",
+ "searchPerson": "व्यक्ति",
+ "searchSources": "स्रोत",
+ "searchClearFilters": "फ़िल्टर रीसेट करें",
+ "searchFiltersAction": "फ़िल्टर",
+ "searchNoSources": "कोई खोज स्रोत नहीं चुना गया",
+ "searchClearText": "पाठ साफ़ करें"
}
diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb
index a3024906..2c9b75cb 100644
--- a/lib/l10n/app_it.arb
+++ b/lib/l10n/app_it.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "BusyMax è stato avviato dalle impostazioni di avvio del desktop. Rimuovi la sua voce da lì, poi esci da BusyMax e riaprilo per usare questo interruttore.",
"timeFormat": "Formato ora",
+ "firstDayOfWeek": "Primo giorno della settimana",
+ "systemDefault": "Predefinito di sistema",
+ "systemDefaultResolved": "Predefinito di sistema ({weekday})",
"timeFormatTwelveHour": "12 ore",
"timeFormatTwentyFourHour": "24 ore",
"timeEndOfDay": "fine giornata",
@@ -1024,5 +1028,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "Filtri di ricerca",
+ "searchType": "Tipo",
+ "searchDate": "Data",
+ "searchAnyDate": "Qualsiasi data",
+ "searchThisWeek": "Questa settimana",
+ "searchCustomRange": "Intervallo personalizzato",
+ "searchTaskStatus": "Stato attività",
+ "searchTaskDue": "Scadenza attività",
+ "searchAnyDueState": "Qualsiasi scadenza",
+ "searchNoDueDate": "Senza scadenza",
+ "searchPerson": "Persona",
+ "searchSources": "Origini",
+ "searchClearFilters": "Reimposta filtri",
+ "searchFiltersAction": "Filtri",
+ "searchNoSources": "Nessuna origine di ricerca selezionata",
+ "searchClearText": "Cancella testo"
}
diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb
index 1105f9cb..2cdeaca7 100644
--- a/lib/l10n/app_ja.arb
+++ b/lib/l10n/app_ja.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "BusyMax はデスクトップの自動起動設定によって起動されました。このスイッチを使うには、そこで BusyMax の項目を削除してから、BusyMax を終了して開き直してください。",
"timeFormat": "時刻の表示形式",
+ "firstDayOfWeek": "週の最初の曜日",
+ "systemDefault": "システムの既定値",
+ "systemDefaultResolved": "システムの既定値({weekday})",
"timeFormatTwelveHour": "12時間表示",
"timeFormatTwentyFourHour": "24時間表示",
"timeEndOfDay": "一日の終わり",
@@ -1024,5 +1028,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "検索フィルター",
+ "searchType": "種類",
+ "searchDate": "日付",
+ "searchAnyDate": "すべての日付",
+ "searchThisWeek": "今週",
+ "searchCustomRange": "期間を指定",
+ "searchTaskStatus": "タスクの状態",
+ "searchTaskDue": "タスクの期限",
+ "searchAnyDueState": "すべての期限状態",
+ "searchNoDueDate": "期限なし",
+ "searchPerson": "人物",
+ "searchSources": "検索元",
+ "searchClearFilters": "フィルターをリセット",
+ "searchFiltersAction": "フィルター",
+ "searchNoSources": "検索元が選択されていません",
+ "searchClearText": "テキストを消去"
}
diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb
index 6d867555..f25807ee 100644
--- a/lib/l10n/app_ko.arb
+++ b/lib/l10n/app_ko.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "데스크톱의 시작 프로그램 설정에 의해 BusyMax가 실행되었습니다. 이 스위치를 사용하려면 해당 설정에서 BusyMax 항목을 제거한 다음 BusyMax를 완전히 종료하고 다시 여세요.",
"timeFormat": "시간 형식",
+ "firstDayOfWeek": "한 주의 시작 요일",
+ "systemDefault": "시스템 기본값",
+ "systemDefaultResolved": "시스템 기본값({weekday})",
"timeFormatTwelveHour": "12시간",
"timeFormatTwentyFourHour": "24시간",
"timeEndOfDay": "하루의 끝",
@@ -1024,5 +1028,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "검색 필터",
+ "searchType": "유형",
+ "searchDate": "날짜",
+ "searchAnyDate": "모든 날짜",
+ "searchThisWeek": "이번 주",
+ "searchCustomRange": "사용자 지정 기간",
+ "searchTaskStatus": "작업 상태",
+ "searchTaskDue": "작업 기한",
+ "searchAnyDueState": "모든 기한 상태",
+ "searchNoDueDate": "기한 없음",
+ "searchPerson": "사람",
+ "searchSources": "검색 소스",
+ "searchClearFilters": "필터 초기화",
+ "searchFiltersAction": "필터",
+ "searchNoSources": "선택한 검색 소스가 없습니다",
+ "searchClearText": "텍스트 지우기"
}
diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb
index 262365d6..edc8996f 100644
--- a/lib/l10n/app_pl.arb
+++ b/lib/l10n/app_pl.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "BusyMax został uruchomiony przez ustawienia autostartu pulpitu. Usuń stamtąd jego wpis, a następnie zakończ i ponownie uruchom BusyMax, aby użyć tego przełącznika.",
"timeFormat": "Format czasu",
+ "firstDayOfWeek": "Pierwszy dzień tygodnia",
+ "systemDefault": "Domyślne ustawienie systemu",
+ "systemDefaultResolved": "Domyślne ustawienie systemu ({weekday})",
"timeFormatTwelveHour": "12-godzinny",
"timeFormatTwentyFourHour": "24-godzinny",
"timeEndOfDay": "koniec dnia",
@@ -1734,5 +1738,21 @@
"type": "String"
}
}
- }
+ },
+ "searchFilters": "Filtry wyszukiwania",
+ "searchType": "Typ",
+ "searchDate": "Data",
+ "searchAnyDate": "Dowolna data",
+ "searchThisWeek": "Ten tydzień",
+ "searchCustomRange": "Zakres niestandardowy",
+ "searchTaskStatus": "Stan zadania",
+ "searchTaskDue": "Termin zadania",
+ "searchAnyDueState": "Dowolny stan terminu",
+ "searchNoDueDate": "Bez terminu",
+ "searchPerson": "Osoba",
+ "searchSources": "Źródła",
+ "searchClearFilters": "Resetuj filtry",
+ "searchFiltersAction": "Filtry",
+ "searchNoSources": "Nie wybrano źródeł wyszukiwania",
+ "searchClearText": "Wyczyść tekst"
}
diff --git a/lib/l10n/app_pt_PT.arb b/lib/l10n/app_pt_PT.arb
index 46eed407..ad3b62b7 100644
--- a/lib/l10n/app_pt_PT.arb
+++ b/lib/l10n/app_pt_PT.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "O BusyMax foi iniciado pelas definições de arranque do ambiente de trabalho. Remova a respetiva entrada, depois saia do BusyMax e volte a abri-lo para utilizar este interruptor.",
"timeFormat": "Formato da hora",
+ "firstDayOfWeek": "Primeiro dia da semana",
+ "systemDefault": "Predefinição do sistema",
+ "systemDefaultResolved": "Predefinição do sistema ({weekday})",
"timeFormatTwelveHour": "12 horas",
"timeFormatTwentyFourHour": "24 horas",
"timeEndOfDay": "fim do dia",
@@ -1024,5 +1028,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "Filtros de pesquisa",
+ "searchType": "Tipo",
+ "searchDate": "Data",
+ "searchAnyDate": "Qualquer data",
+ "searchThisWeek": "Esta semana",
+ "searchCustomRange": "Intervalo personalizado",
+ "searchTaskStatus": "Estado das tarefas",
+ "searchTaskDue": "Prazo das tarefas",
+ "searchAnyDueState": "Qualquer prazo",
+ "searchNoDueDate": "Sem data de conclusão",
+ "searchPerson": "Pessoa",
+ "searchSources": "Origens",
+ "searchClearFilters": "Repor filtros",
+ "searchFiltersAction": "Filtros",
+ "searchNoSources": "Nenhuma origem de pesquisa selecionada",
+ "searchClearText": "Limpar texto"
}
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index a4f1a4e2..b4f4cfa5 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "BusyMax запущен через настройки автозапуска рабочего стола. Удалите его запись там, затем полностью завершите и снова откройте BusyMax, чтобы использовать этот переключатель.",
"timeFormat": "Формат времени",
+ "firstDayOfWeek": "Первый день недели",
+ "systemDefault": "Системное значение",
+ "systemDefaultResolved": "Системное значение ({weekday})",
"timeFormatTwelveHour": "12 часов",
"timeFormatTwentyFourHour": "24 часа",
"timeEndOfDay": "конец дня",
@@ -1024,5 +1028,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "Фильтры поиска",
+ "searchType": "Тип",
+ "searchDate": "Дата",
+ "searchAnyDate": "Любая дата",
+ "searchThisWeek": "Эта неделя",
+ "searchCustomRange": "Свой диапазон",
+ "searchTaskStatus": "Статус задач",
+ "searchTaskDue": "Срок задач",
+ "searchAnyDueState": "Любой срок",
+ "searchNoDueDate": "Без срока",
+ "searchPerson": "Участник",
+ "searchSources": "Источники",
+ "searchClearFilters": "Сбросить фильтры",
+ "searchFiltersAction": "Фильтры",
+ "searchNoSources": "Источники поиска не выбраны",
+ "searchClearText": "Очистить текст"
}
diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb
index 108340a1..2cba9cd8 100644
--- a/lib/l10n/app_vi.arb
+++ b/lib/l10n/app_vi.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "BusyMax được mở bởi cài đặt khởi động của môi trường máy tính. Xóa mục BusyMax ở đó, rồi thoát hẳn và mở lại BusyMax để dùng công tắc này.",
"timeFormat": "Định dạng giờ",
+ "firstDayOfWeek": "Ngày đầu tuần",
+ "systemDefault": "Mặc định hệ thống",
+ "systemDefaultResolved": "Mặc định hệ thống ({weekday})",
"timeFormatTwelveHour": "12 giờ",
"timeFormatTwentyFourHour": "24 giờ",
"timeEndOfDay": "cuối ngày",
@@ -1024,5 +1028,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "Bộ lọc tìm kiếm",
+ "searchType": "Loại",
+ "searchDate": "Ngày",
+ "searchAnyDate": "Mọi ngày",
+ "searchThisWeek": "Tuần này",
+ "searchCustomRange": "Khoảng tùy chọn",
+ "searchTaskStatus": "Trạng thái công việc",
+ "searchTaskDue": "Hạn công việc",
+ "searchAnyDueState": "Mọi trạng thái hạn",
+ "searchNoDueDate": "Không có hạn",
+ "searchPerson": "Người",
+ "searchSources": "Nguồn",
+ "searchClearFilters": "Đặt lại bộ lọc",
+ "searchFiltersAction": "Bộ lọc",
+ "searchNoSources": "Chưa chọn nguồn tìm kiếm",
+ "searchClearText": "Xóa văn bản"
}
diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb
index ea8614c3..f87c01fe 100644
--- a/lib/l10n/app_zh.arb
+++ b/lib/l10n/app_zh.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。",
"timeFormat": "时间格式",
+ "firstDayOfWeek": "一周的第一天",
+ "systemDefault": "系统默认",
+ "systemDefaultResolved": "系统默认({weekday})",
"timeFormatTwelveHour": "12小时制",
"timeFormatTwentyFourHour": "24小时制",
"timeEndOfDay": "当天结束",
@@ -1024,5 +1028,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "搜索筛选条件",
+ "searchType": "类型",
+ "searchDate": "日期",
+ "searchAnyDate": "任何日期",
+ "searchThisWeek": "本周",
+ "searchCustomRange": "自定义范围",
+ "searchTaskStatus": "任务状态",
+ "searchTaskDue": "任务到期状态",
+ "searchAnyDueState": "任何到期状态",
+ "searchNoDueDate": "无截止日期",
+ "searchPerson": "人员",
+ "searchSources": "来源",
+ "searchClearFilters": "重置筛选条件",
+ "searchFiltersAction": "筛选",
+ "searchNoSources": "未选择搜索来源",
+ "searchClearText": "清除文字"
}
diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb
index 7adbcb39..124fcb11 100644
--- a/lib/l10n/app_zh_Hans.arb
+++ b/lib/l10n/app_zh_Hans.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。",
"timeFormat": "时间格式",
+ "firstDayOfWeek": "一周的第一天",
+ "systemDefault": "系统默认",
+ "systemDefaultResolved": "系统默认({weekday})",
"timeFormatTwelveHour": "12小时制",
"timeFormatTwentyFourHour": "24小时制",
"timeEndOfDay": "当天结束",
@@ -1024,5 +1028,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "搜索筛选条件",
+ "searchType": "类型",
+ "searchDate": "日期",
+ "searchAnyDate": "任何日期",
+ "searchThisWeek": "本周",
+ "searchCustomRange": "自定义范围",
+ "searchTaskStatus": "任务状态",
+ "searchTaskDue": "任务到期状态",
+ "searchAnyDueState": "任何到期状态",
+ "searchNoDueDate": "无截止日期",
+ "searchPerson": "人员",
+ "searchSources": "来源",
+ "searchClearFilters": "重置筛选条件",
+ "searchFiltersAction": "筛选",
+ "searchNoSources": "未选择搜索来源",
+ "searchClearText": "清除文字"
}
diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb
index 1b16e4fd..d548d43a 100644
--- a/lib/l10n/app_zh_Hant.arb
+++ b/lib/l10n/app_zh_Hant.arb
@@ -1,5 +1,9 @@
{
+ "launchAtLoginManagedExternally": "BusyMax 由桌面的啟動設定啟動。請在那裡移除 BusyMax 的項目,然後完全結束並重新開啟 BusyMax,以使用此開關。",
"timeFormat": "時間格式",
+ "firstDayOfWeek": "一週的第一天",
+ "systemDefault": "系統預設",
+ "systemDefaultResolved": "系統預設({weekday})",
"timeFormatTwelveHour": "12小時制",
"timeFormatTwentyFourHour": "24小時制",
"timeEndOfDay": "當天結束",
@@ -1024,5 +1028,21 @@
"position": {"type": "String"},
"days": {"type": "String"}
}
- }
+ },
+ "searchFilters": "搜尋篩選條件",
+ "searchType": "類型",
+ "searchDate": "日期",
+ "searchAnyDate": "任何日期",
+ "searchThisWeek": "本週",
+ "searchCustomRange": "自訂範圍",
+ "searchTaskStatus": "工作狀態",
+ "searchTaskDue": "工作到期狀態",
+ "searchAnyDueState": "任何到期狀態",
+ "searchNoDueDate": "無截止日期",
+ "searchPerson": "人員",
+ "searchSources": "來源",
+ "searchClearFilters": "重設篩選條件",
+ "searchFiltersAction": "篩選",
+ "searchNoSources": "未選取搜尋來源",
+ "searchClearText": "清除文字"
}
diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart
index 085a3033..f98fc773 100644
--- a/lib/l10n/generated/app_localizations.dart
+++ b/lib/l10n/generated/app_localizations.dart
@@ -135,12 +135,36 @@ abstract class AppLocalizations {
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
];
+ /// No description provided for @launchAtLoginManagedExternally.
+ ///
+ /// In en, this message translates to:
+ /// **'BusyMax was launched by your desktop\'s startup settings. Remove its entry there, then quit and reopen BusyMax to use this switch.'**
+ String get launchAtLoginManagedExternally;
+
/// No description provided for @timeFormat.
///
/// In en, this message translates to:
/// **'Time format'**
String get timeFormat;
+ /// No description provided for @firstDayOfWeek.
+ ///
+ /// In en, this message translates to:
+ /// **'First day of the week'**
+ String get firstDayOfWeek;
+
+ /// No description provided for @systemDefault.
+ ///
+ /// In en, this message translates to:
+ /// **'System Default'**
+ String get systemDefault;
+
+ /// No description provided for @systemDefaultResolved.
+ ///
+ /// In en, this message translates to:
+ /// **'System Default ({weekday})'**
+ String systemDefaultResolved(String weekday);
+
/// No description provided for @timeFormatTwelveHour.
///
/// In en, this message translates to:
@@ -4861,6 +4885,102 @@ abstract class AppLocalizations {
String position,
String days,
);
+
+ /// No description provided for @searchFilters.
+ ///
+ /// In en, this message translates to:
+ /// **'Search filters'**
+ String get searchFilters;
+
+ /// No description provided for @searchType.
+ ///
+ /// In en, this message translates to:
+ /// **'Type'**
+ String get searchType;
+
+ /// No description provided for @searchDate.
+ ///
+ /// In en, this message translates to:
+ /// **'Date'**
+ String get searchDate;
+
+ /// No description provided for @searchAnyDate.
+ ///
+ /// In en, this message translates to:
+ /// **'Any date'**
+ String get searchAnyDate;
+
+ /// No description provided for @searchThisWeek.
+ ///
+ /// In en, this message translates to:
+ /// **'This week'**
+ String get searchThisWeek;
+
+ /// No description provided for @searchCustomRange.
+ ///
+ /// In en, this message translates to:
+ /// **'Custom range'**
+ String get searchCustomRange;
+
+ /// No description provided for @searchTaskStatus.
+ ///
+ /// In en, this message translates to:
+ /// **'Task status'**
+ String get searchTaskStatus;
+
+ /// No description provided for @searchTaskDue.
+ ///
+ /// In en, this message translates to:
+ /// **'Task due'**
+ String get searchTaskDue;
+
+ /// No description provided for @searchAnyDueState.
+ ///
+ /// In en, this message translates to:
+ /// **'Any due state'**
+ String get searchAnyDueState;
+
+ /// No description provided for @searchNoDueDate.
+ ///
+ /// In en, this message translates to:
+ /// **'No due date'**
+ String get searchNoDueDate;
+
+ /// No description provided for @searchPerson.
+ ///
+ /// In en, this message translates to:
+ /// **'Person'**
+ String get searchPerson;
+
+ /// No description provided for @searchSources.
+ ///
+ /// In en, this message translates to:
+ /// **'Sources'**
+ String get searchSources;
+
+ /// No description provided for @searchClearFilters.
+ ///
+ /// In en, this message translates to:
+ /// **'Clear filters'**
+ String get searchClearFilters;
+
+ /// No description provided for @searchFiltersAction.
+ ///
+ /// In en, this message translates to:
+ /// **'Filters'**
+ String get searchFiltersAction;
+
+ /// No description provided for @searchNoSources.
+ ///
+ /// In en, this message translates to:
+ /// **'No search sources selected'**
+ String get searchNoSources;
+
+ /// No description provided for @searchClearText.
+ ///
+ /// In en, this message translates to:
+ /// **'Clear text'**
+ String get searchClearText;
}
class _AppLocalizationsDelegate
diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart
index 1a12e206..f93994a1 100644
--- a/lib/l10n/generated/app_localizations_ar.dart
+++ b/lib/l10n/generated/app_localizations_ar.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsAr extends AppLocalizations {
AppLocalizationsAr([String locale = 'ar']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'تم تشغيل BusyMax من إعدادات بدء التشغيل في سطح المكتب. أزل إدخاله هناك، ثم أنهِ BusyMax وافتحه مجددًا لاستخدام هذا المفتاح.';
+
@override
String get timeFormat => 'تنسيق الوقت';
+ @override
+ String get firstDayOfWeek => 'أول أيام الأسبوع';
+
+ @override
+ String get systemDefault => 'الإعداد الافتراضي للنظام';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'الإعداد الافتراضي للنظام ($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12 ساعة';
@@ -2985,4 +3000,52 @@ class AppLocalizationsAr extends AppLocalizations {
});
return '$frequency $_temp0';
}
+
+ @override
+ String get searchFilters => 'عوامل تصفية البحث';
+
+ @override
+ String get searchType => 'النوع';
+
+ @override
+ String get searchDate => 'التاريخ';
+
+ @override
+ String get searchAnyDate => 'أي تاريخ';
+
+ @override
+ String get searchThisWeek => 'هذا الأسبوع';
+
+ @override
+ String get searchCustomRange => 'نطاق مخصص';
+
+ @override
+ String get searchTaskStatus => 'حالة المهمة';
+
+ @override
+ String get searchTaskDue => 'استحقاق المهمة';
+
+ @override
+ String get searchAnyDueState => 'أي حالة استحقاق';
+
+ @override
+ String get searchNoDueDate => 'بلا تاريخ استحقاق';
+
+ @override
+ String get searchPerson => 'الشخص';
+
+ @override
+ String get searchSources => 'المصادر';
+
+ @override
+ String get searchClearFilters => 'إعادة ضبط عوامل التصفية';
+
+ @override
+ String get searchFiltersAction => 'عوامل التصفية';
+
+ @override
+ String get searchNoSources => 'لم يتم تحديد مصادر للبحث';
+
+ @override
+ String get searchClearText => 'مسح النص';
}
diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart
index 343efae8..f67bb9a1 100644
--- a/lib/l10n/generated/app_localizations_de.dart
+++ b/lib/l10n/generated/app_localizations_de.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsDe extends AppLocalizations {
AppLocalizationsDe([String locale = 'de']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax wurde durch die Autostart-Einstellungen Ihres Desktops gestartet. Entfernen Sie dort den Eintrag, beenden Sie BusyMax und öffnen Sie es erneut, um diesen Schalter zu verwenden.';
+
@override
String get timeFormat => 'Zeitformat';
+ @override
+ String get firstDayOfWeek => 'Erster Wochentag';
+
+ @override
+ String get systemDefault => 'Systemstandard';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'Systemstandard ($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12 Stunden';
@@ -2921,4 +2936,52 @@ class AppLocalizationsDe extends AppLocalizations {
});
return '$frequency $_temp0 $days in $months';
}
+
+ @override
+ String get searchFilters => 'Suchfilter';
+
+ @override
+ String get searchType => 'Eintragstyp';
+
+ @override
+ String get searchDate => 'Datum';
+
+ @override
+ String get searchAnyDate => 'Beliebiges Datum';
+
+ @override
+ String get searchThisWeek => 'Diese Woche';
+
+ @override
+ String get searchCustomRange => 'Eigener Zeitraum';
+
+ @override
+ String get searchTaskStatus => 'Aufgabenstatus';
+
+ @override
+ String get searchTaskDue => 'Aufgabenfälligkeit';
+
+ @override
+ String get searchAnyDueState => 'Beliebige Fälligkeit';
+
+ @override
+ String get searchNoDueDate => 'Kein Fälligkeitsdatum';
+
+ @override
+ String get searchPerson => 'Beteiligte Person';
+
+ @override
+ String get searchSources => 'Quellen';
+
+ @override
+ String get searchClearFilters => 'Filter zurücksetzen';
+
+ @override
+ String get searchFiltersAction => 'Suchfilter öffnen';
+
+ @override
+ String get searchNoSources => 'Keine Suchquellen ausgewählt';
+
+ @override
+ String get searchClearText => 'Text löschen';
}
diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart
index aa8cc416..429ac2b8 100644
--- a/lib/l10n/generated/app_localizations_en.dart
+++ b/lib/l10n/generated/app_localizations_en.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax was launched by your desktop\'s startup settings. Remove its entry there, then quit and reopen BusyMax to use this switch.';
+
@override
String get timeFormat => 'Time format';
+ @override
+ String get firstDayOfWeek => 'First day of the week';
+
+ @override
+ String get systemDefault => 'System Default';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'System Default ($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12-hour';
@@ -2953,4 +2968,52 @@ class AppLocalizationsEn extends AppLocalizations {
});
return '$frequency $_temp0';
}
+
+ @override
+ String get searchFilters => 'Search filters';
+
+ @override
+ String get searchType => 'Type';
+
+ @override
+ String get searchDate => 'Date';
+
+ @override
+ String get searchAnyDate => 'Any date';
+
+ @override
+ String get searchThisWeek => 'This week';
+
+ @override
+ String get searchCustomRange => 'Custom range';
+
+ @override
+ String get searchTaskStatus => 'Task status';
+
+ @override
+ String get searchTaskDue => 'Task due';
+
+ @override
+ String get searchAnyDueState => 'Any due state';
+
+ @override
+ String get searchNoDueDate => 'No due date';
+
+ @override
+ String get searchPerson => 'Person';
+
+ @override
+ String get searchSources => 'Sources';
+
+ @override
+ String get searchClearFilters => 'Clear filters';
+
+ @override
+ String get searchFiltersAction => 'Filters';
+
+ @override
+ String get searchNoSources => 'No search sources selected';
+
+ @override
+ String get searchClearText => 'Clear text';
}
diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart
index d46e319d..bcfc3595 100644
--- a/lib/l10n/generated/app_localizations_es.dart
+++ b/lib/l10n/generated/app_localizations_es.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsEs extends AppLocalizations {
AppLocalizationsEs([String locale = 'es']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax se inició desde los ajustes de inicio del escritorio. Elimina su entrada allí y luego sal de BusyMax y vuelve a abrirlo para usar este interruptor.';
+
@override
String get timeFormat => 'Formato de hora';
+ @override
+ String get firstDayOfWeek => 'Primer día de la semana';
+
+ @override
+ String get systemDefault => 'Predeterminado del sistema';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'Predeterminado del sistema ($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12 horas';
@@ -2925,4 +2940,52 @@ class AppLocalizationsEs extends AppLocalizations {
});
return '$frequency $_temp0';
}
+
+ @override
+ String get searchFilters => 'Filtros de búsqueda';
+
+ @override
+ String get searchType => 'Tipo';
+
+ @override
+ String get searchDate => 'Fecha';
+
+ @override
+ String get searchAnyDate => 'Cualquier fecha';
+
+ @override
+ String get searchThisWeek => 'Esta semana';
+
+ @override
+ String get searchCustomRange => 'Intervalo personalizado';
+
+ @override
+ String get searchTaskStatus => 'Estado de tareas';
+
+ @override
+ String get searchTaskDue => 'Vencimiento de tareas';
+
+ @override
+ String get searchAnyDueState => 'Cualquier vencimiento';
+
+ @override
+ String get searchNoDueDate => 'Sin fecha de vencimiento';
+
+ @override
+ String get searchPerson => 'Persona';
+
+ @override
+ String get searchSources => 'Fuentes';
+
+ @override
+ String get searchClearFilters => 'Restablecer filtros';
+
+ @override
+ String get searchFiltersAction => 'Filtros';
+
+ @override
+ String get searchNoSources => 'No hay fuentes de búsqueda seleccionadas';
+
+ @override
+ String get searchClearText => 'Borrar texto';
}
diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart
index 055d4941..59d2fe70 100644
--- a/lib/l10n/generated/app_localizations_et.dart
+++ b/lib/l10n/generated/app_localizations_et.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsEt extends AppLocalizations {
AppLocalizationsEt([String locale = 'et']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax käivitati töölaua automaatkäivituse seadete kaudu. Eemalda sealt selle kirje, seejärel sulge BusyMax täielikult ja ava see lüliti kasutamiseks uuesti.';
+
@override
String get timeFormat => 'Kellaajavorming';
+ @override
+ String get firstDayOfWeek => 'Nädala esimene päev';
+
+ @override
+ String get systemDefault => 'Süsteemi vaikeväärtus';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'Süsteemi vaikeväärtus ($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12-tunnine';
@@ -2900,4 +2915,52 @@ class AppLocalizationsEt extends AppLocalizations {
});
return '$frequency $months: $_temp0 $days';
}
+
+ @override
+ String get searchFilters => 'Otsingufiltrid';
+
+ @override
+ String get searchType => 'Tüüp';
+
+ @override
+ String get searchDate => 'Kuupäev';
+
+ @override
+ String get searchAnyDate => 'Kõik kuupäevad';
+
+ @override
+ String get searchThisWeek => 'See nädal';
+
+ @override
+ String get searchCustomRange => 'Kohandatud vahemik';
+
+ @override
+ String get searchTaskStatus => 'Ülesande olek';
+
+ @override
+ String get searchTaskDue => 'Ülesande tähtaeg';
+
+ @override
+ String get searchAnyDueState => 'Kõik tähtajad';
+
+ @override
+ String get searchNoDueDate => 'Tähtajata';
+
+ @override
+ String get searchPerson => 'Isik';
+
+ @override
+ String get searchSources => 'Allikad';
+
+ @override
+ String get searchClearFilters => 'Lähtesta filtrid';
+
+ @override
+ String get searchFiltersAction => 'Filtrid';
+
+ @override
+ String get searchNoSources => 'Otsinguallikaid pole valitud';
+
+ @override
+ String get searchClearText => 'Tühjenda tekst';
}
diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart
index eaa75e4d..d12a8570 100644
--- a/lib/l10n/generated/app_localizations_fa.dart
+++ b/lib/l10n/generated/app_localizations_fa.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsFa extends AppLocalizations {
AppLocalizationsFa([String locale = 'fa']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax از تنظیمات راهاندازی خودکار میزکار شما اجرا شده است. ورودی آن را در آنجا حذف کنید، سپس از BusyMax خارج شوید و دوباره آن را باز کنید تا بتوانید از این کلید استفاده کنید.';
+
@override
String get timeFormat => 'قالب زمان';
+ @override
+ String get firstDayOfWeek => 'روز اول هفته';
+
+ @override
+ String get systemDefault => 'پیشفرض سیستم';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'پیشفرض سیستم ($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '۱۲ ساعته';
@@ -2956,4 +2971,52 @@ class AppLocalizationsFa extends AppLocalizations {
});
return '$frequency در $_temp0 $days ماههای $months';
}
+
+ @override
+ String get searchFilters => 'فیلترهای جستجو';
+
+ @override
+ String get searchType => 'نوع';
+
+ @override
+ String get searchDate => 'تاریخ';
+
+ @override
+ String get searchAnyDate => 'هر تاریخی';
+
+ @override
+ String get searchThisWeek => 'این هفته';
+
+ @override
+ String get searchCustomRange => 'بازهٔ دلخواه';
+
+ @override
+ String get searchTaskStatus => 'وضعیت کار';
+
+ @override
+ String get searchTaskDue => 'سررسید کار';
+
+ @override
+ String get searchAnyDueState => 'هر وضعیت سررسید';
+
+ @override
+ String get searchNoDueDate => 'بدون تاریخ سررسید';
+
+ @override
+ String get searchPerson => 'شخص';
+
+ @override
+ String get searchSources => 'منابع';
+
+ @override
+ String get searchClearFilters => 'بازنشانی فیلترها';
+
+ @override
+ String get searchFiltersAction => 'فیلترها';
+
+ @override
+ String get searchNoSources => 'هیچ منبع جستجویی انتخاب نشده است';
+
+ @override
+ String get searchClearText => 'پاک کردن متن';
}
diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart
index b86302cd..77de90c6 100644
--- a/lib/l10n/generated/app_localizations_fi.dart
+++ b/lib/l10n/generated/app_localizations_fi.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsFi extends AppLocalizations {
AppLocalizationsFi([String locale = 'fi']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax käynnistettiin työpöydän automaattisen käynnistyksen asetuksista. Poista sen merkintä sieltä, lopeta BusyMax ja avaa se uudelleen, jotta voit käyttää tätä kytkintä.';
+
@override
String get timeFormat => 'Aikamuoto';
+ @override
+ String get firstDayOfWeek => 'Viikon ensimmäinen päivä';
+
+ @override
+ String get systemDefault => 'Järjestelmän oletus';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'Järjestelmän oletus ($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12 tuntia';
@@ -2950,4 +2965,52 @@ class AppLocalizationsFi extends AppLocalizations {
});
return '$frequency $months $_temp0 $days';
}
+
+ @override
+ String get searchFilters => 'Hakusuodattimet';
+
+ @override
+ String get searchType => 'Tyyppi';
+
+ @override
+ String get searchDate => 'Päivämäärä';
+
+ @override
+ String get searchAnyDate => 'Kaikki päivämäärät';
+
+ @override
+ String get searchThisWeek => 'Tämä viikko';
+
+ @override
+ String get searchCustomRange => 'Mukautettu aikaväli';
+
+ @override
+ String get searchTaskStatus => 'Tehtävän tila';
+
+ @override
+ String get searchTaskDue => 'Tehtävän erääntyminen';
+
+ @override
+ String get searchAnyDueState => 'Kaikki erääntymistilat';
+
+ @override
+ String get searchNoDueDate => 'Ei eräpäivää';
+
+ @override
+ String get searchPerson => 'Henkilö';
+
+ @override
+ String get searchSources => 'Lähteet';
+
+ @override
+ String get searchClearFilters => 'Nollaa suodattimet';
+
+ @override
+ String get searchFiltersAction => 'Suodattimet';
+
+ @override
+ String get searchNoSources => 'Hakulähteitä ei ole valittu';
+
+ @override
+ String get searchClearText => 'Tyhjennä teksti';
}
diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart
index 624906d5..b3ea7fc3 100644
--- a/lib/l10n/generated/app_localizations_fr.dart
+++ b/lib/l10n/generated/app_localizations_fr.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsFr extends AppLocalizations {
AppLocalizationsFr([String locale = 'fr']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax a été lancé par les paramètres de démarrage de votre bureau. Supprimez son entrée à cet endroit, puis quittez et rouvrez BusyMax pour utiliser cet interrupteur.';
+
@override
String get timeFormat => 'Format de l’heure';
+ @override
+ String get firstDayOfWeek => 'Premier jour de la semaine';
+
+ @override
+ String get systemDefault => 'Valeur par défaut du système';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'Valeur par défaut du système ($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12 heures';
@@ -2926,4 +2941,52 @@ class AppLocalizationsFr extends AppLocalizations {
});
return '$frequency $_temp0';
}
+
+ @override
+ String get searchFilters => 'Filtres de recherche';
+
+ @override
+ String get searchType => 'Type d’élément';
+
+ @override
+ String get searchDate => 'Date de recherche';
+
+ @override
+ String get searchAnyDate => 'Toutes les dates';
+
+ @override
+ String get searchThisWeek => 'Cette semaine';
+
+ @override
+ String get searchCustomRange => 'Période personnalisée';
+
+ @override
+ String get searchTaskStatus => 'État des tâches';
+
+ @override
+ String get searchTaskDue => 'Échéance des tâches';
+
+ @override
+ String get searchAnyDueState => 'Toute échéance';
+
+ @override
+ String get searchNoDueDate => 'Sans échéance';
+
+ @override
+ String get searchPerson => 'Personne';
+
+ @override
+ String get searchSources => 'Sources de recherche';
+
+ @override
+ String get searchClearFilters => 'Réinitialiser les filtres';
+
+ @override
+ String get searchFiltersAction => 'Filtres';
+
+ @override
+ String get searchNoSources => 'Aucune source de recherche sélectionnée';
+
+ @override
+ String get searchClearText => 'Effacer le texte';
}
diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart
index 90187393..194ad5ab 100644
--- a/lib/l10n/generated/app_localizations_hi.dart
+++ b/lib/l10n/generated/app_localizations_hi.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsHi extends AppLocalizations {
AppLocalizationsHi([String locale = 'hi']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax आपके डेस्कटॉप की स्टार्टअप सेटिंग से शुरू हुआ था। वहाँ से इसकी प्रविष्टि हटाएँ, फिर इस स्विच का उपयोग करने के लिए BusyMax से बाहर निकलें और इसे दोबारा खोलें।';
+
@override
String get timeFormat => 'समय प्रारूप';
+ @override
+ String get firstDayOfWeek => 'सप्ताह का पहला दिन';
+
+ @override
+ String get systemDefault => 'सिस्टम डिफ़ॉल्ट';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'सिस्टम डिफ़ॉल्ट ($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12 घंटे';
@@ -2921,4 +2936,52 @@ class AppLocalizationsHi extends AppLocalizations {
});
return '$frequency $months के $_temp0 $days को';
}
+
+ @override
+ String get searchFilters => 'खोज फ़िल्टर';
+
+ @override
+ String get searchType => 'प्रकार';
+
+ @override
+ String get searchDate => 'तारीख';
+
+ @override
+ String get searchAnyDate => 'कोई भी तारीख';
+
+ @override
+ String get searchThisWeek => 'इस सप्ताह';
+
+ @override
+ String get searchCustomRange => 'कस्टम अवधि';
+
+ @override
+ String get searchTaskStatus => 'कार्य स्थिति';
+
+ @override
+ String get searchTaskDue => 'कार्य की नियत तारीख';
+
+ @override
+ String get searchAnyDueState => 'कोई भी नियत स्थिति';
+
+ @override
+ String get searchNoDueDate => 'बिना नियत तारीख';
+
+ @override
+ String get searchPerson => 'व्यक्ति';
+
+ @override
+ String get searchSources => 'स्रोत';
+
+ @override
+ String get searchClearFilters => 'फ़िल्टर रीसेट करें';
+
+ @override
+ String get searchFiltersAction => 'फ़िल्टर';
+
+ @override
+ String get searchNoSources => 'कोई खोज स्रोत नहीं चुना गया';
+
+ @override
+ String get searchClearText => 'पाठ साफ़ करें';
}
diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart
index a118f630..d7d4996b 100644
--- a/lib/l10n/generated/app_localizations_it.dart
+++ b/lib/l10n/generated/app_localizations_it.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsIt extends AppLocalizations {
AppLocalizationsIt([String locale = 'it']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax è stato avviato dalle impostazioni di avvio del desktop. Rimuovi la sua voce da lì, poi esci da BusyMax e riaprilo per usare questo interruttore.';
+
@override
String get timeFormat => 'Formato ora';
+ @override
+ String get firstDayOfWeek => 'Primo giorno della settimana';
+
+ @override
+ String get systemDefault => 'Predefinito di sistema';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'Predefinito di sistema ($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12 ore';
@@ -2942,4 +2957,52 @@ class AppLocalizationsIt extends AppLocalizations {
});
return '$frequency $_temp0';
}
+
+ @override
+ String get searchFilters => 'Filtri di ricerca';
+
+ @override
+ String get searchType => 'Tipo';
+
+ @override
+ String get searchDate => 'Data';
+
+ @override
+ String get searchAnyDate => 'Qualsiasi data';
+
+ @override
+ String get searchThisWeek => 'Questa settimana';
+
+ @override
+ String get searchCustomRange => 'Intervallo personalizzato';
+
+ @override
+ String get searchTaskStatus => 'Stato attività';
+
+ @override
+ String get searchTaskDue => 'Scadenza attività';
+
+ @override
+ String get searchAnyDueState => 'Qualsiasi scadenza';
+
+ @override
+ String get searchNoDueDate => 'Senza scadenza';
+
+ @override
+ String get searchPerson => 'Persona';
+
+ @override
+ String get searchSources => 'Origini';
+
+ @override
+ String get searchClearFilters => 'Reimposta filtri';
+
+ @override
+ String get searchFiltersAction => 'Filtri';
+
+ @override
+ String get searchNoSources => 'Nessuna origine di ricerca selezionata';
+
+ @override
+ String get searchClearText => 'Cancella testo';
}
diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart
index 1a3121b2..89af0ce3 100644
--- a/lib/l10n/generated/app_localizations_ja.dart
+++ b/lib/l10n/generated/app_localizations_ja.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsJa extends AppLocalizations {
AppLocalizationsJa([String locale = 'ja']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax はデスクトップの自動起動設定によって起動されました。このスイッチを使うには、そこで BusyMax の項目を削除してから、BusyMax を終了して開き直してください。';
+
@override
String get timeFormat => '時刻の表示形式';
+ @override
+ String get firstDayOfWeek => '週の最初の曜日';
+
+ @override
+ String get systemDefault => 'システムの既定値';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'システムの既定値($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12時間表示';
@@ -2858,4 +2873,52 @@ class AppLocalizationsJa extends AppLocalizations {
});
return '$frequency$monthsの$_temp0';
}
+
+ @override
+ String get searchFilters => '検索フィルター';
+
+ @override
+ String get searchType => '種類';
+
+ @override
+ String get searchDate => '日付';
+
+ @override
+ String get searchAnyDate => 'すべての日付';
+
+ @override
+ String get searchThisWeek => '今週';
+
+ @override
+ String get searchCustomRange => '期間を指定';
+
+ @override
+ String get searchTaskStatus => 'タスクの状態';
+
+ @override
+ String get searchTaskDue => 'タスクの期限';
+
+ @override
+ String get searchAnyDueState => 'すべての期限状態';
+
+ @override
+ String get searchNoDueDate => '期限なし';
+
+ @override
+ String get searchPerson => '人物';
+
+ @override
+ String get searchSources => '検索元';
+
+ @override
+ String get searchClearFilters => 'フィルターをリセット';
+
+ @override
+ String get searchFiltersAction => 'フィルター';
+
+ @override
+ String get searchNoSources => '検索元が選択されていません';
+
+ @override
+ String get searchClearText => 'テキストを消去';
}
diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart
index 42871a56..93698eed 100644
--- a/lib/l10n/generated/app_localizations_ko.dart
+++ b/lib/l10n/generated/app_localizations_ko.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsKo extends AppLocalizations {
AppLocalizationsKo([String locale = 'ko']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ '데스크톱의 시작 프로그램 설정에 의해 BusyMax가 실행되었습니다. 이 스위치를 사용하려면 해당 설정에서 BusyMax 항목을 제거한 다음 BusyMax를 완전히 종료하고 다시 여세요.';
+
@override
String get timeFormat => '시간 형식';
+ @override
+ String get firstDayOfWeek => '한 주의 시작 요일';
+
+ @override
+ String get systemDefault => '시스템 기본값';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return '시스템 기본값($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12시간';
@@ -2862,4 +2877,52 @@ class AppLocalizationsKo extends AppLocalizations {
});
return '$frequency $months의 $_temp0';
}
+
+ @override
+ String get searchFilters => '검색 필터';
+
+ @override
+ String get searchType => '유형';
+
+ @override
+ String get searchDate => '날짜';
+
+ @override
+ String get searchAnyDate => '모든 날짜';
+
+ @override
+ String get searchThisWeek => '이번 주';
+
+ @override
+ String get searchCustomRange => '사용자 지정 기간';
+
+ @override
+ String get searchTaskStatus => '작업 상태';
+
+ @override
+ String get searchTaskDue => '작업 기한';
+
+ @override
+ String get searchAnyDueState => '모든 기한 상태';
+
+ @override
+ String get searchNoDueDate => '기한 없음';
+
+ @override
+ String get searchPerson => '사람';
+
+ @override
+ String get searchSources => '검색 소스';
+
+ @override
+ String get searchClearFilters => '필터 초기화';
+
+ @override
+ String get searchFiltersAction => '필터';
+
+ @override
+ String get searchNoSources => '선택한 검색 소스가 없습니다';
+
+ @override
+ String get searchClearText => '텍스트 지우기';
}
diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart
index a4b4f087..6aad0970 100644
--- a/lib/l10n/generated/app_localizations_pl.dart
+++ b/lib/l10n/generated/app_localizations_pl.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsPl extends AppLocalizations {
AppLocalizationsPl([String locale = 'pl']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax został uruchomiony przez ustawienia autostartu pulpitu. Usuń stamtąd jego wpis, a następnie zakończ i ponownie uruchom BusyMax, aby użyć tego przełącznika.';
+
@override
String get timeFormat => 'Format czasu';
+ @override
+ String get firstDayOfWeek => 'Pierwszy dzień tygodnia';
+
+ @override
+ String get systemDefault => 'Domyślne ustawienie systemu';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'Domyślne ustawienie systemu ($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12-godzinny';
@@ -3095,4 +3110,52 @@ class AppLocalizationsPl extends AppLocalizations {
});
return '$frequency, $_temp0';
}
+
+ @override
+ String get searchFilters => 'Filtry wyszukiwania';
+
+ @override
+ String get searchType => 'Typ';
+
+ @override
+ String get searchDate => 'Data';
+
+ @override
+ String get searchAnyDate => 'Dowolna data';
+
+ @override
+ String get searchThisWeek => 'Ten tydzień';
+
+ @override
+ String get searchCustomRange => 'Zakres niestandardowy';
+
+ @override
+ String get searchTaskStatus => 'Stan zadania';
+
+ @override
+ String get searchTaskDue => 'Termin zadania';
+
+ @override
+ String get searchAnyDueState => 'Dowolny stan terminu';
+
+ @override
+ String get searchNoDueDate => 'Bez terminu';
+
+ @override
+ String get searchPerson => 'Osoba';
+
+ @override
+ String get searchSources => 'Źródła';
+
+ @override
+ String get searchClearFilters => 'Resetuj filtry';
+
+ @override
+ String get searchFiltersAction => 'Filtry';
+
+ @override
+ String get searchNoSources => 'Nie wybrano źródeł wyszukiwania';
+
+ @override
+ String get searchClearText => 'Wyczyść tekst';
}
diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart
index 1d3f5cab..437533b5 100644
--- a/lib/l10n/generated/app_localizations_pt.dart
+++ b/lib/l10n/generated/app_localizations_pt.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsPt extends AppLocalizations {
AppLocalizationsPt([String locale = 'pt']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax was launched by your desktop\'s startup settings. Remove its entry there, then quit and reopen BusyMax to use this switch.';
+
@override
String get timeFormat => 'Time format';
+ @override
+ String get firstDayOfWeek => 'First day of the week';
+
+ @override
+ String get systemDefault => 'System Default';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'System Default ($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12-hour';
@@ -2953,15 +2968,78 @@ class AppLocalizationsPt extends AppLocalizations {
});
return '$frequency $_temp0';
}
+
+ @override
+ String get searchFilters => 'Search filters';
+
+ @override
+ String get searchType => 'Type';
+
+ @override
+ String get searchDate => 'Date';
+
+ @override
+ String get searchAnyDate => 'Any date';
+
+ @override
+ String get searchThisWeek => 'This week';
+
+ @override
+ String get searchCustomRange => 'Custom range';
+
+ @override
+ String get searchTaskStatus => 'Task status';
+
+ @override
+ String get searchTaskDue => 'Task due';
+
+ @override
+ String get searchAnyDueState => 'Any due state';
+
+ @override
+ String get searchNoDueDate => 'No due date';
+
+ @override
+ String get searchPerson => 'Person';
+
+ @override
+ String get searchSources => 'Sources';
+
+ @override
+ String get searchClearFilters => 'Clear filters';
+
+ @override
+ String get searchFiltersAction => 'Filters';
+
+ @override
+ String get searchNoSources => 'No search sources selected';
+
+ @override
+ String get searchClearText => 'Clear text';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
class AppLocalizationsPtPt extends AppLocalizationsPt {
AppLocalizationsPtPt() : super('pt_PT');
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'O BusyMax foi iniciado pelas definições de arranque do ambiente de trabalho. Remova a respetiva entrada, depois saia do BusyMax e volte a abri-lo para utilizar este interruptor.';
+
@override
String get timeFormat => 'Formato da hora';
+ @override
+ String get firstDayOfWeek => 'Primeiro dia da semana';
+
+ @override
+ String get systemDefault => 'Predefinição do sistema';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'Predefinição do sistema ($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12 horas';
@@ -5888,4 +5966,52 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
});
return '$frequency $_temp0';
}
+
+ @override
+ String get searchFilters => 'Filtros de pesquisa';
+
+ @override
+ String get searchType => 'Tipo';
+
+ @override
+ String get searchDate => 'Data';
+
+ @override
+ String get searchAnyDate => 'Qualquer data';
+
+ @override
+ String get searchThisWeek => 'Esta semana';
+
+ @override
+ String get searchCustomRange => 'Intervalo personalizado';
+
+ @override
+ String get searchTaskStatus => 'Estado das tarefas';
+
+ @override
+ String get searchTaskDue => 'Prazo das tarefas';
+
+ @override
+ String get searchAnyDueState => 'Qualquer prazo';
+
+ @override
+ String get searchNoDueDate => 'Sem data de conclusão';
+
+ @override
+ String get searchPerson => 'Pessoa';
+
+ @override
+ String get searchSources => 'Origens';
+
+ @override
+ String get searchClearFilters => 'Repor filtros';
+
+ @override
+ String get searchFiltersAction => 'Filtros';
+
+ @override
+ String get searchNoSources => 'Nenhuma origem de pesquisa selecionada';
+
+ @override
+ String get searchClearText => 'Limpar texto';
}
diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart
index 8107a4ce..11ad24bc 100644
--- a/lib/l10n/generated/app_localizations_ru.dart
+++ b/lib/l10n/generated/app_localizations_ru.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsRu extends AppLocalizations {
AppLocalizationsRu([String locale = 'ru']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax запущен через настройки автозапуска рабочего стола. Удалите его запись там, затем полностью завершите и снова откройте BusyMax, чтобы использовать этот переключатель.';
+
@override
String get timeFormat => 'Формат времени';
+ @override
+ String get firstDayOfWeek => 'Первый день недели';
+
+ @override
+ String get systemDefault => 'Системное значение';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'Системное значение ($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12 часов';
@@ -2945,4 +2960,52 @@ class AppLocalizationsRu extends AppLocalizations {
});
return '$frequency: $months, $_temp0';
}
+
+ @override
+ String get searchFilters => 'Фильтры поиска';
+
+ @override
+ String get searchType => 'Тип';
+
+ @override
+ String get searchDate => 'Дата';
+
+ @override
+ String get searchAnyDate => 'Любая дата';
+
+ @override
+ String get searchThisWeek => 'Эта неделя';
+
+ @override
+ String get searchCustomRange => 'Свой диапазон';
+
+ @override
+ String get searchTaskStatus => 'Статус задач';
+
+ @override
+ String get searchTaskDue => 'Срок задач';
+
+ @override
+ String get searchAnyDueState => 'Любой срок';
+
+ @override
+ String get searchNoDueDate => 'Без срока';
+
+ @override
+ String get searchPerson => 'Участник';
+
+ @override
+ String get searchSources => 'Источники';
+
+ @override
+ String get searchClearFilters => 'Сбросить фильтры';
+
+ @override
+ String get searchFiltersAction => 'Фильтры';
+
+ @override
+ String get searchNoSources => 'Источники поиска не выбраны';
+
+ @override
+ String get searchClearText => 'Очистить текст';
}
diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart
index 10ca3c5e..2d8f1e7c 100644
--- a/lib/l10n/generated/app_localizations_vi.dart
+++ b/lib/l10n/generated/app_localizations_vi.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsVi extends AppLocalizations {
AppLocalizationsVi([String locale = 'vi']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax được mở bởi cài đặt khởi động của môi trường máy tính. Xóa mục BusyMax ở đó, rồi thoát hẳn và mở lại BusyMax để dùng công tắc này.';
+
@override
String get timeFormat => 'Định dạng giờ';
+ @override
+ String get firstDayOfWeek => 'Ngày đầu tuần';
+
+ @override
+ String get systemDefault => 'Mặc định hệ thống';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return 'Mặc định hệ thống ($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12 giờ';
@@ -2918,4 +2933,52 @@ class AppLocalizationsVi extends AppLocalizations {
});
return '$frequency vào $days $_temp0 của $months';
}
+
+ @override
+ String get searchFilters => 'Bộ lọc tìm kiếm';
+
+ @override
+ String get searchType => 'Loại';
+
+ @override
+ String get searchDate => 'Ngày';
+
+ @override
+ String get searchAnyDate => 'Mọi ngày';
+
+ @override
+ String get searchThisWeek => 'Tuần này';
+
+ @override
+ String get searchCustomRange => 'Khoảng tùy chọn';
+
+ @override
+ String get searchTaskStatus => 'Trạng thái công việc';
+
+ @override
+ String get searchTaskDue => 'Hạn công việc';
+
+ @override
+ String get searchAnyDueState => 'Mọi trạng thái hạn';
+
+ @override
+ String get searchNoDueDate => 'Không có hạn';
+
+ @override
+ String get searchPerson => 'Người';
+
+ @override
+ String get searchSources => 'Nguồn';
+
+ @override
+ String get searchClearFilters => 'Đặt lại bộ lọc';
+
+ @override
+ String get searchFiltersAction => 'Bộ lọc';
+
+ @override
+ String get searchNoSources => 'Chưa chọn nguồn tìm kiếm';
+
+ @override
+ String get searchClearText => 'Xóa văn bản';
}
diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart
index fd5047c2..f2f6e30f 100644
--- a/lib/l10n/generated/app_localizations_zh.dart
+++ b/lib/l10n/generated/app_localizations_zh.dart
@@ -12,9 +12,24 @@ import 'app_localizations.dart';
class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。';
+
@override
String get timeFormat => '时间格式';
+ @override
+ String get firstDayOfWeek => '一周的第一天';
+
+ @override
+ String get systemDefault => '系统默认';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return '系统默认($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12小时制';
@@ -2821,15 +2836,78 @@ class AppLocalizationsZh extends AppLocalizations {
});
return '$frequency$months的$_temp0';
}
+
+ @override
+ String get searchFilters => '搜索筛选条件';
+
+ @override
+ String get searchType => '类型';
+
+ @override
+ String get searchDate => '日期';
+
+ @override
+ String get searchAnyDate => '任何日期';
+
+ @override
+ String get searchThisWeek => '本周';
+
+ @override
+ String get searchCustomRange => '自定义范围';
+
+ @override
+ String get searchTaskStatus => '任务状态';
+
+ @override
+ String get searchTaskDue => '任务到期状态';
+
+ @override
+ String get searchAnyDueState => '任何到期状态';
+
+ @override
+ String get searchNoDueDate => '无截止日期';
+
+ @override
+ String get searchPerson => '人员';
+
+ @override
+ String get searchSources => '来源';
+
+ @override
+ String get searchClearFilters => '重置筛选条件';
+
+ @override
+ String get searchFiltersAction => '筛选';
+
+ @override
+ String get searchNoSources => '未选择搜索来源';
+
+ @override
+ String get searchClearText => '清除文字';
}
/// The translations for Chinese, using the Han script (`zh_Hans`).
class AppLocalizationsZhHans extends AppLocalizationsZh {
AppLocalizationsZhHans() : super('zh_Hans');
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。';
+
@override
String get timeFormat => '时间格式';
+ @override
+ String get firstDayOfWeek => '一周的第一天';
+
+ @override
+ String get systemDefault => '系统默认';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return '系统默认($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12小时制';
@@ -5636,15 +5714,78 @@ class AppLocalizationsZhHans extends AppLocalizationsZh {
});
return '$frequency$months的$_temp0';
}
+
+ @override
+ String get searchFilters => '搜索筛选条件';
+
+ @override
+ String get searchType => '类型';
+
+ @override
+ String get searchDate => '日期';
+
+ @override
+ String get searchAnyDate => '任何日期';
+
+ @override
+ String get searchThisWeek => '本周';
+
+ @override
+ String get searchCustomRange => '自定义范围';
+
+ @override
+ String get searchTaskStatus => '任务状态';
+
+ @override
+ String get searchTaskDue => '任务到期状态';
+
+ @override
+ String get searchAnyDueState => '任何到期状态';
+
+ @override
+ String get searchNoDueDate => '无截止日期';
+
+ @override
+ String get searchPerson => '人员';
+
+ @override
+ String get searchSources => '来源';
+
+ @override
+ String get searchClearFilters => '重置筛选条件';
+
+ @override
+ String get searchFiltersAction => '筛选';
+
+ @override
+ String get searchNoSources => '未选择搜索来源';
+
+ @override
+ String get searchClearText => '清除文字';
}
/// The translations for Chinese, using the Han script (`zh_Hant`).
class AppLocalizationsZhHant extends AppLocalizationsZh {
AppLocalizationsZhHant() : super('zh_Hant');
+ @override
+ String get launchAtLoginManagedExternally =>
+ 'BusyMax 由桌面的啟動設定啟動。請在那裡移除 BusyMax 的項目,然後完全結束並重新開啟 BusyMax,以使用此開關。';
+
@override
String get timeFormat => '時間格式';
+ @override
+ String get firstDayOfWeek => '一週的第一天';
+
+ @override
+ String get systemDefault => '系統預設';
+
+ @override
+ String systemDefaultResolved(String weekday) {
+ return '系統預設($weekday)';
+ }
+
@override
String get timeFormatTwelveHour => '12小時制';
@@ -8452,4 +8593,52 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
});
return '$frequency$months的$_temp0';
}
+
+ @override
+ String get searchFilters => '搜尋篩選條件';
+
+ @override
+ String get searchType => '類型';
+
+ @override
+ String get searchDate => '日期';
+
+ @override
+ String get searchAnyDate => '任何日期';
+
+ @override
+ String get searchThisWeek => '本週';
+
+ @override
+ String get searchCustomRange => '自訂範圍';
+
+ @override
+ String get searchTaskStatus => '工作狀態';
+
+ @override
+ String get searchTaskDue => '工作到期狀態';
+
+ @override
+ String get searchAnyDueState => '任何到期狀態';
+
+ @override
+ String get searchNoDueDate => '無截止日期';
+
+ @override
+ String get searchPerson => '人員';
+
+ @override
+ String get searchSources => '來源';
+
+ @override
+ String get searchClearFilters => '重設篩選條件';
+
+ @override
+ String get searchFiltersAction => '篩選';
+
+ @override
+ String get searchNoSources => '未選取搜尋來源';
+
+ @override
+ String get searchClearText => '清除文字';
}
diff --git a/lib/main_android.dart b/lib/main_android.dart
new file mode 100644
index 00000000..e2a8ad23
--- /dev/null
+++ b/lib/main_android.dart
@@ -0,0 +1,26 @@
+import 'dart:async';
+
+import 'package:flutter/widgets.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:logging/logging.dart';
+
+import 'src/android/android_app.dart';
+import 'src/android/android_background.dart';
+
+Future main(List arguments) async {
+ WidgetsFlutterBinding.ensureInitialized();
+ Logger.root.level = Level.INFO;
+ Logger.root.onRecord.listen((record) {
+ // Provider tokens and credentials are never included in these messages.
+ debugPrint('${record.level.name}: ${record.loggerName}: ${record.message}');
+ });
+ final runtime = await AndroidHeadlessRuntime.create();
+ await configureBusyMaxWorkmanager();
+ unawaited(runtime.notifications.reconcile());
+ runApp(
+ UncontrolledProviderScope(
+ container: runtime.container,
+ child: const AndroidBusyMaxApp(),
+ ),
+ );
+}
diff --git a/lib/src/android/android_account_gate.dart b/lib/src/android/android_account_gate.dart
new file mode 100644
index 00000000..0f91b824
--- /dev/null
+++ b/lib/src/android/android_account_gate.dart
@@ -0,0 +1,19 @@
+import 'package:busymax_android_platform/busymax_android_platform.dart';
+
+import '../features/sync/account_sync_operations.dart';
+
+final class AndroidCrossEngineAccountGate implements CrossEngineAccountGate {
+ const AndroidCrossEngineAccountGate(this._platform);
+
+ final BusyMaxAndroidPlatform _platform;
+
+ @override
+ Future run(String accountId, Future Function() operation) async {
+ final lease = await _platform.acquireAccountGate(accountId);
+ try {
+ return await operation();
+ } finally {
+ await _platform.releaseAccountGate(lease);
+ }
+ }
+}
diff --git a/lib/src/android/android_app.dart b/lib/src/android/android_app.dart
new file mode 100644
index 00000000..a858a28e
--- /dev/null
+++ b/lib/src/android/android_app.dart
@@ -0,0 +1,332 @@
+import 'dart:async';
+
+import 'package:busymax_android_platform/busymax_android_platform.dart';
+import 'package:drift/drift.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../l10n/generated/app_localizations.dart';
+import '../app/app_bootstrap.dart';
+import '../l10n/app_locale.dart';
+import '../l10n/locale_resolution.dart';
+import '../l10n/l10n.dart';
+import '../l10n/time_format_scope.dart';
+import '../l10n/week_preferences_scope.dart';
+import '../platform/android/android_first_weekday_source.dart';
+import 'android_notifications.dart';
+import 'presentation/android_schedule_screen.dart';
+import 'presentation/android_settings_screen.dart';
+import 'presentation/android_tasks_screen.dart';
+
+final androidSelectedDestinationProvider = StateProvider((ref) => 0);
+
+class AndroidBusyMaxApp extends ConsumerStatefulWidget {
+ const AndroidBusyMaxApp({super.key});
+
+ @override
+ ConsumerState createState() => _AndroidBusyMaxAppState();
+}
+
+class _AndroidBusyMaxAppState extends ConsumerState {
+ late final BusyMaxSystemFirstWeekdayController _firstWeekdayController;
+
+ @override
+ void initState() {
+ super.initState();
+ _firstWeekdayController = BusyMaxSystemFirstWeekdayController(
+ AndroidFirstWeekdaySource(),
+ )..addListener(_platformChanged);
+ }
+
+ void _platformChanged() {
+ if (mounted) setState(() {});
+ }
+
+ @override
+ void dispose() {
+ _firstWeekdayController
+ ..removeListener(_platformChanged)
+ ..dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final settings = ref.watch(appSettingsControllerProvider);
+ return MaterialApp(
+ restorationScopeId: 'busymax_android',
+ debugShowCheckedModeBanner: false,
+ onGenerateTitle: (_) => 'BusyMax',
+ locale: settings.locale,
+ supportedLocales: busyMaxSupportedLocales,
+ localizationsDelegates: const [
+ AppLocalizations.delegate,
+ GlobalMaterialLocalizations.delegate,
+ GlobalWidgetsLocalizations.delegate,
+ GlobalCupertinoLocalizations.delegate,
+ ],
+ localeListResolutionCallback: (requested, supported) =>
+ resolveBusyMaxLocales(requested, supported),
+ themeMode: settings.themeMode,
+ theme: _theme(Brightness.light),
+ darkTheme: _theme(Brightness.dark),
+ builder: (context, child) => BusyMaxTimeFormatScope(
+ formatter: BusyMaxTimeFormatter(
+ locale: Localizations.localeOf(context).toLanguageTag(),
+ use24Hour: resolveBusyMax24HourClock(
+ settings.timeFormatPreference,
+ systemUses24Hour: ref.watch(androidSystemUses24HourProvider),
+ ),
+ ),
+ child: BusyMaxWeekPreferencesScope(
+ preference: settings.firstDayOfWeekPreference,
+ systemWeekday: _firstWeekdayController.value,
+ platformLocaleTag: WidgetsBinding.instance.platformDispatcher.locale
+ .toLanguageTag(),
+ child: BusyMaxWeekPreferencesStartupGate(
+ preference: settings.firstDayOfWeekPreference,
+ systemValueInitialized: _firstWeekdayController.isInitialized,
+ child: child ?? const SizedBox.shrink(),
+ ),
+ ),
+ ),
+ home: const AndroidHomeShell(),
+ );
+ }
+
+ ThemeData _theme(Brightness brightness) {
+ final scheme = ColorScheme.fromSeed(
+ seedColor: const Color(0xff6d28d9),
+ brightness: brightness,
+ );
+ return ThemeData(
+ useMaterial3: true,
+ colorScheme: scheme,
+ visualDensity: VisualDensity.standard,
+ scaffoldBackgroundColor: scheme.surface,
+ inputDecorationTheme: const InputDecorationTheme(
+ border: OutlineInputBorder(),
+ ),
+ cardTheme: const CardThemeData(margin: EdgeInsets.zero),
+ );
+ }
+}
+
+class AndroidHomeShell extends ConsumerStatefulWidget {
+ const AndroidHomeShell({super.key});
+
+ @override
+ ConsumerState createState() => _AndroidHomeShellState();
+}
+
+class _AndroidHomeShellState extends ConsumerState
+ with WidgetsBindingObserver {
+ StreamSubscription? _platformEvents;
+ StreamSubscription? _reminderActivations;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ _platformEvents = BusyMaxAndroidPlatform.instance.events.listen(
+ (event) => unawaited(_handlePlatformEvent(event)),
+ );
+ _reminderActivations = ref
+ .read(androidNotificationServiceProvider)
+ .activations
+ .listen((activation) => unawaited(_openReminder(activation)));
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ unawaited(_consumeInitialActivations());
+ });
+ unawaited(_refresh());
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ unawaited(_platformEvents?.cancel());
+ unawaited(_reminderActivations?.cancel());
+ super.dispose();
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ if (state == AppLifecycleState.resumed) unawaited(_refresh());
+ }
+
+ Future _refresh() async {
+ try {
+ await ref.read(allAccountsSyncRunnerProvider)();
+ } on Object {
+ // SyncRuns and account state expose the individual failure.
+ }
+ await ref.read(notificationReconcilerProvider).reconcile();
+ }
+
+ Future _consumeInitialActivations() async {
+ final document = await BusyMaxAndroidPlatform.instance
+ .takeInitialActivation();
+ if (document != null) await _openDocumentActivation(document);
+ if (!mounted) return;
+ final reminder = ref
+ .read(androidNotificationServiceProvider)
+ .takeInitialActivation();
+ if (reminder != null) await _openReminder(reminder);
+ }
+
+ Future _handlePlatformEvent(AndroidPlatformEvent event) async {
+ if (event.type == 'systemSettingsChanged') {
+ final zone = await BusyMaxAndroidPlatform.instance.currentTimeZoneId();
+ final uses24Hour = await BusyMaxAndroidPlatform.instance
+ .uses24HourFormat();
+ ref.read(androidLocalTimeZoneStateProvider.notifier).state = zone;
+ ref.read(androidSystemUses24HourProvider.notifier).state = uses24Hour;
+ await ref
+ .read(androidNotificationServiceProvider)
+ .initialize(timeZoneId: zone);
+ await ref.read(notificationReconcilerProvider).reconcile();
+ return;
+ }
+ if (event.type == 'dataChanged') {
+ ref.invalidate(accountsStreamProvider);
+ ref.invalidate(accountManagementStreamProvider);
+ ref.invalidate(calendarSourcesStreamProvider);
+ ref.invalidate(scheduleTaskListsProvider);
+ ref.invalidate(scheduleDataRevisionProvider);
+ ref.invalidate(davConflictsStreamProvider);
+ ref.invalidate(webCalSubscriptionsProvider);
+ return;
+ }
+ if (event.type == 'document') {
+ await _openDocumentActivation(AndroidActivation.fromMap(event.data));
+ }
+ }
+
+ Future _openDocumentActivation(AndroidActivation activation) async {
+ final uri = activation.uri;
+ if (!mounted || activation.kind != 'document' || uri == null) return;
+ try {
+ final document = await BusyMaxAndroidPlatform.instance.readDocumentUri(
+ uri,
+ );
+ if (!mounted) return;
+ await showAndroidIcsImport(context, ref, document: document);
+ } on Object catch (error) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(context.l10n.importIcsFailed('$error'))),
+ );
+ }
+ }
+ }
+
+ Future _openReminder(AndroidReminderActivation activation) async {
+ if (activation.sourceType == 'summary') {
+ ref.read(androidSelectedDestinationProvider.notifier).state = 1;
+ return;
+ }
+ final database = ref.read(databaseProvider);
+ final row =
+ await (database.select(database.notificationSchedule)..where(
+ (schedule) =>
+ schedule.id.equals(activation.scheduleId) &
+ schedule.generation.equals(activation.generation) &
+ schedule.accountId.equals(activation.accountId) &
+ schedule.sourceId.equals(activation.itemId),
+ ))
+ .getSingleOrNull();
+ if (!mounted) return;
+ if (row == null) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(context.l10n.noEventsOrTasks)));
+ return;
+ }
+ ref.read(androidSelectedDestinationProvider.notifier).state = 0;
+ if (activation.sourceType == 'event') {
+ await showAndroidEventEditor(context, ref, eventId: activation.itemId);
+ } else if (activation.sourceType == 'task') {
+ await showAndroidTaskById(
+ context,
+ ref,
+ accountId: activation.accountId,
+ taskId: activation.itemId,
+ );
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final selected = ref.watch(androidSelectedDestinationProvider);
+ final destinations = [
+ NavigationDestination(
+ icon: const Icon(Icons.calendar_today_outlined),
+ selectedIcon: const Icon(Icons.calendar_today),
+ label: context.l10n.scheduleSection,
+ ),
+ NavigationDestination(
+ icon: const Icon(Icons.checklist_outlined),
+ selectedIcon: const Icon(Icons.checklist),
+ label: context.l10n.tasks,
+ ),
+ NavigationDestination(
+ icon: const Icon(Icons.settings_outlined),
+ selectedIcon: const Icon(Icons.settings),
+ label: context.l10n.settings,
+ ),
+ ];
+ final pages = const [
+ AndroidScheduleScreen(),
+ AndroidTasksScreen(),
+ AndroidSettingsScreen(),
+ ];
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final useRail = constraints.maxWidth >= 840;
+ final content = IndexedStack(index: selected, children: pages);
+ if (useRail) {
+ return Scaffold(
+ body: SafeArea(
+ child: Row(
+ children: [
+ NavigationRail(
+ selectedIndex: selected,
+ extended: constraints.maxWidth >= 1200,
+ onDestinationSelected: (value) =>
+ ref
+ .read(
+ androidSelectedDestinationProvider.notifier,
+ )
+ .state =
+ value,
+ destinations: [
+ for (final destination in destinations)
+ NavigationRailDestination(
+ icon: destination.icon,
+ selectedIcon: destination.selectedIcon,
+ label: Text(destination.label),
+ ),
+ ],
+ ),
+ const VerticalDivider(width: 1),
+ Expanded(child: content),
+ ],
+ ),
+ ),
+ );
+ }
+ return Scaffold(
+ body: SafeArea(child: content),
+ bottomNavigationBar: NavigationBar(
+ selectedIndex: selected,
+ onDestinationSelected: (value) =>
+ ref.read(androidSelectedDestinationProvider.notifier).state =
+ value,
+ destinations: destinations,
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/src/android/android_authorization.dart b/lib/src/android/android_authorization.dart
new file mode 100644
index 00000000..b8052756
--- /dev/null
+++ b/lib/src/android/android_authorization.dart
@@ -0,0 +1,348 @@
+import 'dart:convert';
+
+import 'package:busymax_android_platform/busymax_android_platform.dart';
+import 'package:flutter/services.dart';
+import 'package:http/http.dart' as http;
+
+import '../config/build_config.dart';
+import '../core/auth/account_token_broker.dart';
+import '../core/auth/oauth_models.dart';
+import '../core/secrets/secret_store.dart';
+import '../google_tasks/api/google_tasks_api_surface.dart';
+import '../google_tasks/oauth/oauth_service.dart';
+import '../microsoft_todo/api/microsoft_todo_api_models.dart';
+import '../microsoft_todo/oauth/microsoft_oauth_service.dart';
+import '../providers/busy_provider.dart';
+
+const _googleScopes = [
+ 'openid',
+ 'email',
+ 'profile',
+ googleTasksReadWriteScope,
+ googleCalendarReadWriteScope,
+];
+
+const _microsoftNativeScopes = [
+ 'User.Read',
+ 'Tasks.ReadWrite',
+ 'Calendars.ReadWrite',
+];
+
+const _microsoftStoredScopes = {
+ 'https://graph.microsoft.com/User.Read',
+ 'https://graph.microsoft.com/Tasks.ReadWrite',
+ 'https://graph.microsoft.com/Calendars.ReadWrite',
+};
+
+final class AndroidAuthorizationBroker
+ implements OAuthGateway, MicrosoftOAuthGateway, AccountTokenBroker {
+ AndroidAuthorizationBroker({
+ required BusyMaxAndroidPlatform platform,
+ required http.Client httpClient,
+ required SecretStore secretStore,
+ required BuildConfig config,
+ }) : _platform = platform,
+ _httpClient = httpClient,
+ _secretStore = secretStore,
+ _config = config;
+
+ final BusyMaxAndroidPlatform _platform;
+ final http.Client _httpClient;
+ final SecretStore _secretStore;
+ final BuildConfig _config;
+ final Map _lastAccessTokens = {};
+
+ @override
+ Future get activeAccountId => _secretStore.readActiveAccountId();
+
+ @override
+ Future readActiveTokenSet() async {
+ final accountId = await activeAccountId;
+ if (accountId == null || !accountId.startsWith('google:')) return null;
+ return _googleToken(accountId);
+ }
+
+ @override
+ Future signIn({String? loginHint}) async {
+ try {
+ final native = await _platform.authorizeGoogleInteractively(
+ scopes: _googleScopes,
+ );
+ final tokenSet = _tokenSet(native);
+ final user = await fetchUserInfo(tokenSet);
+ final subject = user?.subject?.trim();
+ if (subject == null || subject.isEmpty) {
+ await _platform.clearRejectedToken(native.accessToken);
+ throw const OAuthException(
+ 'OAuthMissingSubject',
+ 'Google did not return an account identity.',
+ );
+ }
+ final accountId = 'google:$subject';
+ await _platform.bindAuthorization(
+ provider: BusyProvider.google.storageValue,
+ accountId: accountId,
+ nativeAccountId: native.nativeAccountId,
+ username: native.username ?? user?.email,
+ );
+ await _secretStore.setActiveAccountId(accountId);
+ _lastAccessTokens[accountId] = native.accessToken;
+ return OAuthSignInResult(accountId: accountId, tokenSet: tokenSet);
+ } on PlatformException catch (error) {
+ throw _oauthError(error, provider: 'Google');
+ }
+ }
+
+ @override
+ Future signInWithMicrosoft() async {
+ try {
+ final native = await _platform.authorizeMicrosoftInteractively(
+ scopes: _microsoftNativeScopes,
+ );
+ final tokenSet = _tokenSet(native);
+ final user = await _microsoftMe(native.accessToken);
+ if (user.id.trim().isEmpty) {
+ throw const OAuthException(
+ 'MicrosoftOAuthMissingUserId',
+ 'Microsoft Graph did not return a user id.',
+ );
+ }
+ final accountId = 'microsoft:${user.id}';
+ await _platform.bindAuthorization(
+ provider: BusyProvider.microsoft.storageValue,
+ accountId: accountId,
+ nativeAccountId: native.nativeAccountId,
+ username: native.username ?? user.mail ?? user.userPrincipalName,
+ authority: native.authority,
+ );
+ await _secretStore.setActiveAccountId(accountId);
+ _lastAccessTokens[accountId] = native.accessToken;
+ return MicrosoftOAuthSignInResult(
+ accountId: accountId,
+ tokenSet: tokenSet,
+ user: user,
+ );
+ } on PlatformException catch (error) {
+ throw _oauthError(error, provider: 'Microsoft');
+ }
+ }
+
+ @override
+ Future fetchUserInfo(OAuthTokenSet tokenSet) async {
+ final response = await _httpClient.get(
+ Uri.https('openidconnect.googleapis.com', '/v1/userinfo'),
+ headers: {'Authorization': 'Bearer ${tokenSet.accessToken}'},
+ );
+ if (response.statusCode < 200 || response.statusCode >= 300) return null;
+ final decoded = jsonDecode(response.body);
+ return decoded is Map
+ ? GoogleUserInfo.fromJson(decoded.cast())
+ : null;
+ }
+
+ Future _microsoftMe(String accessToken) async {
+ final response = await _httpClient.get(
+ Uri.parse('${_config.microsoftGraphBaseUrl}/me'),
+ headers: {'Authorization': 'Bearer $accessToken'},
+ );
+ if (response.statusCode < 200 || response.statusCode >= 300) {
+ throw OAuthException(
+ 'MicrosoftOAuthUserInfoFailed',
+ 'Microsoft account details could not be loaded (HTTP ${response.statusCode}).',
+ );
+ }
+ final decoded = jsonDecode(response.body);
+ if (decoded is! Map) {
+ throw const OAuthException(
+ 'MicrosoftOAuthUserInfoFailed',
+ 'Microsoft returned an invalid account response.',
+ );
+ }
+ return MicrosoftTodoUserDto.fromJson(decoded.cast());
+ }
+
+ @override
+ Future authorizationHeader(
+ BusyProvider provider,
+ String accountId,
+ ) async {
+ try {
+ final token = switch (provider) {
+ BusyProvider.google => await _platform.authorizeGoogleSilently(
+ accountId: accountId,
+ scopes: _googleScopes,
+ ),
+ BusyProvider.microsoft => await _platform.authorizeMicrosoftSilently(
+ accountId: accountId,
+ scopes: _microsoftNativeScopes,
+ ),
+ _ => throw StateError('$provider does not use native authorization.'),
+ };
+ final tokenSet = _tokenSet(token);
+ _requireSilentScopes(provider, tokenSet);
+ _lastAccessTokens[accountId] = token.accessToken;
+ return 'Bearer ${token.accessToken}';
+ } on PlatformException catch (error) {
+ throw _silentOAuthError(error, provider: provider);
+ }
+ }
+
+ @override
+ Future recoverUnauthorized(
+ BusyProvider provider,
+ String accountId,
+ ) async {
+ final rejected = _lastAccessTokens.remove(accountId);
+ if (provider == BusyProvider.google && rejected != null) {
+ await _platform.clearRejectedToken(rejected);
+ }
+ await authorizationHeader(provider, accountId);
+ }
+
+ Future _googleToken(String accountId) async {
+ try {
+ final native = await _platform.authorizeGoogleSilently(
+ accountId: accountId,
+ scopes: _googleScopes,
+ );
+ final tokenSet = _tokenSet(native);
+ _requireSilentScopes(BusyProvider.google, tokenSet);
+ _lastAccessTokens[accountId] = native.accessToken;
+ return tokenSet;
+ } on PlatformException catch (error) {
+ throw _silentOAuthError(error, provider: BusyProvider.google);
+ }
+ }
+
+ @override
+ Future refreshActiveToken() async {
+ final accountId = await activeAccountId;
+ if (accountId == null) {
+ throw const OAuthException(
+ 'OAuthMissingToken',
+ 'No Google account is active.',
+ );
+ }
+ await recoverUnauthorized(BusyProvider.google, accountId);
+ return _googleToken(accountId);
+ }
+
+ @override
+ Future revokeAndSignOutAccount(String accountId) async {
+ await _platform.removeAuthorization(
+ provider: BusyProvider.google.storageValue,
+ accountId: accountId,
+ revoke: true,
+ );
+ await clearLocalSession(accountId: accountId);
+ }
+
+ @override
+ Future revokeAuthorization(String accountId) =>
+ _platform.removeAuthorization(
+ provider: BusyProvider.google.storageValue,
+ accountId: accountId,
+ revoke: true,
+ );
+
+ @override
+ Future clearLocalSession({String? accountId}) async {
+ final target = accountId ?? await activeAccountId;
+ if (target != null) {
+ _lastAccessTokens.remove(target);
+ if (target.startsWith('google:')) {
+ await _platform.removeAuthorization(
+ provider: BusyProvider.google.storageValue,
+ accountId: target,
+ );
+ }
+ }
+ if (target == null || await activeAccountId == target) {
+ await _secretStore.clearActiveAccount();
+ }
+ }
+
+ @override
+ Future signOutAccount(String accountId) async {
+ await _platform.removeAuthorization(
+ provider: BusyProvider.microsoft.storageValue,
+ accountId: accountId,
+ );
+ await clearLocalSession(accountId: accountId);
+ }
+
+ @override
+ Future cancelSignIn() => _platform.cancelInteractiveAuthorization();
+
+ OAuthTokenSet _tokenSet(AndroidAuthorizationToken token) => OAuthTokenSet(
+ accessToken: token.accessToken,
+ expiresAtUtc:
+ token.expiresAtUtc ??
+ DateTime.now().toUtc().add(const Duration(minutes: 45)),
+ tokenType: 'Bearer',
+ // Persist only scopes reported by the authorization provider. Adding the
+ // requested scopes here would turn a denied permission into a fake grant.
+ scopes: {for (final scope in token.scopes) _storedScope(scope)},
+ );
+
+ void _requireSilentScopes(BusyProvider provider, OAuthTokenSet tokenSet) {
+ final granted = tokenSet.scopes;
+ final complete = switch (provider) {
+ BusyProvider.google =>
+ granted.contains(googleTasksReadWriteScope) &&
+ granted.contains(googleCalendarReadWriteScope),
+ BusyProvider.microsoft => _microsoftStoredScopes.every(granted.contains),
+ _ => true,
+ };
+ if (!complete) {
+ throw OAuthException(
+ provider == BusyProvider.microsoft
+ ? 'MicrosoftOAuthMissingToken'
+ : 'OAuthMissingToken',
+ '${provider.displayName} permissions must be reconnected.',
+ );
+ }
+ }
+}
+
+String _storedScope(String value) {
+ final scope = value.trim();
+ for (final nativeScope in _microsoftNativeScopes) {
+ if (scope.toLowerCase() == nativeScope.toLowerCase()) {
+ return 'https://graph.microsoft.com/$nativeScope';
+ }
+ }
+ return scope;
+}
+
+OAuthException _silentOAuthError(
+ PlatformException error, {
+ required BusyProvider provider,
+}) {
+ final reconnect = error.code == 'android/auth-interaction-required';
+ if (reconnect) {
+ return OAuthException(
+ provider == BusyProvider.microsoft
+ ? 'MicrosoftOAuthMissingToken'
+ : 'OAuthMissingToken',
+ '${provider.displayName} authorization must be reconnected.',
+ );
+ }
+ return OAuthException(
+ error.code,
+ error.message ?? '${provider.displayName} authorization failed.',
+ );
+}
+
+OAuthException _oauthError(
+ PlatformException error, {
+ required String provider,
+}) {
+ final cancelled = error.code == 'android/auth-cancelled';
+ return OAuthException(
+ cancelled ? 'OAuthSignInCancelled' : error.code,
+ cancelled
+ ? '$provider sign-in was cancelled.'
+ : (error.message ?? '$provider authorization failed.'),
+ );
+}
diff --git a/lib/src/android/android_background.dart b/lib/src/android/android_background.dart
new file mode 100644
index 00000000..680d7a8e
--- /dev/null
+++ b/lib/src/android/android_background.dart
@@ -0,0 +1,214 @@
+import 'dart:async';
+import 'dart:ui';
+
+import 'package:busymax_android_platform/busymax_android_platform.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+import 'package:http/http.dart' as http;
+import 'package:workmanager/workmanager.dart';
+
+import '../app/app_bootstrap.dart';
+import '../config/build_config.dart';
+import '../core/secrets/secret_store.dart';
+import '../db/app_database.dart';
+import '../l10n/app_locale.dart';
+import '../../l10n/generated/app_localizations.dart';
+import 'android_account_gate.dart';
+import 'android_authorization.dart';
+import 'android_notifications.dart';
+import 'android_time_zone.dart';
+
+const busyMaxPeriodicSyncUniqueName = 'busymax.periodic-sync.v1';
+const busyMaxPeriodicSyncTaskName = 'busymax.sync-and-reconcile';
+const busyMaxImmediateSyncUniqueName = 'busymax.immediate-sync.v1';
+const busyMaxNotificationRefillUniqueName = 'busymax.notification-refill.v1';
+const busyMaxNotificationRefillTaskName = 'busymax.reconcile-notifications';
+
+@pragma('vm:entry-point')
+void busyMaxWorkmanagerDispatcher() {
+ Workmanager().executeTask(
+ (taskName, inputData) async {
+ if (taskName != busyMaxPeriodicSyncTaskName &&
+ taskName != busyMaxNotificationRefillTaskName) {
+ return true;
+ }
+ WidgetsFlutterBinding.ensureInitialized();
+ DartPluginRegistrant.ensureInitialized();
+ final runtime = await AndroidHeadlessRuntime.create();
+ var succeeded = true;
+ try {
+ if (taskName == busyMaxPeriodicSyncTaskName) {
+ try {
+ await runtime.container.read(allAccountsSyncRunnerProvider)();
+ } on Object {
+ // Provider-specific failures are persisted by the shared engines.
+ succeeded = false;
+ }
+ }
+ // Local reminders must still refill while offline and after a failed
+ // provider synchronization.
+ try {
+ await runtime.notifications.reconcile();
+ await BusyMaxAndroidPlatform.instance.notifyDataChanged();
+ } on Object {
+ succeeded = false;
+ }
+ return succeeded;
+ } finally {
+ await runtime.dispose();
+ }
+ },
+ onTaskStopped: (taskName, stopReason) async {
+ // Dart finally blocks are not guaranteed when Android destroys the
+ // worker engine. Release engine-owned native semaphores explicitly.
+ await BusyMaxAndroidPlatform.instance.releaseOwnedAccountGates();
+ },
+ );
+}
+
+Future configureBusyMaxWorkmanager() async {
+ final workmanager = Workmanager();
+ await workmanager.initialize(busyMaxWorkmanagerDispatcher);
+ await workmanager.registerPeriodicTask(
+ busyMaxPeriodicSyncUniqueName,
+ busyMaxPeriodicSyncTaskName,
+ frequency: const Duration(minutes: 15),
+ flexInterval: const Duration(minutes: 5),
+ constraints: Constraints(
+ networkType: NetworkType.connected,
+ requiresBatteryNotLow: true,
+ requiresStorageNotLow: true,
+ ),
+ existingWorkPolicy: ExistingPeriodicWorkPolicy.update,
+ backoffPolicy: BackoffPolicy.exponential,
+ backoffPolicyDelay: const Duration(minutes: 10),
+ tag: 'busymax-sync',
+ );
+ await workmanager.registerPeriodicTask(
+ busyMaxNotificationRefillUniqueName,
+ busyMaxNotificationRefillTaskName,
+ frequency: const Duration(minutes: 15),
+ flexInterval: const Duration(minutes: 5),
+ constraints: Constraints(
+ requiresBatteryNotLow: true,
+ requiresStorageNotLow: true,
+ ),
+ existingWorkPolicy: ExistingPeriodicWorkPolicy.update,
+ backoffPolicy: BackoffPolicy.exponential,
+ backoffPolicyDelay: const Duration(minutes: 10),
+ tag: 'busymax-notification-refill',
+ );
+}
+
+Future enqueueImmediateBusyMaxSync() => Workmanager().registerOneOffTask(
+ busyMaxImmediateSyncUniqueName,
+ busyMaxPeriodicSyncTaskName,
+ constraints: Constraints(networkType: NetworkType.connected),
+ existingWorkPolicy: ExistingWorkPolicy.replace,
+ backoffPolicy: BackoffPolicy.exponential,
+ backoffPolicyDelay: const Duration(minutes: 10),
+ tag: 'busymax-sync',
+);
+
+final class AndroidHeadlessRuntime {
+ AndroidHeadlessRuntime._({
+ required this.container,
+ required this.database,
+ required this.notifications,
+ required http.Client httpClient,
+ }) : _httpClient = httpClient;
+
+ static Future create() async {
+ final platform = BusyMaxAndroidPlatform.instance;
+ const storage = FlutterSecureStorage(
+ aOptions: AndroidOptions(resetOnError: true),
+ );
+ final settings = await loadInitialAppSettings(
+ const JsonFileLocalSettingsStore(),
+ );
+ final timeZoneId = await platform.currentTimeZoneId();
+ final uses24Hour = await platform.uses24HourFormat();
+ final database = AppDatabase.open();
+ final client = http.Client();
+ final broker = AndroidAuthorizationBroker(
+ platform: platform,
+ httpClient: client,
+ secretStore: SecureSecretStore(storage),
+ config: BuildConfig.forAndroid(),
+ );
+ late ProviderContainer container;
+ final notifications = AndroidNotificationService(
+ database: database,
+ settings: () => container.read(appSettingsControllerProvider),
+ platform: platform,
+ strings: () {
+ final current = container.read(appSettingsControllerProvider);
+ final locale =
+ current.locale ??
+ resolveBusyMaxLocales(
+ PlatformDispatcher.instance.locales,
+ busyMaxSupportedLocales,
+ );
+ final l10n = lookupAppLocalizations(locale);
+ return AndroidNotificationStrings(
+ channelName: l10n.reminderGroup,
+ channelDescription: '${l10n.eventReminders} · ${l10n.taskReminders}',
+ statusChannelName: l10n.sync,
+ statusChannelDescription: l10n.syncConflicts,
+ privateTitle: 'BusyMax',
+ open: l10n.notificationOpenAction,
+ snooze: l10n.notificationSnoozeAction,
+ dismiss: l10n.notificationDismissAction,
+ syncFailureTitle: l10n.syncFailureNotificationTitle,
+ syncFailureBody: l10n.davTemporarilyUnavailable,
+ conflictTitle: l10n.conflictNotificationTitle,
+ dueTodayTitle: l10n.dueTodayNotificationTitle,
+ dueTodayBody: l10n.dueTodayNotificationBody,
+ );
+ },
+ );
+ await notifications.initialize(timeZoneId: timeZoneId);
+ container = ProviderContainer(
+ overrides: [
+ buildConfigProvider.overrideWithValue(BuildConfig.forAndroid()),
+ databaseProvider.overrideWithValue(database),
+ secureStorageProvider.overrideWithValue(storage),
+ initialAppSettingsProvider.overrideWithValue(settings),
+ localTimeZoneSourceProvider.overrideWithValue(
+ AndroidLocalTimeZoneSource(platform),
+ ),
+ androidLocalTimeZoneStateProvider.overrideWith((ref) => timeZoneId),
+ androidSystemUses24HourProvider.overrideWith((ref) => uses24Hour),
+ applicationOAuthGatewayProvider.overrideWithValue(broker),
+ applicationMicrosoftOAuthServiceProvider.overrideWithValue(broker),
+ accountTokenBrokerProvider.overrideWithValue(broker),
+ crossEngineAccountGateProvider.overrideWithValue(
+ AndroidCrossEngineAccountGate(platform),
+ ),
+ notificationReconcilerProvider.overrideWithValue(notifications),
+ androidNotificationServiceProvider.overrideWithValue(notifications),
+ operationalNotificationReporterProvider.overrideWithValue(
+ notifications,
+ ),
+ ],
+ );
+ return AndroidHeadlessRuntime._(
+ container: container,
+ database: database,
+ notifications: notifications,
+ httpClient: client,
+ );
+ }
+
+ final ProviderContainer container;
+ final AppDatabase database;
+ final AndroidNotificationService notifications;
+ final http.Client _httpClient;
+
+ Future dispose() async {
+ container.dispose();
+ _httpClient.close();
+ await database.close();
+ }
+}
diff --git a/lib/src/android/android_notifications.dart b/lib/src/android/android_notifications.dart
new file mode 100644
index 00000000..1ea0122c
--- /dev/null
+++ b/lib/src/android/android_notifications.dart
@@ -0,0 +1,1018 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:busymax_android_platform/busymax_android_platform.dart';
+import 'package:crypto/crypto.dart';
+import 'package:drift/drift.dart';
+import 'package:flutter_local_notifications/flutter_local_notifications.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:timezone/data/latest.dart' as tz_data;
+import 'package:timezone/timezone.dart' as tz;
+
+import '../app/app_settings.dart';
+import '../core/time/stored_temporal_projection.dart';
+import '../db/app_database.dart';
+import '../features/accounts/data/accounts_repository.dart';
+import '../features/notifications/notification_reconciler.dart';
+
+const androidReminderChannelId = 'busymax_reminders_v1';
+const androidStatusChannelId = 'busymax_status_v1';
+const androidNotificationActionOpen = 'busymax.open';
+const androidNotificationActionSnooze = 'busymax.snooze';
+const androidNotificationActionDismiss = 'busymax.dismiss';
+const _maximumScheduledAlarms = 256;
+const _schedulingHorizon = Duration(days: 90);
+
+final androidNotificationServiceProvider = Provider(
+ (ref) => throw UnsupportedError(
+ 'AndroidNotificationService must be supplied by the Android runtime.',
+ ),
+);
+
+/// Testable boundary between reminder policy/database reconciliation and the
+/// process-global notifications plugin.
+abstract interface class AndroidNotificationBackend {
+ Future initialize({
+ required InitializationSettings settings,
+ DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse,
+ DidReceiveBackgroundNotificationResponseCallback?
+ onDidReceiveBackgroundNotificationResponse,
+ });
+
+ Future getNotificationAppLaunchDetails();
+ Future> pendingNotificationRequests();
+ Future> getActiveNotifications();
+ Future cancel({required int id});
+ Future show({
+ required int id,
+ String? title,
+ String? body,
+ required NotificationDetails notificationDetails,
+ });
+ Future zonedSchedule({
+ required int id,
+ String? title,
+ String? body,
+ required tz.TZDateTime scheduledDate,
+ required NotificationDetails notificationDetails,
+ required AndroidScheduleMode androidScheduleMode,
+ String? payload,
+ });
+ Future requestNotificationPermission();
+ Future requestExactAlarmPermission();
+ Future canScheduleExactly();
+}
+
+final class FlutterAndroidNotificationBackend
+ implements AndroidNotificationBackend {
+ FlutterAndroidNotificationBackend([FlutterLocalNotificationsPlugin? plugin])
+ : _plugin = plugin ?? FlutterLocalNotificationsPlugin();
+
+ final FlutterLocalNotificationsPlugin _plugin;
+
+ AndroidFlutterLocalNotificationsPlugin? get _android => _plugin
+ .resolvePlatformSpecificImplementation<
+ AndroidFlutterLocalNotificationsPlugin
+ >();
+
+ @override
+ Future initialize({
+ required InitializationSettings settings,
+ DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse,
+ DidReceiveBackgroundNotificationResponseCallback?
+ onDidReceiveBackgroundNotificationResponse,
+ }) => _plugin.initialize(
+ settings: settings,
+ onDidReceiveNotificationResponse: onDidReceiveNotificationResponse,
+ onDidReceiveBackgroundNotificationResponse:
+ onDidReceiveBackgroundNotificationResponse,
+ );
+
+ @override
+ Future getNotificationAppLaunchDetails() =>
+ _plugin.getNotificationAppLaunchDetails();
+
+ @override
+ Future> pendingNotificationRequests() =>
+ _plugin.pendingNotificationRequests();
+
+ @override
+ Future> getActiveNotifications() =>
+ _plugin.getActiveNotifications();
+
+ @override
+ Future cancel({required int id}) => _plugin.cancel(id: id);
+
+ @override
+ Future show({
+ required int id,
+ String? title,
+ String? body,
+ required NotificationDetails notificationDetails,
+ }) => _plugin.show(
+ id: id,
+ title: title,
+ body: body,
+ notificationDetails: notificationDetails,
+ );
+
+ @override
+ Future zonedSchedule({
+ required int id,
+ String? title,
+ String? body,
+ required tz.TZDateTime scheduledDate,
+ required NotificationDetails notificationDetails,
+ required AndroidScheduleMode androidScheduleMode,
+ String? payload,
+ }) => _plugin.zonedSchedule(
+ id: id,
+ title: title,
+ body: body,
+ scheduledDate: scheduledDate,
+ notificationDetails: notificationDetails,
+ androidScheduleMode: androidScheduleMode,
+ payload: payload,
+ );
+
+ @override
+ Future requestNotificationPermission() async =>
+ await _android?.requestNotificationsPermission() ?? true;
+
+ @override
+ Future requestExactAlarmPermission() async =>
+ await _android?.requestExactAlarmsPermission() ?? false;
+
+ @override
+ Future canScheduleExactly() async =>
+ await _android?.canScheduleExactNotifications() ?? false;
+}
+
+@pragma('vm:entry-point')
+Future busyMaxNotificationBackgroundResponse(
+ NotificationResponse response,
+) async {
+ final payload = _AndroidReminderPayload.tryParse(response.payload);
+ if (payload == null ||
+ response.actionId == null ||
+ response.actionId == androidNotificationActionOpen) {
+ return;
+ }
+ final platform = BusyMaxAndroidPlatform.instance;
+ final lease = await platform.acquireAccountGate('__notifications__');
+ final database = AppDatabase.open();
+ var changed = false;
+ try {
+ final row = await (database.select(
+ database.notificationSchedule,
+ )..where((table) => table.id.equals(payload.scheduleId))).getSingleOrNull();
+ if (row == null || row.generation != payload.generation) return;
+ final account = await (database.select(
+ database.accounts,
+ )..where((table) => table.id.equals(payload.accountId))).getSingleOrNull();
+ if (account == null) return;
+ final itemExists = switch (payload.sourceType) {
+ 'event' =>
+ await (database.select(database.calendarEvents)..where(
+ (table) =>
+ table.accountId.equals(payload.accountId) &
+ table.id.equals(payload.sourceId) &
+ table.isDeleted.equals(false),
+ ))
+ .getSingleOrNull() !=
+ null,
+ 'task' =>
+ await (database.select(database.tasks)..where(
+ (table) =>
+ table.accountId.equals(payload.accountId) &
+ table.id.equals(payload.sourceId) &
+ table.pendingDelete.equals(false),
+ ))
+ .getSingleOrNull() !=
+ null,
+ _ => false,
+ };
+ if (!itemExists) return;
+ final now = DateTime.now().toUtc().millisecondsSinceEpoch;
+ final update = switch (response.actionId) {
+ androidNotificationActionSnooze => NotificationScheduleCompanion(
+ generation: Value('${row.generation}:snooze:$now'),
+ snoozedUntilUtc: Value(
+ DateTime.now()
+ .toUtc()
+ .add(const Duration(minutes: 10))
+ .millisecondsSinceEpoch,
+ ),
+ sentAtUtc: const Value(null),
+ updatedAtLocal: Value(now),
+ ),
+ androidNotificationActionDismiss => NotificationScheduleCompanion(
+ dismissedAtUtc: Value(now),
+ updatedAtLocal: Value(now),
+ ),
+ _ => null,
+ };
+ if (update != null) {
+ await (database.update(database.notificationSchedule)..where(
+ (table) =>
+ table.id.equals(payload.scheduleId) &
+ table.generation.equals(payload.generation),
+ ))
+ .write(update);
+ await (database.delete(
+ database.androidNotificationMappings,
+ )..where((table) => table.scheduleId.equals(payload.scheduleId))).go();
+ changed = true;
+ }
+ } finally {
+ await database.close();
+ await platform.releaseAccountGate(lease);
+ }
+ if (changed) {
+ final settings = await loadInitialAppSettings(
+ const JsonFileLocalSettingsStore(),
+ );
+ final reconcileDatabase = AppDatabase.open();
+ final service = AndroidNotificationService(
+ database: reconcileDatabase,
+ settings: () => settings,
+ platform: platform,
+ );
+ try {
+ await service.initialize(timeZoneId: await platform.currentTimeZoneId());
+ await service.reconcile();
+ await platform.notifyDataChanged();
+ } finally {
+ await reconcileDatabase.close();
+ }
+ }
+}
+
+final class AndroidReminderActivation {
+ const AndroidReminderActivation({
+ required this.scheduleId,
+ required this.generation,
+ required this.sourceType,
+ required this.accountId,
+ required this.itemId,
+ });
+
+ final String scheduleId;
+ final String generation;
+ final String sourceType;
+ final String accountId;
+ final String itemId;
+}
+
+final class AndroidNotificationStrings {
+ const AndroidNotificationStrings({
+ this.channelName = 'Reminders',
+ this.channelDescription = 'Calendar event and task reminders',
+ this.statusChannelName = 'Synchronization',
+ this.statusChannelDescription = 'Synchronization status and conflicts',
+ this.privateTitle = 'BusyMax reminder',
+ this.open = 'Open',
+ this.snooze = 'Snooze',
+ this.dismiss = 'Dismiss',
+ this.syncFailureTitle = 'BusyMax sync issue',
+ this.syncFailureBody = 'Open BusyMax to review the account status.',
+ this.conflictTitle = 'BusyMax sync conflict',
+ this.dueTodayTitle = 'Tasks due today',
+ this.dueTodayBody = _defaultDueTodayBody,
+ });
+
+ final String channelName;
+ final String channelDescription;
+ final String statusChannelName;
+ final String statusChannelDescription;
+ final String privateTitle;
+ final String open;
+ final String snooze;
+ final String dismiss;
+ final String syncFailureTitle;
+ final String syncFailureBody;
+ final String conflictTitle;
+ final String dueTodayTitle;
+ final String Function(int count) dueTodayBody;
+}
+
+final class AndroidNotificationService
+ implements NotificationReconciler, OperationalNotificationReporter {
+ AndroidNotificationService({
+ required AppDatabase database,
+ required AppSettings Function() settings,
+ BusyMaxAndroidPlatform? platform,
+ AndroidNotificationStrings Function()? strings,
+ FlutterLocalNotificationsPlugin? plugin,
+ AndroidNotificationBackend? backend,
+ Future Function()? exactAlarmCapability,
+ }) : _database = database,
+ _settings = settings,
+ _platform = platform ?? BusyMaxAndroidPlatform.instance,
+ _strings = strings ?? _defaultStrings,
+ _notifications = backend ?? FlutterAndroidNotificationBackend(plugin),
+ _exactAlarmCapability = exactAlarmCapability;
+
+ final AppDatabase _database;
+ final AppSettings Function() _settings;
+ final BusyMaxAndroidPlatform _platform;
+ final AndroidNotificationStrings Function() _strings;
+ final AndroidNotificationBackend _notifications;
+ final Future Function()? _exactAlarmCapability;
+ final StreamController _activations =
+ StreamController.broadcast(sync: true);
+ AndroidReminderActivation? _initialActivation;
+ bool _initialized = false;
+ bool _reconciling = false;
+ bool _reconcileAgain = false;
+ String _precisionDiagnostic = 'Alarm precision has not been checked.';
+
+ String get precisionDiagnostic => _precisionDiagnostic;
+ Stream get activations => _activations.stream;
+
+ AndroidReminderActivation? takeInitialActivation() {
+ final value = _initialActivation;
+ _initialActivation = null;
+ return value;
+ }
+
+ Future initialize({required String timeZoneId}) async {
+ if (_initialized) {
+ updateTimeZone(timeZoneId);
+ return;
+ }
+ tz_data.initializeTimeZones();
+ updateTimeZone(timeZoneId);
+ await _notifications.initialize(
+ settings: const InitializationSettings(
+ android: AndroidInitializationSettings('ic_notification'),
+ ),
+ onDidReceiveNotificationResponse: _foregroundResponse,
+ onDidReceiveBackgroundNotificationResponse:
+ busyMaxNotificationBackgroundResponse,
+ );
+ final launch = await _notifications.getNotificationAppLaunchDetails();
+ if (launch?.didNotificationLaunchApp ?? false) {
+ _initialActivation = _publicActivation(
+ _AndroidReminderPayload.tryParse(launch?.notificationResponse?.payload),
+ );
+ }
+ _initialized = true;
+ }
+
+ void updateTimeZone(String timeZoneId) {
+ try {
+ tz.setLocalLocation(tz.getLocation(timeZoneId));
+ } on ArgumentError {
+ tz.setLocalLocation(tz.UTC);
+ }
+ }
+
+ Future requestNotificationPermission() async {
+ return _notifications.requestNotificationPermission();
+ }
+
+ Future requestExactAlarmPermission() async {
+ return _notifications.requestExactAlarmPermission();
+ }
+
+ Future canScheduleExactly() async {
+ final override = _exactAlarmCapability;
+ if (override != null) return override();
+ return _notifications.canScheduleExactly();
+ }
+
+ @override
+ Future notifySyncFailure(Object error) async {
+ final settings = _settings();
+ if (!settings.notifySyncFailures || _isQuietNow(settings)) return;
+ final strings = _strings();
+ await _showStatus(
+ id: 0x425901,
+ title: strings.syncFailureTitle,
+ body: strings.syncFailureBody,
+ );
+ }
+
+ @override
+ Future notifyConflict(String summary) async {
+ final settings = _settings();
+ if (!settings.notifyConflicts || _isQuietNow(settings)) return;
+ final strings = _strings();
+ await _showStatus(
+ id: 0x425902,
+ title: strings.conflictTitle,
+ body: settings.notificationDetailLevel == NotificationDetailLevel.private
+ ? null
+ : summary,
+ );
+ }
+
+ Future _showStatus({
+ required int id,
+ required String title,
+ required String? body,
+ }) async {
+ if (!_initialized) return;
+ final strings = _strings();
+ await _notifications.show(
+ id: id,
+ title: title,
+ body: body,
+ notificationDetails: NotificationDetails(
+ android: AndroidNotificationDetails(
+ androidStatusChannelId,
+ strings.statusChannelName,
+ channelDescription: strings.statusChannelDescription,
+ importance: Importance.defaultImportance,
+ priority: Priority.defaultPriority,
+ visibility:
+ _settings().notificationDetailLevel ==
+ NotificationDetailLevel.private
+ ? NotificationVisibility.private
+ : NotificationVisibility.public,
+ ),
+ ),
+ );
+ }
+
+ @override
+ Future reconcile() async {
+ if (!_initialized) return;
+ if (_reconciling) {
+ _reconcileAgain = true;
+ return;
+ }
+ _reconciling = true;
+ String? lease;
+ try {
+ lease = await _platform.acquireAccountGate('__notifications__');
+ do {
+ _reconcileAgain = false;
+ await _reconcileOnce();
+ } while (_reconcileAgain);
+ } finally {
+ if (lease != null) await _platform.releaseAccountGate(lease);
+ _reconciling = false;
+ }
+ }
+
+ Future _reconcileOnce() async {
+ final now = DateTime.now().toUtc();
+ final horizon = now.add(_schedulingHorizon).millisecondsSinceEpoch;
+ final nowMillis = now.millisecondsSinceEpoch;
+ final eligibleAccounts = {
+ for (final account in await _database.select(_database.accounts).get())
+ if (accountLocalReminderEligibleStates.contains(account.authState))
+ account.id,
+ };
+ final settings = _settings();
+ final mappings = await _database
+ .select(_database.androidNotificationMappings)
+ .get();
+ final mappingByScheduleId = {
+ for (final mapping in mappings) mapping.scheduleId: mapping,
+ };
+ final pendingIds = {
+ for (final request in await _notifications.pendingNotificationRequests())
+ request.id,
+ };
+ var activeStateKnown = true;
+ Set activeIds;
+ try {
+ activeIds = {
+ for (final notification
+ in await _notifications.getActiveNotifications())
+ if (notification.id case final id?) id,
+ };
+ } on Object {
+ // Losing visibility into posted notifications must not make a routine
+ // reconciliation dismiss them. Source/generation invalidation below is
+ // still authoritative and will cancel an obsolete registration.
+ activeStateKnown = false;
+ activeIds = const {};
+ }
+ final exact = await canScheduleExactly();
+ final rows =
+ await (_database.select(_database.notificationSchedule)..where(
+ (table) =>
+ table.accountId.isIn(eligibleAccounts) &
+ table.sentAtUtc.isNull() &
+ table.dismissedAtUtc.isNull(),
+ ))
+ .get();
+ rows.removeWhere((row) {
+ if ((row.sourceType == 'event' && !settings.notifyEventReminders) ||
+ (row.sourceType == 'task' && !settings.notifyTaskReminders) ||
+ !const {'event', 'task'}.contains(row.sourceType)) {
+ return true;
+ }
+ final effectiveAt = applyAndroidQuietHours(_effectiveAt(row), settings);
+ final mapping = mappingByScheduleId[row.id];
+ final mappingMatchesGeneration = mapping?.generation == row.generation;
+ final remainsPending =
+ mappingMatchesGeneration && pendingIds.contains(mapping!.platformId);
+ final remainsDisplayed =
+ mappingMatchesGeneration &&
+ (activeIds.contains(mapping!.platformId) ||
+ (!activeStateKnown && !remainsPending));
+ return !shouldKeepAndroidReminder(
+ effectiveAt: effectiveAt,
+ now: nowMillis,
+ horizon: horizon,
+ remainsPending: remainsPending,
+ remainsDisplayed: remainsDisplayed,
+ );
+ });
+ rows.sort(
+ (a, b) => applyAndroidQuietHours(
+ _effectiveAt(a),
+ settings,
+ ).compareTo(applyAndroidQuietHours(_effectiveAt(b), settings)),
+ );
+ final desired = rows.take(_maximumScheduledAlarms).toList();
+ final desiredIds = {for (final row in desired) row.id};
+
+ for (final mapping in mappings) {
+ if (!desiredIds.contains(mapping.scheduleId)) {
+ await _notifications.cancel(id: mapping.platformId);
+ await (_database.delete(
+ _database.androidNotificationMappings,
+ )..where((table) => table.scheduleId.equals(mapping.scheduleId))).go();
+ }
+ }
+
+ _precisionDiagnostic = exact
+ ? 'Exact reminder alarms are enabled.'
+ : 'Exact alarm access is unavailable; reminders use Android inexact scheduling.';
+ if (rows.length > _maximumScheduledAlarms) {
+ _precisionDiagnostic =
+ '$_precisionDiagnostic ${rows.length - _maximumScheduledAlarms} later reminders are waiting for scheduling capacity.';
+ }
+ final activeMappings = {
+ for (final mapping
+ in await _database
+ .select(_database.androidNotificationMappings)
+ .get())
+ mapping.scheduleId: mapping,
+ };
+ final usedIds = {
+ for (final mapping in activeMappings.values) mapping.platformId,
+ };
+ final strings = _strings();
+ for (final row in desired) {
+ final effectiveAt = applyAndroidQuietHours(_effectiveAt(row), settings);
+ final title =
+ settings.notificationDetailLevel == NotificationDetailLevel.private
+ ? strings.privateTitle
+ : row.title;
+ final body =
+ settings.notificationDetailLevel == NotificationDetailLevel.private
+ ? null
+ : row.body;
+ final registrationState = androidNotificationRegistrationState(
+ settings: settings,
+ exact: exact,
+ title: title,
+ body: body,
+ );
+ var mapping = activeMappings[row.id];
+ final sameGeneration = mapping?.generation == row.generation;
+ final displayed =
+ sameGeneration &&
+ (activeIds.contains(mapping!.platformId) ||
+ (!activeStateKnown &&
+ !pendingIds.contains(mapping.platformId) &&
+ effectiveAt <= nowMillis));
+ final unchangedPending =
+ mapping != null &&
+ sameGeneration &&
+ mapping.scheduledAtUtc == effectiveAt &&
+ mapping.state == registrationState &&
+ pendingIds.contains(mapping.platformId);
+ if (displayed || unchangedPending) continue;
+ if (mapping != null) {
+ await _notifications.cancel(id: mapping.platformId);
+ }
+ final platformId =
+ mapping?.platformId ?? allocateAndroidNotificationId(row.id, usedIds);
+ usedIds.add(platformId);
+ final payload = _AndroidReminderPayload(
+ scheduleId: row.id,
+ generation: row.generation,
+ sourceType: row.sourceType,
+ accountId: row.accountId,
+ sourceId: row.sourceId,
+ ).encode();
+ final scheduledAt = effectiveAt <= nowMillis
+ ? now.add(const Duration(seconds: 5)).millisecondsSinceEpoch
+ : effectiveAt;
+ await _notifications.zonedSchedule(
+ id: platformId,
+ title: title,
+ body: body,
+ scheduledDate: tz.TZDateTime.fromMillisecondsSinceEpoch(
+ tz.local,
+ scheduledAt,
+ ),
+ notificationDetails: NotificationDetails(
+ android: AndroidNotificationDetails(
+ androidReminderChannelId,
+ strings.channelName,
+ channelDescription: strings.channelDescription,
+ importance: Importance.high,
+ priority: Priority.high,
+ category: AndroidNotificationCategory.reminder,
+ visibility:
+ settings.notificationDetailLevel ==
+ NotificationDetailLevel.private
+ ? NotificationVisibility.private
+ : NotificationVisibility.public,
+ actions: androidReminderNotificationActions(strings),
+ ),
+ ),
+ androidScheduleMode: exact
+ ? AndroidScheduleMode.exactAllowWhileIdle
+ : AndroidScheduleMode.inexactAllowWhileIdle,
+ payload: payload,
+ );
+ await _database
+ .into(_database.androidNotificationMappings)
+ .insertOnConflictUpdate(
+ AndroidNotificationMappingsCompanion.insert(
+ scheduleId: row.id,
+ generation: row.generation,
+ platformId: platformId,
+ scheduledAtUtc: effectiveAt,
+ state: Value(registrationState),
+ updatedAtUtc: now.millisecondsSinceEpoch,
+ ),
+ );
+ }
+ await _reconcileDailySummary(
+ settings: settings,
+ eligibleAccounts: eligibleAccounts,
+ exact: exact,
+ pendingIds: pendingIds,
+ activeIds: activeIds,
+ );
+ }
+
+ Future _reconcileDailySummary({
+ required AppSettings settings,
+ required Set eligibleAccounts,
+ required bool exact,
+ required Set pendingIds,
+ required Set activeIds,
+ }) async {
+ const platformId = 0x425903;
+ final existing = await _database
+ .select(_database.androidDailySummarySchedules)
+ .get();
+ if (!settings.notifyDueToday || eligibleAccounts.isEmpty) {
+ await _notifications.cancel(id: platformId);
+ await _database.delete(_database.androidDailySummarySchedules).go();
+ return;
+ }
+ final now = tz.TZDateTime.now(tz.local);
+ final localDate =
+ '${now.year.toString().padLeft(4, '0')}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
+ for (final row in existing.where((row) => row.localDate != localDate)) {
+ await _notifications.cancel(id: row.platformId);
+ await (_database.delete(
+ _database.androidDailySummarySchedules,
+ )..where((table) => table.localDate.equals(row.localDate))).go();
+ }
+ final activeListKeys = {
+ for (final list
+ in await (_database.select(_database.taskLists)..where(
+ (table) =>
+ table.accountId.isIn(eligibleAccounts) &
+ table.pendingDelete.equals(false) &
+ table.serverMissing.equals(false),
+ ))
+ .get())
+ '${list.accountId}\u0000${list.id}',
+ };
+ final tasks = activeListKeys.isEmpty
+ ? const []
+ : await (_database.select(_database.tasks)..where(
+ (table) =>
+ table.accountId.isIn(eligibleAccounts) &
+ table.pendingDelete.equals(false) &
+ (table.deleted.isNull() | table.deleted.equals(false)) &
+ (table.hidden.isNull() | table.hidden.equals(false)),
+ ))
+ .get();
+ final count = tasks.where((task) {
+ if (!activeListKeys.contains(
+ '${task.accountId}\u0000${task.taskListId}',
+ )) {
+ return false;
+ }
+ if (task.status == 'completed' || task.status == 'cancelled') {
+ return false;
+ }
+ final due = taskDueAsLocal(task);
+ return due != null &&
+ due.year == now.year &&
+ due.month == now.month &&
+ due.day == now.day;
+ }).length;
+ if (count == 0) {
+ await _notifications.cancel(id: platformId);
+ await (_database.delete(
+ _database.androidDailySummarySchedules,
+ )..where((table) => table.localDate.equals(localDate))).go();
+ return;
+ }
+ final strings = _strings();
+ final title = strings.dueTodayTitle;
+ final body =
+ settings.notificationDetailLevel == NotificationDetailLevel.private
+ ? null
+ : strings.dueTodayBody(count);
+ final registrationState = androidNotificationRegistrationState(
+ settings: settings,
+ exact: exact,
+ title: title,
+ body: body,
+ );
+ final current = existing
+ .where((row) => row.localDate == localDate)
+ .firstOrNull;
+ final nowMillis = DateTime.now().millisecondsSinceEpoch;
+ if (current != null) {
+ final pending = pendingIds.contains(platformId);
+ final displayed = activeIds.contains(platformId);
+ final contentMatches = current.taskCount == count;
+ if (displayed && contentMatches) {
+ // A posted summary remains actionable. Do not remove or repost it just
+ // because its scheduled time has elapsed or policy has since changed.
+ return;
+ }
+ if (pending && contentMatches && current.state == registrationState) {
+ // Inexact delivery may be late. Preserve an unchanged pending alarm,
+ // but policy/content changes below must replace it.
+ return;
+ }
+ if (current.scheduledAtUtc <= nowMillis && !pending && !displayed) {
+ // Absence from both platform sets after the due time can mean the user
+ // dismissed an already shown summary. Do not infer that it needs to be
+ // emitted again. If active-state inspection failed, prefer the same
+ // non-destructive outcome.
+ return;
+ }
+ }
+ var scheduled = tz.TZDateTime(tz.local, now.year, now.month, now.day, 9);
+ if (!scheduled.isAfter(now)) {
+ scheduled = now.add(const Duration(seconds: 10));
+ }
+ final scheduledAt = applyAndroidQuietHours(
+ scheduled.millisecondsSinceEpoch,
+ settings,
+ );
+ final unchanged =
+ current != null &&
+ current.taskCount == count &&
+ current.scheduledAtUtc == scheduledAt &&
+ current.state == registrationState &&
+ pendingIds.contains(platformId);
+ if (unchanged) return;
+ if (current != null) await _notifications.cancel(id: platformId);
+ final generation = '$localDate:$count:$scheduledAt';
+ await _notifications.zonedSchedule(
+ id: platformId,
+ title: title,
+ body: body,
+ scheduledDate: tz.TZDateTime.fromMillisecondsSinceEpoch(
+ tz.local,
+ scheduledAt,
+ ),
+ notificationDetails: NotificationDetails(
+ android: AndroidNotificationDetails(
+ androidReminderChannelId,
+ strings.channelName,
+ channelDescription: strings.channelDescription,
+ importance: Importance.defaultImportance,
+ priority: Priority.defaultPriority,
+ visibility:
+ settings.notificationDetailLevel ==
+ NotificationDetailLevel.private
+ ? NotificationVisibility.private
+ : NotificationVisibility.public,
+ actions: [androidOpenNotificationAction(strings)],
+ ),
+ ),
+ androidScheduleMode: exact
+ ? AndroidScheduleMode.exactAllowWhileIdle
+ : AndroidScheduleMode.inexactAllowWhileIdle,
+ payload: _AndroidReminderPayload(
+ scheduleId: 'due-today:$localDate',
+ generation: generation,
+ sourceType: 'summary',
+ accountId: 'all',
+ sourceId: localDate,
+ ).encode(),
+ );
+ await _database
+ .into(_database.androidDailySummarySchedules)
+ .insertOnConflictUpdate(
+ AndroidDailySummarySchedulesCompanion.insert(
+ localDate: localDate,
+ platformId: platformId,
+ generation: generation,
+ scheduledAtUtc: scheduledAt,
+ taskCount: count,
+ state: Value(registrationState),
+ updatedAtUtc: DateTime.now().millisecondsSinceEpoch,
+ ),
+ );
+ }
+
+ Future _foregroundResponse(NotificationResponse response) async {
+ if (response.actionId == androidNotificationActionSnooze ||
+ response.actionId == androidNotificationActionDismiss) {
+ await busyMaxNotificationBackgroundResponse(response);
+ await reconcile();
+ return;
+ }
+ final activation = _publicActivation(
+ _AndroidReminderPayload.tryParse(response.payload),
+ );
+ if (activation != null) _activations.add(activation);
+ }
+}
+
+AndroidNotificationStrings _defaultStrings() =>
+ const AndroidNotificationStrings();
+
+String _defaultDueTodayBody(int count) => '$count tasks are due today.';
+
+AndroidReminderActivation? _publicActivation(_AndroidReminderPayload? payload) {
+ if (payload == null) return null;
+ return AndroidReminderActivation(
+ scheduleId: payload.scheduleId,
+ generation: payload.generation,
+ sourceType: payload.sourceType,
+ accountId: payload.accountId,
+ itemId: payload.sourceId,
+ );
+}
+
+int applyAndroidQuietHours(int epochMillis, AppSettings settings) {
+ if (!settings.quietHoursEnabled) return epochMillis;
+ final start = _minutesOfDay(settings.quietHoursStart);
+ final end = _minutesOfDay(settings.quietHoursEnd);
+ if (start == null || end == null || start == end) return epochMillis;
+ final local = tz.TZDateTime.fromMillisecondsSinceEpoch(tz.local, epochMillis);
+ final current = local.hour * 60 + local.minute;
+ final quiet = start < end
+ ? current >= start && current < end
+ : current >= start || current < end;
+ if (!quiet) return epochMillis;
+ var dayOffset = 0;
+ if (start > end && current >= start) dayOffset = 1;
+ return tz.TZDateTime(
+ tz.local,
+ local.year,
+ local.month,
+ local.day + dayOffset,
+ end ~/ 60,
+ end % 60,
+ ).millisecondsSinceEpoch;
+}
+
+/// Fingerprint of platform-visible scheduling choices that are not represented
+/// by a reminder generation. A changed fingerprint must replace the existing
+/// Android registration even when the source reminder itself did not change.
+String androidNotificationRegistrationState({
+ required AppSettings settings,
+ required bool exact,
+ String? title,
+ String? body,
+}) {
+ final contentHash = sha256.convert(utf8.encode(jsonEncode([title, body])));
+ return 'scheduled:v3:${settings.notificationDetailLevel.name}:'
+ '${exact ? 'exact' : 'inexact'}:$contentHash';
+}
+
+AndroidNotificationAction androidOpenNotificationAction(
+ AndroidNotificationStrings strings,
+) => AndroidNotificationAction(
+ androidNotificationActionOpen,
+ strings.open,
+ showsUserInterface: true,
+);
+
+List androidReminderNotificationActions(
+ AndroidNotificationStrings strings,
+) => [
+ androidOpenNotificationAction(strings),
+ AndroidNotificationAction(
+ androidNotificationActionSnooze,
+ strings.snooze,
+ cancelNotification: true,
+ ),
+ AndroidNotificationAction(
+ androidNotificationActionDismiss,
+ strings.dismiss,
+ cancelNotification: true,
+ ),
+];
+
+bool shouldKeepAndroidReminder({
+ required int effectiveAt,
+ required int now,
+ required int horizon,
+ required bool remainsPending,
+ bool remainsDisplayed = false,
+}) =>
+ effectiveAt <= horizon &&
+ (effectiveAt > now || remainsPending || remainsDisplayed);
+
+bool _isQuietNow(AppSettings settings) {
+ final now = DateTime.now().millisecondsSinceEpoch;
+ return applyAndroidQuietHours(now, settings) != now;
+}
+
+int? _minutesOfDay(String value) {
+ final parts = value.split(':');
+ if (parts.length != 2) return null;
+ final hour = int.tryParse(parts[0]);
+ final minute = int.tryParse(parts[1]);
+ if (hour == null ||
+ minute == null ||
+ hour < 0 ||
+ hour > 23 ||
+ minute < 0 ||
+ minute > 59) {
+ return null;
+ }
+ return hour * 60 + minute;
+}
+
+int _effectiveAt(NotificationScheduleData row) =>
+ row.snoozedUntilUtc ?? row.scheduledAtUtc;
+
+int allocateAndroidNotificationId(String value, Set used) {
+ var hash = 0x811c9dc5;
+ for (final byte in utf8.encode(value)) {
+ hash = ((hash ^ byte) * 0x01000193) & 0x7fffffff;
+ }
+ if (hash == 0) hash = 1;
+ while (used.contains(hash)) {
+ hash = hash == 0x7fffffff ? 1 : hash + 1;
+ }
+ return hash;
+}
+
+AndroidReminderActivation? parseAndroidReminderActivation(String? payload) =>
+ _publicActivation(_AndroidReminderPayload.tryParse(payload));
+
+final class _AndroidReminderPayload {
+ const _AndroidReminderPayload({
+ required this.scheduleId,
+ required this.generation,
+ required this.sourceType,
+ required this.accountId,
+ required this.sourceId,
+ });
+
+ static _AndroidReminderPayload? tryParse(String? value) {
+ if (value == null || value.length > 2048) return null;
+ try {
+ final json = jsonDecode(value);
+ if (json is! Map) return null;
+ final map = json.cast();
+ final fields = ['schedule', 'generation', 'type', 'account', 'source'];
+ if (fields.any((key) => (map[key]?.toString().trim().isEmpty ?? true))) {
+ return null;
+ }
+ return _AndroidReminderPayload(
+ scheduleId: map['schedule']!.toString(),
+ generation: map['generation']!.toString(),
+ sourceType: map['type']!.toString(),
+ accountId: map['account']!.toString(),
+ sourceId: map['source']!.toString(),
+ );
+ } on Object {
+ return null;
+ }
+ }
+
+ final String scheduleId;
+ final String generation;
+ final String sourceType;
+ final String accountId;
+ final String sourceId;
+
+ String encode() => jsonEncode({
+ 'v': 1,
+ 'schedule': scheduleId,
+ 'generation': generation,
+ 'type': sourceType,
+ 'account': accountId,
+ 'source': sourceId,
+ });
+}
diff --git a/lib/src/android/android_time_zone.dart b/lib/src/android/android_time_zone.dart
new file mode 100644
index 00000000..569a08e9
--- /dev/null
+++ b/lib/src/android/android_time_zone.dart
@@ -0,0 +1,15 @@
+import 'package:busymax_android_platform/busymax_android_platform.dart';
+
+import '../platform/common/desktop_services.dart';
+
+final class AndroidLocalTimeZoneSource implements LocalTimeZoneSource {
+ const AndroidLocalTimeZoneSource(this._platform);
+
+ final BusyMaxAndroidPlatform _platform;
+
+ @override
+ String? get diagnostic => null;
+
+ @override
+ Future currentIanaTimeZone() => _platform.currentTimeZoneId();
+}
diff --git a/lib/src/android/presentation/android_availability_dialog.dart b/lib/src/android/presentation/android_availability_dialog.dart
new file mode 100644
index 00000000..785da5b7
--- /dev/null
+++ b/lib/src/android/presentation/android_availability_dialog.dart
@@ -0,0 +1,232 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:intl/intl.dart';
+
+import '../../app/app_bootstrap.dart';
+import '../../calendar_providers/calendar_sync_dto.dart';
+import '../../calendar_providers/cloud_calendar_client.dart';
+import '../../core/logging/redacting_logger.dart';
+import '../../dav/nextcloud/nextcloud_scheduling_service.dart';
+import '../../features/calendar/presentation/event_editor_draft.dart';
+import '../../l10n/l10n.dart';
+import '../../l10n/time_format_scope.dart';
+import '../../providers/busy_provider.dart';
+
+Future showAndroidGuestAvailabilityDialog(
+ BuildContext context, {
+ required String accountId,
+ required BusyProvider provider,
+ required EventEditorDraft draft,
+ String? collectionId,
+}) => showDialog(
+ context: context,
+ builder: (_) => AndroidGuestAvailabilityDialog(
+ accountId: accountId,
+ provider: provider,
+ collectionId: collectionId,
+ draft: draft,
+ ),
+);
+
+class AndroidGuestAvailabilityDialog extends ConsumerStatefulWidget {
+ const AndroidGuestAvailabilityDialog({
+ super.key,
+ required this.accountId,
+ required this.provider,
+ required this.draft,
+ this.collectionId,
+ });
+
+ final String accountId;
+ final BusyProvider provider;
+ final String? collectionId;
+ final EventEditorDraft draft;
+
+ @override
+ ConsumerState createState() =>
+ _AndroidGuestAvailabilityDialogState();
+}
+
+class _AndroidGuestAvailabilityDialogState
+ extends ConsumerState {
+ bool _loading = true;
+ Object? _error;
+ List _nextcloud = const [];
+ Map _google = const {};
+
+ List get _recipients => widget.draft.attendees
+ .where((attendee) => !attendee.self && !attendee.organizer)
+ .map((attendee) => attendee.email.trim())
+ .where((email) => email.isNotEmpty)
+ .toSet()
+ .toList(growable: false);
+
+ @override
+ void initState() {
+ super.initState();
+ unawaited(_load());
+ }
+
+ Future _load() async {
+ if (!_loading && mounted) setState(() => _loading = true);
+ _error = null;
+ try {
+ if (widget.provider == BusyProvider.nextcloud) {
+ final collectionId = widget.collectionId;
+ if (collectionId == null) throw StateError('Missing collection');
+ _nextcloud = await ref
+ .read(nextcloudSchedulingServiceProvider(widget.accountId))
+ .freeBusyForDraft(
+ collectionId: collectionId,
+ draft: widget.draft,
+ fallbackTimeZone: ref.read(localTimeZoneProvider),
+ );
+ } else if (widget.provider == BusyProvider.google) {
+ final client = ref.read(
+ calendarRemoteApiClientForAccountProvider(widget.accountId),
+ );
+ final start = widget.draft.start;
+ final end = widget.draft.end;
+ if (client == null || start == null || end == null) {
+ throw StateError('Availability is unavailable');
+ }
+ if (client is DetailedFreeBusyClient) {
+ final results = await (client as DetailedFreeBusyClient)
+ .freeBusyDetails(
+ calendarIds: _recipients,
+ rangeStart: start.toUtc(),
+ rangeEnd: end.toUtc(),
+ );
+ final byCalendar = {
+ for (final result in results)
+ result.calendarId.toLowerCase(): result,
+ };
+ _google = {
+ for (final recipient in _recipients)
+ recipient:
+ byCalendar[recipient.toLowerCase()] ??
+ FreeBusyCalendarResultDto(
+ calendarId: recipient,
+ status: FreeBusyEvaluationStatus.missing,
+ ),
+ };
+ } else {
+ _google = {
+ for (final recipient in _recipients)
+ recipient: FreeBusyCalendarResultDto(
+ calendarId: recipient,
+ status: FreeBusyEvaluationStatus.missing,
+ ),
+ };
+ }
+ } else {
+ throw StateError('Availability is unsupported');
+ }
+ } on Object catch (error) {
+ _error = error;
+ } finally {
+ if (mounted) setState(() => _loading = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final date = DateFormat.yMMMd(Localizations.localeOf(context).toString());
+ String interval(DateTime start, DateTime end) =>
+ '${formatClockDateTime(context, start.toLocal(), date.format(start.toLocal()))} – '
+ '${formatClockDateTime(context, end.toLocal(), date.format(end.toLocal()))}';
+ return AlertDialog(
+ key: const ValueKey('android-guest-availability-dialog'),
+ title: Text(context.l10n.nextcloudGuestAvailability),
+ content: SizedBox(
+ width: 520,
+ height: (MediaQuery.sizeOf(context).height * .55).clamp(180, 520),
+ child: _loading
+ ? const Center(child: CircularProgressIndicator())
+ : _error != null
+ ? SingleChildScrollView(child: Text(redactForLog(_error)))
+ : ListView(
+ children: [
+ for (final result in _nextcloud)
+ _AvailabilityEntry(
+ recipient: result.recipient.replaceFirst(
+ RegExp(r'^mailto:', caseSensitive: false),
+ '',
+ ),
+ free:
+ result.availability !=
+ NextcloudAvailability.unknown &&
+ result.intervals.isEmpty,
+ unknown:
+ result.availability == NextcloudAvailability.unknown,
+ intervals: [
+ for (final value in result.intervals)
+ interval(value.startUtc, value.endUtc),
+ ],
+ ),
+ for (final entry in _google.entries)
+ _AvailabilityEntry(
+ recipient: entry.key,
+ free:
+ entry.value.succeeded &&
+ entry.value.busySlots.isEmpty,
+ unknown: !entry.value.succeeded,
+ intervals: [
+ for (final value in entry.value.busySlots)
+ interval(value.start, value.end),
+ ],
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: _loading ? null : _load,
+ child: Text(context.l10n.refresh),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.pop(context),
+ child: Text(context.l10n.close),
+ ),
+ ],
+ );
+ }
+}
+
+class _AvailabilityEntry extends StatelessWidget {
+ const _AvailabilityEntry({
+ required this.recipient,
+ required this.free,
+ required this.unknown,
+ required this.intervals,
+ });
+
+ final String recipient;
+ final bool free;
+ final bool unknown;
+ final List intervals;
+
+ @override
+ Widget build(BuildContext context) => Padding(
+ padding: const EdgeInsets.only(bottom: 16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SelectableText(
+ recipient,
+ style: Theme.of(context).textTheme.titleSmall,
+ ),
+ Text(
+ unknown
+ ? context.l10n.nextcloudAvailabilityUnknown
+ : free
+ ? context.l10n.nextcloudAvailabilityFree
+ : context.l10n.nextcloudAvailabilityBusy,
+ ),
+ for (final value in intervals) SelectableText(value),
+ ],
+ ),
+ );
+}
diff --git a/lib/src/android/presentation/android_date_picker.dart b/lib/src/android/presentation/android_date_picker.dart
new file mode 100644
index 00000000..5d0b41bf
--- /dev/null
+++ b/lib/src/android/presentation/android_date_picker.dart
@@ -0,0 +1,458 @@
+// ignore_for_file: deprecated_member_use
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+
+import '../../l10n/week_preferences_scope.dart';
+
+Future showBusyMaxDatePicker({
+ required BuildContext context,
+ required DateTime initialDate,
+ required DateTime firstDate,
+ required DateTime lastDate,
+ DateTime? currentDate,
+ DatePickerEntryMode initialEntryMode = DatePickerEntryMode.calendar,
+ SelectableDayPredicate? selectableDayPredicate,
+ String? helpText,
+ String? cancelText,
+ String? confirmText,
+ DatePickerMode initialDatePickerMode = DatePickerMode.day,
+ String? errorFormatText,
+ String? errorInvalidText,
+ String? fieldHintText,
+ String? fieldLabelText,
+ TextInputType? keyboardType,
+ ValueChanged? onDatePickerModeChange,
+ Icon? switchToInputEntryModeIcon,
+ Icon? switchToCalendarEntryModeIcon,
+ bool barrierDismissible = true,
+ Color? barrierColor,
+ String? barrierLabel,
+ bool useRootNavigator = true,
+ RouteSettings? routeSettings,
+ TextDirection? textDirection,
+ TransitionBuilder? builder,
+ Offset? anchorPoint,
+}) => showDatePicker(
+ context: context,
+ initialDate: initialDate,
+ firstDate: firstDate,
+ lastDate: lastDate,
+ currentDate: currentDate,
+ initialEntryMode: initialEntryMode,
+ selectableDayPredicate: selectableDayPredicate,
+ helpText: helpText,
+ cancelText: cancelText,
+ confirmText: confirmText,
+ initialDatePickerMode: initialDatePickerMode,
+ errorFormatText: errorFormatText,
+ errorInvalidText: errorInvalidText,
+ fieldHintText: fieldHintText,
+ fieldLabelText: fieldLabelText,
+ keyboardType: keyboardType,
+ onDatePickerModeChange: onDatePickerModeChange,
+ switchToInputEntryModeIcon: switchToInputEntryModeIcon,
+ switchToCalendarEntryModeIcon: switchToCalendarEntryModeIcon,
+ barrierDismissible: barrierDismissible,
+ barrierColor: barrierColor,
+ barrierLabel: barrierLabel,
+ useRootNavigator: useRootNavigator,
+ routeSettings: routeSettings,
+ textDirection: textDirection,
+ anchorPoint: anchorPoint,
+ builder: (pickerContext, child) {
+ final actual = MaterialLocalizations.of(pickerContext);
+ final firstWeekday = BusyMaxWeekPreferencesScope.firstWeekdayOf(
+ pickerContext,
+ );
+ final localizedChild = Localizations.override(
+ context: pickerContext,
+ delegates: [_BusyMaxPickerLocalizationsDelegate(actual, firstWeekday)],
+ child: child!,
+ );
+ return builder?.call(pickerContext, localizedChild) ?? localizedChild;
+ },
+);
+
+class _BusyMaxPickerLocalizationsDelegate
+ extends LocalizationsDelegate {
+ const _BusyMaxPickerLocalizationsDelegate(this.actual, this.firstWeekday);
+
+ final MaterialLocalizations actual;
+ final int firstWeekday;
+
+ @override
+ bool isSupported(Locale locale) => true;
+
+ @override
+ Future load(Locale locale) =>
+ SynchronousFuture(
+ _BusyMaxPickerMaterialLocalizations(actual, firstWeekday),
+ );
+
+ @override
+ bool shouldReload(_BusyMaxPickerLocalizationsDelegate old) =>
+ !identical(old.actual, actual) || old.firstWeekday != firstWeekday;
+}
+
+class _BusyMaxPickerMaterialLocalizations implements MaterialLocalizations {
+ const _BusyMaxPickerMaterialLocalizations(this._delegate, this._firstWeekday);
+
+ final MaterialLocalizations _delegate;
+ final int _firstWeekday;
+
+ @override
+ String get openAppDrawerTooltip => _delegate.openAppDrawerTooltip;
+ @override
+ String get backButtonTooltip => _delegate.backButtonTooltip;
+ @override
+ String get clearButtonTooltip => _delegate.clearButtonTooltip;
+ @override
+ String get closeButtonTooltip => _delegate.closeButtonTooltip;
+ @override
+ String get deleteButtonTooltip => _delegate.deleteButtonTooltip;
+ @override
+ String get moreButtonTooltip => _delegate.moreButtonTooltip;
+ @override
+ String get nextMonthTooltip => _delegate.nextMonthTooltip;
+ @override
+ String get previousMonthTooltip => _delegate.previousMonthTooltip;
+ @override
+ String get firstPageTooltip => _delegate.firstPageTooltip;
+ @override
+ String get lastPageTooltip => _delegate.lastPageTooltip;
+ @override
+ String get nextPageTooltip => _delegate.nextPageTooltip;
+ @override
+ String get previousPageTooltip => _delegate.previousPageTooltip;
+ @override
+ String get showMenuTooltip => _delegate.showMenuTooltip;
+ @override
+ String aboutListTileTitle(String applicationName) =>
+ _delegate.aboutListTileTitle(applicationName);
+ @override
+ String get licensesPageTitle => _delegate.licensesPageTitle;
+ @override
+ String licensesPackageDetailText(int licenseCount) =>
+ _delegate.licensesPackageDetailText(licenseCount);
+ @override
+ String pageRowsInfoTitle(
+ int firstRow,
+ int lastRow,
+ int rowCount,
+ bool rowCountIsApproximate,
+ ) => _delegate.pageRowsInfoTitle(
+ firstRow,
+ lastRow,
+ rowCount,
+ rowCountIsApproximate,
+ );
+ @override
+ String get rowsPerPageTitle => _delegate.rowsPerPageTitle;
+ @override
+ String tabLabel({required int tabIndex, required int tabCount}) =>
+ _delegate.tabLabel(tabIndex: tabIndex, tabCount: tabCount);
+ @override
+ String selectedRowCountTitle(int selectedRowCount) =>
+ _delegate.selectedRowCountTitle(selectedRowCount);
+ @override
+ String get cancelButtonLabel => _delegate.cancelButtonLabel;
+ @override
+ String get closeButtonLabel => _delegate.closeButtonLabel;
+ @override
+ String get continueButtonLabel => _delegate.continueButtonLabel;
+ @override
+ String get copyButtonLabel => _delegate.copyButtonLabel;
+ @override
+ String get cutButtonLabel => _delegate.cutButtonLabel;
+ @override
+ String get scanTextButtonLabel => _delegate.scanTextButtonLabel;
+ @override
+ String get okButtonLabel => _delegate.okButtonLabel;
+ @override
+ String get pasteButtonLabel => _delegate.pasteButtonLabel;
+ @override
+ String get selectAllButtonLabel => _delegate.selectAllButtonLabel;
+ @override
+ String get lookUpButtonLabel => _delegate.lookUpButtonLabel;
+ @override
+ String get searchWebButtonLabel => _delegate.searchWebButtonLabel;
+ @override
+ String get shareButtonLabel => _delegate.shareButtonLabel;
+ @override
+ String get viewLicensesButtonLabel => _delegate.viewLicensesButtonLabel;
+ @override
+ String get anteMeridiemAbbreviation => _delegate.anteMeridiemAbbreviation;
+ @override
+ String get postMeridiemAbbreviation => _delegate.postMeridiemAbbreviation;
+ @override
+ String get timePickerHourModeAnnouncement =>
+ _delegate.timePickerHourModeAnnouncement;
+ @override
+ String get timePickerMinuteModeAnnouncement =>
+ _delegate.timePickerMinuteModeAnnouncement;
+ @override
+ String get modalBarrierDismissLabel => _delegate.modalBarrierDismissLabel;
+ @override
+ String get menuDismissLabel => _delegate.menuDismissLabel;
+ @override
+ String get drawerLabel => _delegate.drawerLabel;
+ @override
+ String get popupMenuLabel => _delegate.popupMenuLabel;
+ @override
+ String get menuBarMenuLabel => _delegate.menuBarMenuLabel;
+ @override
+ String get dialogLabel => _delegate.dialogLabel;
+ @override
+ String get alertDialogLabel => _delegate.alertDialogLabel;
+ @override
+ String get searchFieldLabel => _delegate.searchFieldLabel;
+ @override
+ String get currentDateLabel => _delegate.currentDateLabel;
+ @override
+ String get selectedDateLabel => _delegate.selectedDateLabel;
+ @override
+ String get scrimLabel => _delegate.scrimLabel;
+ @override
+ String get bottomSheetLabel => _delegate.bottomSheetLabel;
+ @override
+ String scrimOnTapHint(String modalRouteContentName) =>
+ _delegate.scrimOnTapHint(modalRouteContentName);
+ @override
+ TimeOfDayFormat timeOfDayFormat({bool alwaysUse24HourFormat = false}) =>
+ _delegate.timeOfDayFormat(alwaysUse24HourFormat: alwaysUse24HourFormat);
+ @override
+ ScriptCategory get scriptCategory => _delegate.scriptCategory;
+ @override
+ String formatDecimal(int number) => _delegate.formatDecimal(number);
+ @override
+ String formatHour(
+ TimeOfDay timeOfDay, {
+ bool alwaysUse24HourFormat = false,
+ }) => _delegate.formatHour(
+ timeOfDay,
+ alwaysUse24HourFormat: alwaysUse24HourFormat,
+ );
+ @override
+ String formatMinute(TimeOfDay timeOfDay) => _delegate.formatMinute(timeOfDay);
+ @override
+ String formatTimeOfDay(
+ TimeOfDay timeOfDay, {
+ bool alwaysUse24HourFormat = false,
+ }) => _delegate.formatTimeOfDay(
+ timeOfDay,
+ alwaysUse24HourFormat: alwaysUse24HourFormat,
+ );
+ @override
+ String formatYear(DateTime date) => _delegate.formatYear(date);
+ @override
+ String formatCompactDate(DateTime date) => _delegate.formatCompactDate(date);
+ @override
+ String formatShortDate(DateTime date) => _delegate.formatShortDate(date);
+ @override
+ String formatMediumDate(DateTime date) => _delegate.formatMediumDate(date);
+ @override
+ String formatFullDate(DateTime date) => _delegate.formatFullDate(date);
+ @override
+ String formatMonthYear(DateTime date) => _delegate.formatMonthYear(date);
+ @override
+ String formatShortMonthDay(DateTime date) =>
+ _delegate.formatShortMonthDay(date);
+ @override
+ DateTime? parseCompactDate(String? inputString) =>
+ _delegate.parseCompactDate(inputString);
+ @override
+ List get narrowWeekdays => _delegate.narrowWeekdays;
+ @override
+ int get firstDayOfWeekIndex =>
+ _firstWeekday == DateTime.sunday ? 0 : _firstWeekday;
+ @override
+ String get dateSeparator => _delegate.dateSeparator;
+ @override
+ String get dateHelpText => _delegate.dateHelpText;
+ @override
+ String get selectYearSemanticsLabel => _delegate.selectYearSemanticsLabel;
+ @override
+ String get unspecifiedDate => _delegate.unspecifiedDate;
+ @override
+ String get unspecifiedDateRange => _delegate.unspecifiedDateRange;
+ @override
+ String get dateInputLabel => _delegate.dateInputLabel;
+ @override
+ String get dateRangeStartLabel => _delegate.dateRangeStartLabel;
+ @override
+ String get dateRangeEndLabel => _delegate.dateRangeEndLabel;
+ @override
+ String dateRangeStartDateSemanticLabel(String formattedDate) =>
+ _delegate.dateRangeStartDateSemanticLabel(formattedDate);
+ @override
+ String dateRangeEndDateSemanticLabel(String formattedDate) =>
+ _delegate.dateRangeEndDateSemanticLabel(formattedDate);
+ @override
+ String get invalidDateFormatLabel => _delegate.invalidDateFormatLabel;
+ @override
+ String get invalidDateRangeLabel => _delegate.invalidDateRangeLabel;
+ @override
+ String get dateOutOfRangeLabel => _delegate.dateOutOfRangeLabel;
+ @override
+ String get saveButtonLabel => _delegate.saveButtonLabel;
+ @override
+ String get datePickerHelpText => _delegate.datePickerHelpText;
+ @override
+ String get dateRangePickerHelpText => _delegate.dateRangePickerHelpText;
+ @override
+ String get calendarModeButtonLabel => _delegate.calendarModeButtonLabel;
+ @override
+ String get inputDateModeButtonLabel => _delegate.inputDateModeButtonLabel;
+ @override
+ String get timePickerDialHelpText => _delegate.timePickerDialHelpText;
+ @override
+ String get timePickerInputHelpText => _delegate.timePickerInputHelpText;
+ @override
+ String get timePickerHourLabel => _delegate.timePickerHourLabel;
+ @override
+ String get timePickerMinuteLabel => _delegate.timePickerMinuteLabel;
+ @override
+ String get invalidTimeLabel => _delegate.invalidTimeLabel;
+ @override
+ String get dialModeButtonLabel => _delegate.dialModeButtonLabel;
+ @override
+ String get inputTimeModeButtonLabel => _delegate.inputTimeModeButtonLabel;
+ @override
+ String get signedInLabel => _delegate.signedInLabel;
+ @override
+ String get hideAccountsLabel => _delegate.hideAccountsLabel;
+ @override
+ String get showAccountsLabel => _delegate.showAccountsLabel;
+ @override
+ String get reorderItemToStart => _delegate.reorderItemToStart;
+ @override
+ String get reorderItemToEnd => _delegate.reorderItemToEnd;
+ @override
+ String get reorderItemUp => _delegate.reorderItemUp;
+ @override
+ String get reorderItemDown => _delegate.reorderItemDown;
+ @override
+ String get reorderItemLeft => _delegate.reorderItemLeft;
+ @override
+ String get reorderItemRight => _delegate.reorderItemRight;
+ @override
+ String get expandedIconTapHint => _delegate.expandedIconTapHint;
+ @override
+ String get collapsedIconTapHint => _delegate.collapsedIconTapHint;
+ @override
+ String get expansionTileExpandedHint => _delegate.expansionTileExpandedHint;
+ @override
+ String get expansionTileCollapsedHint => _delegate.expansionTileCollapsedHint;
+ @override
+ String get expansionTileExpandedTapHint =>
+ _delegate.expansionTileExpandedTapHint;
+ @override
+ String get expansionTileCollapsedTapHint =>
+ _delegate.expansionTileCollapsedTapHint;
+ @override
+ String get expandedHint => _delegate.expandedHint;
+ @override
+ String get collapsedHint => _delegate.collapsedHint;
+ @override
+ String remainingTextFieldCharacterCount(int remaining) =>
+ _delegate.remainingTextFieldCharacterCount(remaining);
+ @override
+ String get refreshIndicatorSemanticLabel =>
+ _delegate.refreshIndicatorSemanticLabel;
+ @override
+ String get keyboardKeyAlt => _delegate.keyboardKeyAlt;
+ @override
+ String get keyboardKeyAltGraph => _delegate.keyboardKeyAltGraph;
+ @override
+ String get keyboardKeyBackspace => _delegate.keyboardKeyBackspace;
+ @override
+ String get keyboardKeyCapsLock => _delegate.keyboardKeyCapsLock;
+ @override
+ String get keyboardKeyChannelDown => _delegate.keyboardKeyChannelDown;
+ @override
+ String get keyboardKeyChannelUp => _delegate.keyboardKeyChannelUp;
+ @override
+ String get keyboardKeyControl => _delegate.keyboardKeyControl;
+ @override
+ String get keyboardKeyDelete => _delegate.keyboardKeyDelete;
+ @override
+ String get keyboardKeyEject => _delegate.keyboardKeyEject;
+ @override
+ String get keyboardKeyEnd => _delegate.keyboardKeyEnd;
+ @override
+ String get keyboardKeyEscape => _delegate.keyboardKeyEscape;
+ @override
+ String get keyboardKeyFn => _delegate.keyboardKeyFn;
+ @override
+ String get keyboardKeyHome => _delegate.keyboardKeyHome;
+ @override
+ String get keyboardKeyInsert => _delegate.keyboardKeyInsert;
+ @override
+ String get keyboardKeyMeta => _delegate.keyboardKeyMeta;
+ @override
+ String get keyboardKeyMetaMacOs => _delegate.keyboardKeyMetaMacOs;
+ @override
+ String get keyboardKeyMetaWindows => _delegate.keyboardKeyMetaWindows;
+ @override
+ String get keyboardKeyNumLock => _delegate.keyboardKeyNumLock;
+ @override
+ String get keyboardKeyNumpad1 => _delegate.keyboardKeyNumpad1;
+ @override
+ String get keyboardKeyNumpad2 => _delegate.keyboardKeyNumpad2;
+ @override
+ String get keyboardKeyNumpad3 => _delegate.keyboardKeyNumpad3;
+ @override
+ String get keyboardKeyNumpad4 => _delegate.keyboardKeyNumpad4;
+ @override
+ String get keyboardKeyNumpad5 => _delegate.keyboardKeyNumpad5;
+ @override
+ String get keyboardKeyNumpad6 => _delegate.keyboardKeyNumpad6;
+ @override
+ String get keyboardKeyNumpad7 => _delegate.keyboardKeyNumpad7;
+ @override
+ String get keyboardKeyNumpad8 => _delegate.keyboardKeyNumpad8;
+ @override
+ String get keyboardKeyNumpad9 => _delegate.keyboardKeyNumpad9;
+ @override
+ String get keyboardKeyNumpad0 => _delegate.keyboardKeyNumpad0;
+ @override
+ String get keyboardKeyNumpadAdd => _delegate.keyboardKeyNumpadAdd;
+ @override
+ String get keyboardKeyNumpadComma => _delegate.keyboardKeyNumpadComma;
+ @override
+ String get keyboardKeyNumpadDecimal => _delegate.keyboardKeyNumpadDecimal;
+ @override
+ String get keyboardKeyNumpadDivide => _delegate.keyboardKeyNumpadDivide;
+ @override
+ String get keyboardKeyNumpadEnter => _delegate.keyboardKeyNumpadEnter;
+ @override
+ String get keyboardKeyNumpadEqual => _delegate.keyboardKeyNumpadEqual;
+ @override
+ String get keyboardKeyNumpadMultiply => _delegate.keyboardKeyNumpadMultiply;
+ @override
+ String get keyboardKeyNumpadParenLeft => _delegate.keyboardKeyNumpadParenLeft;
+ @override
+ String get keyboardKeyNumpadParenRight =>
+ _delegate.keyboardKeyNumpadParenRight;
+ @override
+ String get keyboardKeyNumpadSubtract => _delegate.keyboardKeyNumpadSubtract;
+ @override
+ String get keyboardKeyPageDown => _delegate.keyboardKeyPageDown;
+ @override
+ String get keyboardKeyPageUp => _delegate.keyboardKeyPageUp;
+ @override
+ String get keyboardKeyPower => _delegate.keyboardKeyPower;
+ @override
+ String get keyboardKeyPowerOff => _delegate.keyboardKeyPowerOff;
+ @override
+ String get keyboardKeyPrintScreen => _delegate.keyboardKeyPrintScreen;
+ @override
+ String get keyboardKeyScrollLock => _delegate.keyboardKeyScrollLock;
+ @override
+ String get keyboardKeySelect => _delegate.keyboardKeySelect;
+ @override
+ String get keyboardKeyShift => _delegate.keyboardKeyShift;
+ @override
+ String get keyboardKeySpace => _delegate.keyboardKeySpace;
+}
diff --git a/lib/src/android/presentation/android_diagnostics_screen.dart b/lib/src/android/presentation/android_diagnostics_screen.dart
new file mode 100644
index 00000000..71b17b5f
--- /dev/null
+++ b/lib/src/android/presentation/android_diagnostics_screen.dart
@@ -0,0 +1,189 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../app/app_bootstrap.dart';
+import '../../core/logging/redacting_logger.dart';
+import '../../db/app_database.dart';
+import '../../l10n/l10n.dart';
+import '../../providers/busy_provider.dart';
+import '../android_background.dart';
+import '../android_notifications.dart';
+
+class AndroidDiagnosticsScreen extends ConsumerWidget {
+ const AndroidDiagnosticsScreen({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final accounts = ref.watch(accountsStreamProvider).valueOrNull ?? const [];
+ final labels = {
+ for (final account in accounts) account.id: account.displayLabel,
+ };
+ final redact = ref
+ .watch(appSettingsControllerProvider)
+ .redactTaskContentInDiagnostics;
+ return Scaffold(
+ appBar: AppBar(title: Text(context.l10n.diagnostics)),
+ body: ListView(
+ padding: const EdgeInsets.fromLTRB(12, 8, 12, 40),
+ children: [
+ Card(
+ child: Column(
+ children: [
+ ListTile(
+ leading: const Icon(Icons.notifications_outlined),
+ title: Text(context.l10n.reminders),
+ subtitle: Text(
+ ref
+ .read(androidNotificationServiceProvider)
+ .precisionDiagnostic,
+ ),
+ ),
+ ListTile(
+ leading: const Icon(Icons.sync),
+ title: Text(context.l10n.sync),
+ subtitle: Text(context.l10n.davCachedOfflineNotice),
+ onTap: () => unawaited(enqueueImmediateBusyMaxSync()),
+ ),
+ ],
+ ),
+ ),
+ for (final account in accounts)
+ Card(
+ child: ListTile(
+ leading: const Icon(Icons.account_circle_outlined),
+ title: Text(account.displayLabel),
+ subtitle: Text(
+ '${account.provider.displayName} · ${account.authState}'
+ '${account.lastSuccessfulSyncAtUtc == null ? '' : '\n${account.lastSuccessfulSyncAtUtc!.toLocal()}'}',
+ ),
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.fromLTRB(4, 20, 4, 8),
+ child: Text(
+ context.l10n.blockedPendingOperations,
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ ),
+ StreamBuilder>(
+ stream: ref
+ .watch(databaseProvider)
+ .pendingOpsDao
+ .watchAllBlockedOps(),
+ builder: (context, snapshot) {
+ final operations = snapshot.data ?? const [];
+ if (operations.isEmpty) {
+ return ListTile(
+ leading: const Icon(Icons.check_circle_outline),
+ title: Text(context.l10n.noBlockedPendingOperations),
+ );
+ }
+ return Column(
+ children: [
+ for (final operation in operations)
+ Card(
+ child: ListTile(
+ leading: const Icon(Icons.report_problem_outlined),
+ title: Text(
+ '${operation.entityType}: ${operation.operation}',
+ ),
+ subtitle: Text(
+ [
+ labels[operation.accountId] ?? operation.accountId,
+ if (operation.taskListId != null)
+ context.l10n.pendingOpListId(
+ operation.taskListId!,
+ ),
+ context.l10n.pendingOpAttempts(
+ operation.attemptCount,
+ ),
+ if (operation.lastErrorCode != null)
+ operation.lastErrorCode!,
+ if (operation.lastErrorMessage != null)
+ redact
+ ? redactForLog(operation.lastErrorMessage)
+ : operation.lastErrorMessage!,
+ ].join('\n'),
+ ),
+ isThreeLine: true,
+ trailing: PopupMenuButton(
+ onSelected: (action) => unawaited(
+ _resolve(context, ref, operation, action),
+ ),
+ itemBuilder: (context) => [
+ PopupMenuItem(
+ value: 'retry',
+ child: Text(context.l10n.retry),
+ ),
+ PopupMenuItem(
+ value: 'discard',
+ child: Text(context.l10n.discard),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ ],
+ ),
+ );
+ }
+
+ Future _resolve(
+ BuildContext context,
+ WidgetRef ref,
+ PendingOp operation,
+ String action,
+ ) async {
+ final service = ref.read(
+ pendingOpResolutionServiceForAccountProvider(operation.accountId),
+ );
+ try {
+ if (action == 'retry') {
+ await service.retryNow(operation.id);
+ } else {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (dialogContext) => AlertDialog(
+ title: Text(context.l10n.discardPendingOperation),
+ content: Text(context.l10n.discardPendingOperationConfirmation),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, false),
+ child: Text(context.l10n.cancel),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.pop(dialogContext, true),
+ child: Text(context.l10n.discard),
+ ),
+ ],
+ ),
+ );
+ if (confirmed != true) return;
+ await service.discard(operation.id);
+ }
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ action == 'retry'
+ ? context.l10n.retryCompleted
+ : context.l10n.pendingOperationDiscarded,
+ ),
+ ),
+ );
+ }
+ } on Object catch (error) {
+ if (context.mounted) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(redactForLog(error))));
+ }
+ }
+ }
+}
diff --git a/lib/src/android/presentation/android_feedback_screen.dart b/lib/src/android/presentation/android_feedback_screen.dart
new file mode 100644
index 00000000..a6b3d951
--- /dev/null
+++ b/lib/src/android/presentation/android_feedback_screen.dart
@@ -0,0 +1,275 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:package_info_plus/package_info_plus.dart';
+import 'package:uuid/uuid.dart';
+
+import '../../app/app_bootstrap.dart';
+import '../../features/feedback/data/feedback_api_client.dart';
+import '../../features/feedback/data/feedback_submission.dart';
+import '../../l10n/l10n.dart';
+
+class AndroidFeedbackScreen extends ConsumerStatefulWidget {
+ const AndroidFeedbackScreen({super.key});
+
+ @override
+ ConsumerState createState() =>
+ _AndroidFeedbackScreenState();
+}
+
+class _AndroidFeedbackScreenState extends ConsumerState {
+ final _subject = TextEditingController();
+ final _message = TextEditingController();
+ final _email = TextEditingController();
+ FeedbackCategory? _category;
+ bool _technical = false;
+ bool _submitting = false;
+ bool _validate = false;
+ String? _status;
+ bool _statusError = false;
+ String _submissionId = const Uuid().v4();
+
+ bool get _dirty =>
+ _category != null ||
+ _subject.text.trim().isNotEmpty ||
+ _message.text.trim().isNotEmpty ||
+ _email.text.trim().isNotEmpty ||
+ _technical;
+
+ @override
+ void dispose() {
+ _subject.dispose();
+ _message.dispose();
+ _email.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) => PopScope(
+ canPop: !_dirty && !_submitting,
+ onPopInvokedWithResult: (didPop, _) async {
+ if (didPop || _submitting) return;
+ final discard = await _confirmDiscard();
+ if (!mounted || !discard) return;
+ Navigator.pop(this.context);
+ },
+ child: Scaffold(
+ appBar: AppBar(
+ title: Text(context.l10n.sendFeedback),
+ actions: [
+ TextButton(
+ onPressed: _submitting ? null : _submit,
+ child: Text(context.l10n.feedbackSubmit),
+ ),
+ ],
+ ),
+ body: ListView(
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 40),
+ children: [
+ DropdownButtonFormField(
+ initialValue: _category,
+ decoration: InputDecoration(
+ labelText: context.l10n.feedbackCategory,
+ errorText:
+ _validate && !FeedbackValidation.categoryIsValid(_category)
+ ? context.l10n.feedbackCategoryRequired
+ : null,
+ ),
+ items: [
+ for (final value in FeedbackCategory.values)
+ DropdownMenuItem(
+ value: value,
+ child: Text(_categoryLabel(context, value)),
+ ),
+ ],
+ onChanged: _submitting
+ ? null
+ : (value) => setState(() {
+ _category = value;
+ _changed();
+ }),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ key: const ValueKey('android-feedback-subject'),
+ controller: _subject,
+ enabled: !_submitting,
+ decoration: InputDecoration(
+ labelText: context.l10n.feedbackSubject,
+ errorText:
+ _validate && !FeedbackValidation.subjectIsValid(_subject.text)
+ ? context.l10n.feedbackSubjectLengthError
+ : null,
+ ),
+ onChanged: (_) => setState(_changed),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ key: const ValueKey('android-feedback-message'),
+ controller: _message,
+ enabled: !_submitting,
+ minLines: 5,
+ maxLines: 10,
+ decoration: InputDecoration(
+ labelText: context.l10n.feedbackDetailedMessage,
+ alignLabelWithHint: true,
+ errorText:
+ _validate && !FeedbackValidation.messageIsValid(_message.text)
+ ? context.l10n.feedbackMessageLengthError
+ : null,
+ ),
+ onChanged: (_) => setState(_changed),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ controller: _email,
+ enabled: !_submitting,
+ keyboardType: TextInputType.emailAddress,
+ decoration: InputDecoration(
+ labelText: context.l10n.feedbackReplyEmail,
+ errorText:
+ _validate &&
+ !FeedbackValidation.replyEmailIsValid(_email.text)
+ ? context.l10n.feedbackInvalidEmail
+ : null,
+ ),
+ onChanged: (_) => setState(_changed),
+ ),
+ const SizedBox(height: 8),
+ SwitchListTile(
+ contentPadding: EdgeInsets.zero,
+ value: _technical,
+ title: Text(context.l10n.feedbackIncludeTechnicalDetails),
+ subtitle: Text(context.l10n.feedbackTechnicalDetailsDisclosure),
+ onChanged: _submitting
+ ? null
+ : (value) => setState(() {
+ _technical = value;
+ _changed();
+ }),
+ ),
+ if (_submitting) const LinearProgressIndicator(),
+ if (_status case final value?)
+ Semantics(
+ liveRegion: true,
+ child: Padding(
+ padding: const EdgeInsets.only(top: 12),
+ child: Text(
+ value,
+ style: TextStyle(
+ color: _statusError
+ ? Theme.of(context).colorScheme.error
+ : Theme.of(context).colorScheme.primary,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+
+ void _changed() {
+ if (_statusError) _submissionId = const Uuid().v4();
+ _status = null;
+ _statusError = false;
+ }
+
+ Future _submit() async {
+ final category = _category;
+ final valid =
+ FeedbackValidation.categoryIsValid(category) &&
+ FeedbackValidation.subjectIsValid(_subject.text) &&
+ FeedbackValidation.messageIsValid(_message.text) &&
+ FeedbackValidation.replyEmailIsValid(_email.text);
+ setState(() => _validate = true);
+ if (!valid || category == null) return;
+ final locale = Localizations.localeOf(context).toLanguageTag();
+ setState(() => _submitting = true);
+ try {
+ final package = await PackageInfo.fromPlatform();
+ final receipt = await ref
+ .read(feedbackSubmissionServiceProvider)
+ .submit(
+ FeedbackSubmission(
+ submissionId: _submissionId,
+ appVersion: package.version.trim().isEmpty
+ ? 'unknown'
+ : package.version,
+ buildNumber: package.buildNumber.trim().isEmpty
+ ? '0'
+ : package.buildNumber,
+ category: category,
+ subject: _subject.text,
+ message: _message.text,
+ replyEmail: _email.text,
+ platform: Platform.operatingSystem,
+ technicalDetails: _technical
+ ? FeedbackTechnicalDetails(
+ osVersion: Platform.operatingSystemVersion,
+ locale: locale,
+ )
+ : null,
+ ),
+ );
+ if (!mounted) return;
+ _subject.clear();
+ _message.clear();
+ _email.clear();
+ setState(() {
+ _category = null;
+ _technical = false;
+ _validate = false;
+ _status = context.l10n.feedbackSuccess(receipt.id);
+ _statusError = false;
+ _submissionId = const Uuid().v4();
+ });
+ } on Object catch (error) {
+ if (!mounted) return;
+ setState(() {
+ _status = switch (error) {
+ FeedbackConnectionFailure() => context.l10n.feedbackConnectionError,
+ FeedbackTimeoutFailure() => context.l10n.feedbackTimeoutError,
+ FeedbackRateLimitedFailure() => context.l10n.feedbackRateLimitedError,
+ FeedbackRejectedFailure() => context.l10n.feedbackRejectedError,
+ FeedbackServerFailure() => context.l10n.feedbackServerError,
+ _ => context.l10n.feedbackServerError,
+ };
+ _statusError = true;
+ });
+ } finally {
+ if (mounted) setState(() => _submitting = false);
+ }
+ }
+
+ Future _confirmDiscard() async =>
+ await showDialog(
+ context: context,
+ builder: (dialogContext) => AlertDialog(
+ title: Text(context.l10n.discardChanges),
+ content: Text(context.l10n.discardChangesConfirmation),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, false),
+ child: Text(context.l10n.cancel),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.pop(dialogContext, true),
+ child: Text(context.l10n.discardChangesAction),
+ ),
+ ],
+ ),
+ ) ??
+ false;
+}
+
+String _categoryLabel(BuildContext context, FeedbackCategory category) =>
+ switch (category) {
+ FeedbackCategory.problem => context.l10n.feedbackCategoryProblem,
+ FeedbackCategory.feature => context.l10n.feedbackCategoryFeature,
+ FeedbackCategory.privacySecurity =>
+ context.l10n.feedbackCategoryPrivacySecurity,
+ FeedbackCategory.usability => context.l10n.feedbackCategoryUsability,
+ FeedbackCategory.other => context.l10n.feedbackCategoryOther,
+ };
diff --git a/lib/src/android/presentation/android_nextcloud_scheduling_screen.dart b/lib/src/android/presentation/android_nextcloud_scheduling_screen.dart
new file mode 100644
index 00000000..41790255
--- /dev/null
+++ b/lib/src/android/presentation/android_nextcloud_scheduling_screen.dart
@@ -0,0 +1,134 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../app/app_bootstrap.dart';
+import '../../dav/dav_errors.dart';
+import '../../dav/nextcloud/nextcloud_scheduling_controller.dart';
+import '../../dav/nextcloud/nextcloud_scheduling_service.dart';
+import '../../l10n/l10n.dart';
+
+class AndroidNextcloudSchedulingScreen extends ConsumerStatefulWidget {
+ const AndroidNextcloudSchedulingScreen({
+ super.key,
+ required this.accountId,
+ required this.collectionId,
+ });
+
+ final String accountId;
+ final String collectionId;
+
+ @override
+ ConsumerState createState() =>
+ _AndroidNextcloudSchedulingScreenState();
+}
+
+class _AndroidNextcloudSchedulingScreenState
+ extends ConsumerState {
+ late final NextcloudSchedulingController _model;
+
+ @override
+ void initState() {
+ super.initState();
+ _model = NextcloudSchedulingController(
+ ref.read(nextcloudSchedulingServiceProvider(widget.accountId)),
+ widget.collectionId,
+ fallbackTimeZone: ref.read(localTimeZoneProvider),
+ )..addListener(_changed);
+ unawaited(_model.load());
+ }
+
+ void _changed() {
+ if (mounted) setState(() {});
+ }
+
+ @override
+ void dispose() {
+ _model.removeListener(_changed);
+ _model.dispose();
+ super.dispose();
+ }
+
+ Future _acknowledge(NextcloudInboxMessage message) async {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (dialogContext) => AlertDialog(
+ title: Text(context.l10n.nextcloudAcknowledge),
+ content: Text(context.l10n.nextcloudAcknowledgeConfirm),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, false),
+ child: Text(context.l10n.cancel),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.pop(dialogContext, true),
+ child: Text(context.l10n.nextcloudAcknowledge),
+ ),
+ ],
+ ),
+ );
+ if (confirmed == true && mounted) await _model.acknowledge(message);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final error = _model.error;
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(context.l10n.nextcloudSchedulingInbox),
+ actions: [
+ IconButton(
+ tooltip: context.l10n.refresh,
+ onPressed: _model.busy ? null : _model.load,
+ icon: const Icon(Icons.refresh),
+ ),
+ ],
+ ),
+ body: RefreshIndicator(
+ onRefresh: _model.load,
+ child: ListView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 40),
+ children: [
+ Text(context.l10n.nextcloudInboxExplanation),
+ if (_model.busy) ...[
+ const SizedBox(height: 16),
+ const LinearProgressIndicator(),
+ ],
+ if (error != null) ...[
+ const SizedBox(height: 16),
+ Text(
+ error is DavException &&
+ error.kind == DavErrorKind.authorization
+ ? context.l10n.nextcloudOperationDenied
+ : context.l10n.nextcloudServerUnavailable,
+ ),
+ ],
+ if (_model.loaded &&
+ !_model.busy &&
+ error == null &&
+ _model.messages.isEmpty)
+ Padding(
+ padding: const EdgeInsets.only(top: 24),
+ child: Text(context.l10n.nextcloudInboxEmpty),
+ ),
+ for (final message in _model.messages)
+ Card(
+ child: ListTile(
+ title: Text(message.title),
+ subtitle: message.method == null
+ ? null
+ : Text(message.method!),
+ trailing: TextButton(
+ onPressed: _model.busy ? null : () => _acknowledge(message),
+ child: Text(context.l10n.nextcloudAcknowledge),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/src/android/presentation/android_schedule_screen.dart b/lib/src/android/presentation/android_schedule_screen.dart
new file mode 100644
index 00000000..8cba5a2a
--- /dev/null
+++ b/lib/src/android/presentation/android_schedule_screen.dart
@@ -0,0 +1,2805 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:busymax_android_platform/busymax_android_platform.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:intl/intl.dart';
+
+import '../../app/app_bootstrap.dart';
+import '../../calendar_providers/calendar_mutation.dart';
+import '../../core/logging/redacting_logger.dart';
+import '../../features/accounts/data/accounts_repository.dart';
+import '../../features/calendar/data/calendar_repository.dart';
+import '../../features/calendar/presentation/event_editor_draft.dart';
+import '../../features/maps/application/external_location_launcher.dart';
+import '../../features/schedule/application/saved_schedule_location.dart';
+import '../../features/tasks/data/tasks_repository.dart';
+import '../../features/recurrence/domain/event_recurrence_codec.dart';
+import '../../features/recurrence/domain/recurrence_rule.dart';
+import '../../l10n/l10n.dart';
+import '../../l10n/time_format_scope.dart';
+import '../../l10n/week_preferences_scope.dart';
+import '../../providers/busy_provider.dart';
+import '../../schedule/schedule_filters.dart';
+import '../../schedule/schedule_item.dart';
+import '../../schedule/schedule_projection.dart';
+import '../../schedule/schedule_range.dart';
+import '../../schedule/schedule_view_mode.dart';
+import '../../schedule/schedule_source_visibility.dart';
+import '../../schedule/schedule_search_criteria.dart';
+import 'android_schedule_search_filters.dart';
+import '../../features/schedule/presentation/schedule_search_result_text.dart';
+import '../android_notifications.dart';
+import 'android_availability_dialog.dart';
+import 'android_date_picker.dart';
+import 'android_settings_screen.dart';
+import 'android_tasks_screen.dart';
+
+final _androidScheduleItemsProvider = FutureProvider.autoDispose
+ .family<
+ List,
+ ({
+ DateTime anchor,
+ ScheduleViewMode mode,
+ String query,
+ int firstWeekday,
+ ScheduleSearchCriteria? search,
+ })
+ >((ref, key) async {
+ ref.watch(scheduleDataRevisionProvider);
+ final sources = await ref.watch(calendarSourcesStreamProvider.future);
+ final lists = await ref.watch(scheduleTaskListsProvider.future);
+ final settings = ref.watch(appSettingsControllerProvider);
+ return ref
+ .watch(scheduleRepositoryProvider)
+ .listItems(
+ range:
+ key.search?.range ??
+ _rangeFor(key.anchor, key.mode, firstWeekday: key.firstWeekday),
+ filters:
+ key.search?.filters(key.query) ??
+ ScheduleFilters(
+ query: key.query,
+ sourceIds: {
+ for (final source in sources)
+ if (source.selected) source.id,
+ },
+ taskListKeys: {
+ for (final list in lists)
+ if (settings.isTaskListVisibleInSchedule(
+ list.accountId,
+ list.id,
+ ))
+ ScheduleTaskListKey(
+ accountId: list.accountId,
+ taskListId: list.id,
+ ),
+ },
+ sourceFilterActive: true,
+ taskListFilterActive: true,
+ taskCompletion: ScheduleTaskCompletion.all,
+ showNoDateTasks: key.mode == ScheduleViewMode.agenda,
+ ),
+ );
+ });
+
+class AndroidScheduleScreen extends ConsumerStatefulWidget {
+ const AndroidScheduleScreen({super.key});
+
+ @override
+ ConsumerState createState() =>
+ _AndroidScheduleScreenState();
+}
+
+class _AndroidScheduleScreenState extends ConsumerState {
+ DateTime _anchor = DateTime.now();
+ bool _searching = false;
+ String _query = '';
+ final _searchController = TextEditingController();
+ ScheduleSearchCriteria? _searchCriteria;
+ ScheduleSearchCriteria? _initialSearchCriteria;
+ var _searchFirstWeekday = DateTime.monday;
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ final firstWeekday = _firstWeekday(context);
+ if (_searchFirstWeekday == firstWeekday) return;
+ _searchFirstWeekday = firstWeekday;
+ _searchCriteria = _searchCriteria?.copyWith(firstWeekday: firstWeekday);
+ _initialSearchCriteria = _initialSearchCriteria?.copyWith(
+ firstWeekday: firstWeekday,
+ );
+ }
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final settings = ref.watch(appSettingsControllerProvider);
+ final mode = settings.androidScheduleViewMode ?? ScheduleViewMode.agenda;
+ final items = ref.watch(
+ _androidScheduleItemsProvider((
+ anchor: DateTime(_anchor.year, _anchor.month, _anchor.day),
+ mode: mode,
+ query: _query,
+ search: _searching ? _searchCriteria : null,
+ firstWeekday: _firstWeekday(context),
+ )),
+ );
+ return Scaffold(
+ appBar: AppBar(
+ title: _searching
+ ? TextField(
+ controller: _searchController,
+ autofocus: true,
+ decoration: InputDecoration(
+ hintText: context.l10n.windowsSearch,
+ border: InputBorder.none,
+ suffixIcon: IconButton(
+ tooltip: MaterialLocalizations.of(
+ context,
+ ).clearButtonTooltip,
+ icon: const Icon(Icons.clear),
+ onPressed: () {
+ _searchController.clear();
+ setState(() => _query = '');
+ },
+ ),
+ ),
+ onChanged: (value) => setState(() => _query = value),
+ )
+ : InkWell(
+ borderRadius: BorderRadius.circular(12),
+ onTap: _selectDate,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(minHeight: 48),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 4),
+ child: Align(
+ alignment: AlignmentDirectional.centerStart,
+ child: Text(_periodLabel(context, _anchor, mode)),
+ ),
+ ),
+ ),
+ ),
+ actions: [
+ IconButton(
+ tooltip: context.l10n.windowsSearch,
+ onPressed: _searching ? _closeSearch : _openSearch,
+ icon: Icon(_searching ? Icons.close : Icons.search),
+ ),
+ IconButton(
+ tooltip: _searching
+ ? context.l10n.searchFiltersAction
+ : context.l10n.collectionSettings,
+ onPressed: () =>
+ _searching ? _showSearchFilters() : _showSources(context),
+ icon: const Icon(Icons.tune),
+ ),
+ if (!_searching)
+ PopupMenuButton(
+ tooltip: context.l10n.scheduleDisplaySettings,
+ initialValue: mode,
+ onSelected: ref
+ .read(appSettingsControllerProvider.notifier)
+ .setAndroidScheduleViewMode,
+ itemBuilder: (context) => [
+ for (final candidate in ScheduleViewMode.values)
+ PopupMenuItem(
+ value: candidate,
+ child: Text(_modeLabel(context, candidate)),
+ ),
+ ],
+ ),
+ ],
+ bottom: _searching
+ ? null
+ : PreferredSize(
+ preferredSize: const Size.fromHeight(48),
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
+ child: Row(
+ children: [
+ IconButton(
+ tooltip: context.l10n.shortcutPreviousPeriod,
+ onPressed: () =>
+ setState(() => _anchor = _move(_anchor, mode, -1)),
+ icon: const Icon(Icons.chevron_left),
+ ),
+ FilledButton.tonal(
+ onPressed: () =>
+ setState(() => _anchor = DateTime.now()),
+ child: Text(context.l10n.today),
+ ),
+ IconButton(
+ tooltip: context.l10n.shortcutNextPeriod,
+ onPressed: () =>
+ setState(() => _anchor = _move(_anchor, mode, 1)),
+ icon: const Icon(Icons.chevron_right),
+ ),
+ const Spacer(),
+ Flexible(
+ child: Text(
+ _modeLabel(context, mode),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ body: items.when(
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (error, _) => _Message(
+ icon: Icons.error_outline,
+ title: context.l10n.scheduleUnavailable,
+ detail: '$error',
+ ),
+ data: (value) =>
+ value.isEmpty && (_searching || mode == ScheduleViewMode.agenda)
+ ? _Message(
+ icon: !_searching ? Icons.event_busy : Icons.search_off,
+ title: !_searching
+ ? context.l10n.noEventsOrTasks
+ : _searchCriteria?.hasSources == false
+ ? context.l10n.searchNoSources
+ : context.l10n.scheduleNoSearchResults,
+ detail: !_searching
+ ? context.l10n.noEventsOrTasks
+ : context.l10n.scheduleNoSearchResultsDescription,
+ )
+ : _searching
+ ? _AgendaList(
+ key: const ValueKey('android-search-results'),
+ items: value,
+ onOpen: (item) => _showItem(context, item),
+ onToggleTask: _toggleTask,
+ searchCriteria: _searchCriteria,
+ searchQuery: _query,
+ )
+ : _ScheduleBody(
+ anchor: _anchor,
+ mode: mode,
+ items: value,
+ onSelectDate: (date) => setState(() => _anchor = date),
+ onModeChanged: (value) => unawaited(
+ ref
+ .read(appSettingsControllerProvider.notifier)
+ .setAndroidScheduleViewMode(value),
+ ),
+ onOpen: (item) => _showItem(context, item),
+ onToggleTask: _toggleTask,
+ ),
+ ),
+ floatingActionButton: FloatingActionButton(
+ heroTag: 'android-schedule-add',
+ onPressed: () => _createItem(context),
+ tooltip: context.l10n.create,
+ child: const Icon(Icons.add),
+ ),
+ );
+ }
+
+ Future _openSearch() async {
+ final sources = await ref.read(calendarSourcesStreamProvider.future);
+ final lists = await ref.read(scheduleTaskListsProvider.future);
+ if (!mounted) return;
+ final visibility = ScheduleSourceVisibility.fromSources(
+ calendarSources: sources,
+ taskLists: lists,
+ settings: ref.read(appSettingsControllerProvider),
+ );
+ final now = DateTime.now();
+ setState(() {
+ _initialSearchCriteria = ScheduleSearchCriteria(
+ referenceDate: DateTime(now.year, now.month, now.day),
+ firstWeekday: _firstWeekday(context),
+ sourceIds: visibility.visibleCalendarSourceIds,
+ taskListKeys: visibility.visibleTaskListKeys,
+ );
+ _searchCriteria = _initialSearchCriteria;
+ _searching = true;
+ });
+ }
+
+ void _closeSearch() {
+ setState(() {
+ _searching = false;
+ _searchCriteria = null;
+ _initialSearchCriteria = null;
+ _query = '';
+ _searchController.clear();
+ });
+ }
+
+ Future _showSearchFilters() async {
+ if (_searchCriteria == null) return;
+ await showModalBottomSheet(
+ context: context,
+ showDragHandle: true,
+ isScrollControlled: true,
+ builder: (context) => StatefulBuilder(
+ builder: (context, update) {
+ final firstWeekday = _firstWeekday(context);
+ return SafeArea(
+ child: Padding(
+ padding: EdgeInsetsDirectional.only(
+ bottom: MediaQuery.viewInsetsOf(context).bottom,
+ ),
+ child: SizedBox(
+ height: MediaQuery.sizeOf(context).height * .8,
+ child: AndroidScheduleSearchFilters(
+ value: _searchCriteria!.copyWith(firstWeekday: firstWeekday),
+ accounts:
+ ref.read(accountsStreamProvider).valueOrNull ?? const [],
+ sources:
+ ref.read(calendarSourcesStreamProvider).valueOrNull ??
+ const [],
+ taskLists:
+ ref.read(scheduleTaskListsProvider).valueOrNull ??
+ const [],
+ onChanged: (value) {
+ setState(
+ () => _searchCriteria = value.copyWith(
+ firstWeekday: _firstWeekday(context),
+ ),
+ );
+ update(() {});
+ },
+ onClear: () {
+ setState(
+ () => _searchCriteria = _initialSearchCriteria?.copyWith(
+ firstWeekday: _firstWeekday(context),
+ ),
+ );
+ update(() {});
+ },
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ Future _showSources(BuildContext context) async {
+ await showModalBottomSheet(
+ context: context,
+ showDragHandle: true,
+ isScrollControlled: true,
+ builder: (context) => Consumer(
+ builder: (context, ref, _) {
+ final sources = ref.watch(calendarSourcesStreamProvider);
+ final lists = ref.watch(scheduleTaskListsProvider);
+ final accounts =
+ ref.watch(accountsStreamProvider).valueOrNull ??
+ const [];
+ final accountLabels = {
+ for (final account in accounts) account.id: account.displayLabel,
+ };
+ final settings = ref.watch(appSettingsControllerProvider);
+ return SafeArea(
+ child: SizedBox(
+ height: MediaQuery.sizeOf(context).height * .72,
+ child: ListView(
+ padding: const EdgeInsets.only(bottom: 24),
+ children: [
+ ListTile(
+ title: Text(context.l10n.collectionSettings),
+ titleTextStyle: Theme.of(context).textTheme.titleLarge,
+ ),
+ ...(sources.valueOrNull ?? const []).map(
+ (source) => CheckboxListTile(
+ secondary: const Icon(Icons.calendar_month),
+ title: Text(source.summary),
+ subtitle: Text(
+ '${source.provider.displayName} · '
+ '${source.authenticatedAccountEmail ?? source.accountId}',
+ ),
+ value: source.selected,
+ onChanged: (selected) => ref
+ .read(calendarRepositoryProvider)
+ .setSourceSelected(source.id, selected == true),
+ ),
+ ),
+ ...(lists.valueOrNull ?? const []).map(
+ (list) => CheckboxListTile(
+ secondary: const Icon(Icons.checklist),
+ title: Text(list.title),
+ subtitle: Text(
+ accountLabels[list.accountId] ?? list.accountId,
+ ),
+ value: settings.isTaskListVisibleInSchedule(
+ list.accountId,
+ list.id,
+ ),
+ onChanged: (visible) => ref
+ .read(appSettingsControllerProvider.notifier)
+ .setTaskListVisibleInSchedule(
+ accountId: list.accountId,
+ taskListId: list.id,
+ visible: visible == true,
+ ),
+ ),
+ ),
+ const Divider(),
+ ListTile(
+ leading: const Icon(Icons.settings_outlined),
+ title: Text(context.l10n.collectionSettings),
+ onTap: () {
+ Navigator.pop(context);
+ Navigator.of(this.context).push(
+ MaterialPageRoute(
+ builder: (_) => const AndroidSettingsScreen(),
+ ),
+ );
+ },
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ Future _selectDate() async {
+ final selected = await showBusyMaxDatePicker(
+ context: context,
+ initialDate: _anchor,
+ firstDate: DateTime(1900),
+ lastDate: DateTime(2200),
+ );
+ if (selected != null && mounted) setState(() => _anchor = selected);
+ }
+
+ Future _createItem(BuildContext context) async {
+ final type = await showModalBottomSheet(
+ context: context,
+ showDragHandle: true,
+ builder: (context) => SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ListTile(
+ leading: const Icon(Icons.event_outlined),
+ title: Text(context.l10n.newEvent),
+ onTap: () => Navigator.pop(context, 'event'),
+ ),
+ ListTile(
+ leading: const Icon(Icons.check_circle_outline),
+ title: Text(context.l10n.newTask),
+ onTap: () => Navigator.pop(context, 'task'),
+ ),
+ ],
+ ),
+ ),
+ );
+ if (!context.mounted || type == null) return;
+ if (type == 'event') {
+ await _createEvent(context);
+ return;
+ }
+ final lists = ref.read(scheduleTaskListsProvider).valueOrNull ?? const [];
+ if (lists.isEmpty) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(context.l10n.noTaskListsSynced)));
+ return;
+ }
+ final accounts =
+ ref.read(accountsStreamProvider).valueOrNull ?? const [];
+ final list = await selectAndroidTaskList(
+ context,
+ lists,
+ title: context.l10n.newTask,
+ accountLabels: {
+ for (final account in accounts) account.id: account.displayLabel,
+ },
+ );
+ if (list == null || !context.mounted) return;
+ final account = accounts
+ .where((value) => value.id == list.accountId)
+ .firstOrNull;
+ if (account == null) return;
+ await showAndroidTaskEditor(
+ context,
+ ref,
+ creationList: list,
+ creationProvider: account.provider,
+ initialDue: DateTime(_anchor.year, _anchor.month, _anchor.day),
+ );
+ }
+
+ Future _showItem(BuildContext context, ScheduleItem item) async {
+ await showModalBottomSheet(
+ context: context,
+ showDragHandle: true,
+ isScrollControlled: true,
+ builder: (sheetContext) => SafeArea(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ maxHeight: MediaQuery.sizeOf(sheetContext).height * .82,
+ ),
+ child: SingleChildScrollView(
+ key: const ValueKey('android-schedule-details-scroll'),
+ padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ item.title,
+ style: Theme.of(context).textTheme.headlineSmall,
+ ),
+ const SizedBox(height: 8),
+ Text(item.sourceName ?? item.provider.displayName),
+ if (item.start != null)
+ Text(
+ formatClockDateTime(
+ context,
+ item.start!,
+ DateFormat.yMMMd().format(item.start!),
+ ),
+ ),
+ if (item case CalendarScheduleItem(
+ :final location?,
+ ) when location.isNotEmpty)
+ ListTile(
+ contentPadding: EdgeInsets.zero,
+ leading: const Icon(Icons.place),
+ title: Text(location),
+ trailing: const Icon(Icons.open_in_new),
+ onTap: () => unawaited(
+ const ExternalLocationLauncher(
+ platform: _androidLocationPlatform,
+ ).open(projectedScheduleItemLocationDestination(item)),
+ ),
+ ),
+ if (item case TaskScheduleItem(
+ :final notes?,
+ ) when notes.isNotEmpty)
+ Padding(
+ padding: const EdgeInsets.only(top: 12),
+ child: Text(notes),
+ ),
+ if (item is CalendarScheduleItem &&
+ item.canRespondToInvitation) ...[
+ const SizedBox(height: 16),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ FilledButton.tonal(
+ onPressed: () {
+ Navigator.pop(sheetContext);
+ unawaited(
+ _respondToInvitation(
+ item,
+ CalendarInvitationResponse.accept,
+ ),
+ );
+ },
+ child: Text(context.l10n.acceptInvitation),
+ ),
+ OutlinedButton(
+ onPressed: () {
+ Navigator.pop(sheetContext);
+ unawaited(
+ _respondToInvitation(
+ item,
+ CalendarInvitationResponse.tentative,
+ ),
+ );
+ },
+ child: Text(context.l10n.tentativeInvitation),
+ ),
+ OutlinedButton(
+ onPressed: () {
+ Navigator.pop(sheetContext);
+ unawaited(
+ _respondToInvitation(
+ item,
+ CalendarInvitationResponse.decline,
+ ),
+ );
+ },
+ child: Text(context.l10n.declineInvitation),
+ ),
+ ],
+ ),
+ ],
+ const SizedBox(height: 16),
+ if (item.capabilities.canEdit)
+ FilledButton.icon(
+ onPressed: () async {
+ Navigator.pop(sheetContext);
+ if (item is CalendarScheduleItem) {
+ await _editEvent(context, item);
+ } else if (item is TaskScheduleItem) {
+ await showAndroidTaskEditor(context, ref, task: item);
+ }
+ },
+ icon: const Icon(Icons.edit),
+ label: Text(
+ item is CalendarScheduleItem
+ ? context.l10n.editEvent
+ : context.l10n.editTask,
+ ),
+ ),
+ if (item.provider == BusyProvider.nextcloud) ...[
+ const SizedBox(height: 8),
+ OutlinedButton.icon(
+ onPressed: () => _exportItem(sheetContext, item),
+ icon: const Icon(Icons.file_download_outlined),
+ label: Text(context.l10n.export),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ Future _exportItem(BuildContext context, ScheduleItem item) async {
+ try {
+ final raw = item is CalendarScheduleItem
+ ? await ref
+ .read(calendarRepositoryProvider)
+ .nativeEventExport(item.id)
+ : item is TaskScheduleItem
+ ? await ref
+ .read(tasksRepositoryForAccountProvider(item.accountId))
+ .nativeTaskExport(item.sourceId, item.id)
+ : null;
+ if (raw == null) return;
+ final uri = await BusyMaxAndroidPlatform.instance.createDocument(
+ suggestedName: item is TaskScheduleItem ? 'task.ics' : 'event.ics',
+ mimeType: 'text/calendar',
+ bytes: Uint8List.fromList(utf8.encode(raw)),
+ );
+ if (context.mounted && uri != null) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(context.l10n.exportedFile(uri))));
+ }
+ } on Object catch (error) {
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(context.l10n.exportFailed('$error'))),
+ );
+ }
+ }
+ }
+
+ Future _createEvent(BuildContext context) async {
+ final sources =
+ (ref.read(calendarSourcesStreamProvider).valueOrNull ??
+ const [])
+ .where((source) => source.capabilities.canCreateEvents)
+ .toList();
+ if (sources.isEmpty) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(context.l10n.noWritableCalendars)));
+ return;
+ }
+ final source = sources.length == 1
+ ? sources.single
+ : await showModalBottomSheet(
+ context: context,
+ showDragHandle: true,
+ builder: (sheetContext) => SafeArea(
+ child: ListView(
+ shrinkWrap: true,
+ children: [
+ ListTile(title: Text(context.l10n.newEvent)),
+ for (final candidate in sources)
+ ListTile(
+ leading: const Icon(Icons.calendar_today_outlined),
+ title: Text(candidate.summary),
+ subtitle: Text(
+ '${candidate.provider.displayName} · '
+ '${candidate.authenticatedAccountEmail ?? candidate.accountId}',
+ ),
+ onTap: () => Navigator.pop(sheetContext, candidate),
+ ),
+ ],
+ ),
+ ),
+ );
+ if (source == null || !context.mounted) return;
+ final start = DateTime(_anchor.year, _anchor.month, _anchor.day, 9);
+ await Navigator.of(context).push(
+ MaterialPageRoute(
+ fullscreenDialog: true,
+ builder: (_) => AndroidEventEditor(
+ sources: sources,
+ draft: EventEditorDraft.newEvent(
+ accountId: source.accountId,
+ sourceId: source.id,
+ providerCalendarId: source.providerCalendarId,
+ start: start,
+ end: start.add(const Duration(hours: 1)),
+ ),
+ ),
+ ),
+ );
+ }
+
+ Future _editEvent(
+ BuildContext context,
+ CalendarScheduleItem item,
+ ) async {
+ await showAndroidEventEditor(context, ref, eventId: item.id);
+ }
+
+ Future _respondToInvitation(
+ CalendarScheduleItem item,
+ CalendarInvitationResponse response,
+ ) async {
+ if (!item.canRespondToInvitation) return;
+ try {
+ RecurringEventMutationScope? scope;
+ if (item.provider == BusyProvider.nextcloud &&
+ item.providerRecurringEventId != null) {
+ scope = await showModalBottomSheet(
+ context: context,
+ showDragHandle: true,
+ builder: (sheetContext) => SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ListTile(title: Text(context.l10n.chooseRecurringEventScope)),
+ for (final value in RecurringEventMutationScope.values)
+ if (value != RecurringEventMutationScope.thisAndFuture)
+ ListTile(
+ title: Text(_scopeLabel(context, value)),
+ onTap: () => Navigator.pop(sheetContext, value),
+ ),
+ ],
+ ),
+ ),
+ );
+ if (scope == null || !mounted) return;
+ }
+ final accountId = await ref
+ .read(calendarRepositoryProvider)
+ .respondToLocalEvent(item.id, response, recurringScope: scope);
+ ref
+ .read(
+ pendingCalendarMutationSyncRequesterForAccountProvider(accountId),
+ )
+ .request();
+ } on Object catch (error) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ context.l10n.invitationResponseFailed(redactForLog(error)),
+ ),
+ ),
+ );
+ }
+ }
+
+ Future _toggleTask(TaskScheduleItem task) async {
+ if (!task.capabilities.canEdit) return;
+ try {
+ await ref
+ .read(tasksRepositoryForAccountProvider(task.accountId))
+ .patchTask(
+ task.sourceId,
+ task.id,
+ TaskPatchInput({
+ 'status': task.completed ? 'needsAction' : 'completed',
+ }),
+ );
+ } on Object catch (error) {
+ if (mounted) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('$error')));
+ }
+ }
+ }
+}
+
+Future showAndroidEventEditor(
+ BuildContext context,
+ WidgetRef ref, {
+ required String eventId,
+}) async {
+ final detail = await ref
+ .read(calendarRepositoryProvider)
+ .loadEventDetail(eventId);
+ if (!context.mounted || detail == null) return;
+ final sources = await ref.read(calendarSourcesStreamProvider.future);
+ if (!context.mounted) return;
+ await Navigator.of(context).push(
+ MaterialPageRoute(
+ fullscreenDialog: true,
+ builder: (_) => AndroidEventEditor(
+ sources: sources,
+ draft: EventEditorDraft.fromEventDetail(detail),
+ ),
+ ),
+ );
+}
+
+ExternalLocationPlatform _androidLocationPlatform() =>
+ ExternalLocationPlatform.android;
+
+class _ScheduleBody extends StatelessWidget {
+ const _ScheduleBody({
+ required this.anchor,
+ required this.mode,
+ required this.items,
+ required this.onSelectDate,
+ required this.onModeChanged,
+ required this.onOpen,
+ required this.onToggleTask,
+ });
+ final DateTime anchor;
+ final ScheduleViewMode mode;
+ final List items;
+ final ValueChanged onSelectDate;
+ final ValueChanged onModeChanged;
+ final ValueChanged onOpen;
+ final ValueChanged onToggleTask;
+
+ @override
+ Widget build(BuildContext context) => switch (mode) {
+ ScheduleViewMode.month => _MonthView(
+ anchor: anchor,
+ items: items,
+ onSelectDate: onSelectDate,
+ onOpen: onOpen,
+ onToggleTask: onToggleTask,
+ ),
+ ScheduleViewMode.year => _YearView(
+ anchor: anchor,
+ items: items,
+ onSelectDate: onSelectDate,
+ onModeChanged: onModeChanged,
+ ),
+ ScheduleViewMode.week || ScheduleViewMode.day => _AndroidTimeGrid(
+ key: ValueKey('android-${mode.name}-time-grid'),
+ anchor: anchor,
+ days: mode == ScheduleViewMode.day ? 1 : 7,
+ firstWeekday: _firstWeekday(context),
+ items: items,
+ onOpen: onOpen,
+ ),
+ ScheduleViewMode.agenda => _AgendaList(
+ items: items,
+ onOpen: onOpen,
+ onToggleTask: onToggleTask,
+ ),
+ };
+}
+
+class _AgendaList extends StatelessWidget {
+ const _AgendaList({
+ super.key,
+ this.searchCriteria,
+ this.searchQuery = '',
+ required this.items,
+ required this.onOpen,
+ required this.onToggleTask,
+ });
+ final ScheduleSearchCriteria? searchCriteria;
+ final String searchQuery;
+ final List items;
+ final ValueChanged onOpen;
+ final ValueChanged onToggleTask;
+ @override
+ Widget build(BuildContext context) {
+ if (items.isEmpty) {
+ return Center(child: Text(context.l10n.noEventsOrTasks));
+ }
+ final agendaItems = searchCriteria == null
+ ? items
+ : (List.of(items)
+ ..sort(compareScheduleSearchResultPresentation));
+ return ListView.separated(
+ padding: const EdgeInsets.fromLTRB(12, 8, 12, 96),
+ itemCount: agendaItems.length,
+ separatorBuilder: (_, _) => const SizedBox(height: 6),
+ itemBuilder: (context, index) {
+ final item = agendaItems[index];
+ final displayDate = searchCriteria == null
+ ? item.start
+ : scheduleSearchResultDisplayDate(item);
+ final time = item.start == null
+ ? context.l10n.noDate
+ : item.allDay
+ ? context.l10n.allDay
+ : BusyMaxTimeFormatScope.of(context).format(item.start!);
+ final isToday =
+ displayDate != null && _sameDay(displayDate, DateTime.now());
+ final previousDisplayDate = index == 0
+ ? null
+ : searchCriteria == null
+ ? agendaItems[index - 1].start
+ : scheduleSearchResultDisplayDate(agendaItems[index - 1]);
+ return Card(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ if (index == 0 ||
+ (displayDate == null && previousDisplayDate != null) ||
+ (displayDate != null &&
+ (previousDisplayDate == null ||
+ !_sameDay(displayDate, previousDisplayDate))))
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
+ child: Text(
+ displayDate == null
+ ? context.l10n.noDate
+ : '${isToday ? '${context.l10n.today} · ' : ''}'
+ '${DateFormat.yMMMMEEEEd().format(displayDate)}',
+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
+ color: isToday
+ ? Theme.of(context).colorScheme.primary
+ : null,
+ ),
+ ),
+ ),
+ ListTile(
+ minVerticalPadding: 12,
+ leading: item is TaskScheduleItem
+ ? IconButton(
+ tooltip: item.completed
+ ? context.l10n.taskStatusCompleted
+ : context.l10n.taskStatusInProcess,
+ onPressed: item.capabilities.canEdit
+ ? () => onToggleTask(item)
+ : null,
+ icon: Icon(
+ item.completed
+ ? Icons.check_circle
+ : Icons.radio_button_unchecked,
+ ),
+ )
+ : const Icon(Icons.event),
+ title: Text(
+ item.title,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ subtitle: Text(
+ searchCriteria != null
+ ? scheduleSearchResultText(
+ context,
+ item,
+ searchCriteria!,
+ searchQuery,
+ )
+ : '$time · ${item.sourceName ?? item.provider.displayName}',
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ onTap: () => onOpen(item),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
+
+class _AndroidTimeGrid extends StatelessWidget {
+ const _AndroidTimeGrid({
+ super.key,
+ required this.anchor,
+ required this.days,
+ required this.firstWeekday,
+ required this.items,
+ required this.onOpen,
+ });
+
+ static const _axisWidth = 54.0;
+ static const _minuteHeight = .72;
+
+ final DateTime anchor;
+ final int days;
+ final int firstWeekday;
+ final List items;
+ final ValueChanged onOpen;
+
+ @override
+ Widget build(BuildContext context) {
+ final start = days == 1
+ ? DateTime(anchor.year, anchor.month, anchor.day)
+ : ScheduleRange.week(anchor, firstWeekday: firstWeekday).start;
+ final dates = [
+ for (var index = 0; index < days; index++)
+ DateTime(start.year, start.month, start.day + index),
+ ];
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final visibleColumns = constraints.maxWidth >= 700
+ ? days
+ : (days < 3 ? days : 3);
+ final availableWidth = constraints.maxWidth - _axisWidth;
+ final columnWidth = days == 1
+ ? availableWidth.clamp(112.0, double.infinity).toDouble()
+ : (availableWidth / visibleColumns.clamp(1, 7))
+ .clamp(112.0, 220.0)
+ .toDouble();
+ final contentWidth = _axisWidth + columnWidth * days;
+ final compactHeight = constraints.maxHeight < 240;
+ final dateHeaderHeight = compactHeight ? 36.0 : 48.0;
+ final preferredAllDayHeight =
+ (MediaQuery.textScalerOf(context).scale(28) + 70)
+ .clamp(68.0, 148.0)
+ .toDouble();
+ final maximumAllDayHeight =
+ (constraints.maxHeight - dateHeaderHeight - 32)
+ .clamp(0.0, double.infinity)
+ .toDouble();
+ final allDayHeight = preferredAllDayHeight
+ .clamp(0.0, maximumAllDayHeight)
+ .toDouble();
+ return SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: SizedBox(
+ width: contentWidth,
+ height: constraints.maxHeight,
+ child: Column(
+ children: [
+ SizedBox(
+ height: dateHeaderHeight,
+ child: Row(
+ children: [
+ const SizedBox(width: _axisWidth),
+ for (final day in dates)
+ SizedBox(
+ width: columnWidth,
+ child: Center(
+ child: Text(
+ DateFormat.E().add_d().format(day),
+ style: Theme.of(context).textTheme.titleSmall,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ SizedBox(
+ height: allDayHeight,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ SizedBox(
+ width: _axisWidth,
+ child: Center(
+ child: Text(
+ context.l10n.allDay,
+ style: Theme.of(context).textTheme.labelSmall,
+ ),
+ ),
+ ),
+ for (final day in dates)
+ SizedBox(
+ width: columnWidth,
+ child: _AndroidAllDayCell(
+ items: ScheduleProjection.itemsForDay(
+ items,
+ day,
+ ).where((item) => item.allDay).toList(),
+ onOpen: onOpen,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const Divider(height: 1),
+ Expanded(
+ child: SingleChildScrollView(
+ child: SizedBox(
+ height: 24 * 60 * _minuteHeight,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ SizedBox(
+ width: _axisWidth,
+ child: Stack(
+ children: [
+ for (var hour = 0; hour < 24; hour++)
+ PositionedDirectional(
+ top: hour * 60 * _minuteHeight - 8,
+ end: 6,
+ child: Text(
+ BusyMaxTimeFormatScope.of(
+ context,
+ ).format(DateTime(2026, 1, 1, hour)),
+ style: Theme.of(
+ context,
+ ).textTheme.labelSmall,
+ ),
+ ),
+ ],
+ ),
+ ),
+ for (final day in dates)
+ SizedBox(
+ width: columnWidth,
+ child: _AndroidTimedDayColumn(
+ day: day,
+ items: ScheduleProjection.itemsForDay(
+ items,
+ day,
+ ).where((item) => !item.allDay).toList(),
+ minuteHeight: _minuteHeight,
+ onOpen: onOpen,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
+
+class _AndroidAllDayCell extends StatelessWidget {
+ const _AndroidAllDayCell({required this.items, required this.onOpen});
+
+ final List items;
+ final ValueChanged onOpen;
+
+ @override
+ Widget build(BuildContext context) => Container(
+ padding: const EdgeInsets.all(3),
+ decoration: BoxDecoration(
+ border: BorderDirectional(
+ start: BorderSide(color: Theme.of(context).dividerColor, width: .5),
+ ),
+ ),
+ child: items.isEmpty
+ ? null
+ : SingleChildScrollView(
+ primary: false,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _AndroidCalendarChip(item: items.first, onOpen: onOpen),
+ if (items.length > 1)
+ Align(
+ alignment: AlignmentDirectional.centerEnd,
+ child: TextButton(
+ style: TextButton.styleFrom(
+ minimumSize: const Size(48, 32),
+ padding: const EdgeInsets.symmetric(horizontal: 4),
+ textStyle: Theme.of(context).textTheme.labelSmall,
+ ),
+ onPressed: () => _showAllDayItems(context),
+ child: Text(context.l10n.moreItems(items.length - 1)),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+
+ Future _showAllDayItems(BuildContext context) =>
+ showModalBottomSheet(
+ context: context,
+ showDragHandle: true,
+ isScrollControlled: true,
+ builder: (sheetContext) => SafeArea(
+ child: FractionallySizedBox(
+ heightFactor: .62,
+ child: ListView.builder(
+ padding: const EdgeInsets.fromLTRB(12, 0, 12, 24),
+ itemCount: items.length,
+ itemBuilder: (context, index) {
+ final item = items[index];
+ return ListTile(
+ leading: Icon(
+ item is TaskScheduleItem
+ ? Icons.check_circle_outline
+ : Icons.event_outlined,
+ ),
+ title: Text(item.title),
+ subtitle: Text(item.sourceName ?? item.provider.displayName),
+ onTap: () {
+ Navigator.pop(sheetContext);
+ onOpen(item);
+ },
+ );
+ },
+ ),
+ ),
+ ),
+ );
+}
+
+class _AndroidTimedDayColumn extends StatelessWidget {
+ const _AndroidTimedDayColumn({
+ required this.day,
+ required this.items,
+ required this.minuteHeight,
+ required this.onOpen,
+ });
+
+ final DateTime day;
+ final List items;
+ final double minuteHeight;
+ final ValueChanged onOpen;
+
+ @override
+ Widget build(BuildContext context) {
+ final placements = _timedPlacements(items, day);
+ final laneCount = placements.fold(
+ 1,
+ (maximum, placement) =>
+ placement.lane + 1 > maximum ? placement.lane + 1 : maximum,
+ );
+ final now = DateTime.now();
+ return LayoutBuilder(
+ builder: (context, constraints) => DecoratedBox(
+ decoration: BoxDecoration(
+ border: BorderDirectional(
+ start: BorderSide(color: Theme.of(context).dividerColor, width: .5),
+ ),
+ ),
+ child: Stack(
+ clipBehavior: Clip.hardEdge,
+ children: [
+ for (var hour = 0; hour < 24; hour++)
+ Positioned(
+ top: hour * 60 * minuteHeight,
+ left: 0,
+ right: 0,
+ child: Divider(
+ height: 1,
+ color: Theme.of(context).dividerColor.withValues(alpha: .55),
+ ),
+ ),
+ for (final placement in placements)
+ Positioned(
+ top: placement.startMinute * minuteHeight + 1,
+ left: placement.lane * constraints.maxWidth / laneCount + 2,
+ width: constraints.maxWidth / laneCount - 4,
+ height:
+ ((placement.endMinute - placement.startMinute) *
+ minuteHeight)
+ .clamp(28.0, 24 * 60 * minuteHeight)
+ .toDouble(),
+ child: _AndroidCalendarChip(
+ item: placement.item,
+ onOpen: onOpen,
+ showTime: true,
+ ),
+ ),
+ if (_sameDay(now, day))
+ Positioned(
+ top: (now.hour * 60 + now.minute) * minuteHeight,
+ left: 0,
+ right: 0,
+ child: Container(
+ height: 2,
+ color: Theme.of(context).colorScheme.error,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _AndroidCalendarChip extends StatelessWidget {
+ const _AndroidCalendarChip({
+ required this.item,
+ required this.onOpen,
+ this.showTime = false,
+ });
+
+ final ScheduleItem item;
+ final ValueChanged onOpen;
+ final bool showTime;
+
+ @override
+ Widget build(BuildContext context) {
+ final color = ScheduleProjection.colorForItem(
+ item,
+ Theme.of(context).brightness,
+ );
+ return Material(
+ color: color.withValues(alpha: .2),
+ borderRadius: BorderRadius.circular(5),
+ child: InkWell(
+ borderRadius: BorderRadius.circular(5),
+ onTap: () => onOpen(item),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
+ child: Text(
+ showTime && item.start != null
+ ? '${BusyMaxTimeFormatScope.of(context).format(item.start!)} ${item.title}'
+ : item.title,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurface,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+final class _AndroidTimedPlacement {
+ const _AndroidTimedPlacement({
+ required this.item,
+ required this.startMinute,
+ required this.endMinute,
+ required this.lane,
+ });
+
+ final ScheduleItem item;
+ final int startMinute;
+ final int endMinute;
+ final int lane;
+}
+
+List<_AndroidTimedPlacement> _timedPlacements(
+ List items,
+ DateTime day,
+) {
+ final dayStart = DateTime(day.year, day.month, day.day);
+ final dayEnd = DateTime(day.year, day.month, day.day + 1);
+ final candidates = <({ScheduleItem item, int start, int end})>[];
+ for (final item in items) {
+ final rawStart = item.start;
+ if (rawStart == null) continue;
+ final rawEnd = item.end != null && item.end!.isAfter(rawStart)
+ ? item.end!
+ : rawStart.add(const Duration(minutes: 30));
+ final start = rawStart.isAfter(dayStart) ? rawStart : dayStart;
+ final end = rawEnd.isBefore(dayEnd) ? rawEnd : dayEnd;
+ if (!end.isAfter(start)) continue;
+ final startMinute = start == dayStart ? 0 : start.hour * 60 + start.minute;
+ final endMinute = end == dayEnd ? 24 * 60 : end.hour * 60 + end.minute;
+ candidates.add((item: item, start: startMinute, end: endMinute));
+ }
+ candidates.sort((a, b) {
+ final byStart = a.start.compareTo(b.start);
+ return byStart != 0 ? byStart : b.end.compareTo(a.end);
+ });
+ final laneEnds = [];
+ final result = <_AndroidTimedPlacement>[];
+ for (final candidate in candidates) {
+ var lane = laneEnds.indexWhere((end) => end <= candidate.start);
+ if (lane < 0) {
+ lane = laneEnds.length;
+ laneEnds.add(candidate.end);
+ } else {
+ laneEnds[lane] = candidate.end;
+ }
+ result.add(
+ _AndroidTimedPlacement(
+ item: candidate.item,
+ startMinute: candidate.start,
+ endMinute: candidate.end,
+ lane: lane,
+ ),
+ );
+ }
+ return result;
+}
+
+class _MonthView extends StatelessWidget {
+ const _MonthView({
+ required this.anchor,
+ required this.items,
+ required this.onSelectDate,
+ required this.onOpen,
+ required this.onToggleTask,
+ });
+ final DateTime anchor;
+ final List items;
+ final ValueChanged onSelectDate;
+ final ValueChanged onOpen;
+ final ValueChanged onToggleTask;
+ @override
+ Widget build(BuildContext context) {
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final scaledDayText = MediaQuery.textScalerOf(context).scale(16);
+ // The grid is inside the outer vertical scroller, so preserve the
+ // user's text scale with real civil-day row height instead of
+ // shrinking full-size Month labels to fit a viewport fraction.
+ final gridHeight = 30 + 6 * (scaledDayText * 1.55 + 36);
+ final agendaHeight = (constraints.maxHeight * .42).clamp(200.0, 420.0);
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onHorizontalDragEnd: (details) {
+ final velocity = details.primaryVelocity ?? 0;
+ if (velocity.abs() < 180) return;
+ onSelectDate(
+ DateTime(anchor.year, anchor.month + (velocity < 0 ? 1 : -1)),
+ );
+ },
+ child: SingleChildScrollView(
+ child: Column(
+ children: [
+ SizedBox(
+ height: gridHeight,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
+ child: _AndroidMonthGrid(
+ key: const ValueKey('android-month-grid'),
+ displayedMonth: DateTime(anchor.year, anchor.month),
+ selectedDate: anchor,
+ items: items,
+ onDaySelected: onSelectDate,
+ ),
+ ),
+ ),
+ SizedBox(
+ height: agendaHeight,
+ child: _AgendaList(
+ items: ScheduleProjection.itemsForDay(items, anchor),
+ onOpen: onOpen,
+ onToggleTask: onToggleTask,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
+
+class _YearView extends StatelessWidget {
+ const _YearView({
+ required this.anchor,
+ required this.items,
+ required this.onSelectDate,
+ required this.onModeChanged,
+ });
+ final DateTime anchor;
+ final List items;
+ final ValueChanged onSelectDate;
+ final ValueChanged onModeChanged;
+ @override
+ Widget build(BuildContext context) => GridView.builder(
+ key: const ValueKey('android-year-grid'),
+ padding: const EdgeInsets.fromLTRB(12, 8, 12, 96),
+ gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
+ maxCrossAxisExtent: 330,
+ mainAxisExtent: 245,
+ crossAxisSpacing: 8,
+ mainAxisSpacing: 8,
+ ),
+ itemCount: 12,
+ itemBuilder: (context, index) {
+ final month = DateTime(anchor.year, index + 1);
+ return Card(
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: () {
+ onSelectDate(month);
+ onModeChanged(ScheduleViewMode.month);
+ },
+ child: Padding(
+ padding: const EdgeInsets.all(8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ DateFormat.MMMM().format(month),
+ style: Theme.of(context).textTheme.titleSmall,
+ ),
+ const SizedBox(height: 4),
+ Expanded(
+ child: IgnorePointer(
+ child: _AndroidMonthGrid(
+ displayedMonth: month,
+ selectedDate: anchor,
+ items: items,
+ onDaySelected: (_) {},
+ compact: true,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ );
+}
+
+class _AndroidMonthGrid extends StatelessWidget {
+ const _AndroidMonthGrid({
+ super.key,
+ required this.displayedMonth,
+ required this.selectedDate,
+ required this.items,
+ required this.onDaySelected,
+ this.compact = false,
+ });
+
+ final DateTime displayedMonth;
+ final DateTime selectedDate;
+ final List items;
+ final ValueChanged onDaySelected;
+ final bool compact;
+
+ @override
+ Widget build(BuildContext context) {
+ final firstWeekday = _firstWeekday(context);
+ final month = DateTime(displayedMonth.year, displayedMonth.month);
+ final range = androidMonthGridRange(
+ displayedMonth,
+ firstWeekday: firstWeekday,
+ );
+ final gridStart = range.start;
+ final days = [
+ for (var index = 0; index < 42; index++)
+ DateTime(gridStart.year, gridStart.month, gridStart.day + index),
+ ];
+ final weekdays = [
+ for (var index = 0; index < 7; index++)
+ ((firstWeekday + index - 1) % 7) + 1,
+ ];
+ return Column(
+ children: [
+ SizedBox(
+ height: compact ? 16 : 22,
+ child: Row(
+ children: [
+ for (final weekday in weekdays)
+ Expanded(
+ child: Center(
+ child: Text(
+ DateFormat.E().format(DateTime(2026, 1, 5 + weekday - 1)),
+ maxLines: 1,
+ style: compact
+ ? Theme.of(context).textTheme.labelSmall
+ : Theme.of(context).textTheme.labelMedium,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ for (var row = 0; row < 6; row++)
+ Expanded(
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ for (var column = 0; column < 7; column++)
+ Expanded(
+ child: _AndroidMonthDay(
+ day: days[row * 7 + column],
+ displayedMonth: month,
+ selectedDate: selectedDate,
+ items: items,
+ compact: compact,
+ onSelected: onDaySelected,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _AndroidMonthDay extends StatelessWidget {
+ const _AndroidMonthDay({
+ required this.day,
+ required this.displayedMonth,
+ required this.selectedDate,
+ required this.items,
+ required this.compact,
+ required this.onSelected,
+ });
+
+ final DateTime day;
+ final DateTime displayedMonth;
+ final DateTime selectedDate;
+ final List items;
+ final bool compact;
+ final ValueChanged onSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ final matching = ScheduleProjection.itemsForDay(items, day);
+ final selected = _sameDay(day, selectedDate);
+ final scheme = Theme.of(context).colorScheme;
+ final content = Column(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(
+ '${day.day}',
+ style:
+ (compact
+ ? Theme.of(context).textTheme.labelSmall
+ : Theme.of(context).textTheme.bodyMedium)
+ ?.copyWith(
+ color: day.month == displayedMonth.month
+ ? null
+ : scheme.onSurfaceVariant.withValues(alpha: .45),
+ ),
+ ),
+ if (matching.isNotEmpty)
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ for (final item in matching.take(compact ? 2 : 3))
+ Container(
+ width: compact ? 3 : 5,
+ height: compact ? 3 : 5,
+ margin: const EdgeInsets.symmetric(horizontal: 1),
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: ScheduleProjection.colorForItem(
+ item,
+ Theme.of(context).brightness,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ return InkWell(
+ onTap: () => onSelected(day),
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: selected ? scheme.secondaryContainer : null,
+ border: compact
+ ? null
+ : Border.all(color: Theme.of(context).dividerColor, width: .35),
+ ),
+ child: compact
+ ? FittedBox(fit: BoxFit.scaleDown, child: content)
+ : Center(child: content),
+ ),
+ );
+ }
+}
+
+class AndroidEventEditor extends ConsumerStatefulWidget {
+ const AndroidEventEditor({
+ super.key,
+ required this.sources,
+ required this.draft,
+ });
+ final List sources;
+ final EventEditorDraft draft;
+ @override
+ ConsumerState createState() => _AndroidEventEditorState();
+}
+
+class _AndroidEventEditorState extends ConsumerState {
+ late EventEditorDraft _draft = widget.draft;
+ late final TextEditingController _title = TextEditingController(
+ text: _draft.title,
+ );
+ late final TextEditingController _location = TextEditingController(
+ text: _draft.location,
+ );
+ late final TextEditingController _description = TextEditingController(
+ text: _draft.description,
+ );
+ late final TextEditingController _guests = TextEditingController(
+ text: _draft.attendees
+ .where((a) => !a.self && !a.organizer)
+ .map((a) => a.email)
+ .join(', '),
+ );
+ late final TextEditingController _categories = TextEditingController(
+ text: _draft.categories.join(', '),
+ );
+ int? _reminderMinutes;
+ late RecurrenceFrequency _frequency = EventRecurrenceCodec.decode(
+ _provider,
+ _draft.recurrence,
+ baseDate: _draft.start,
+ ).frequency;
+ bool _recurrenceChanged = false;
+ bool _saving = false;
+ bool _allowPop = false;
+ bool _recoveryLoaded = false;
+ bool _recoveryWritesBlocked = false;
+ Future _recoveryIo = Future.value();
+ Timer? _recoveryTimer;
+
+ String get _recoveryKey =>
+ 'busymax.android.event-draft.${widget.draft.accountId}.'
+ '${widget.draft.eventId ?? 'new-${widget.draft.sourceId}'}';
+
+ @override
+ void initState() {
+ super.initState();
+ unawaited(_restoreRecovery());
+ _recoveryTimer = Timer.periodic(
+ const Duration(seconds: 1),
+ (_) => unawaited(_persistRecovery()),
+ );
+ }
+
+ @override
+ void dispose() {
+ _recoveryTimer?.cancel();
+ if (_recoveryLoaded && _hasPendingEdits && !_allowPop) {
+ unawaited(_persistRecovery());
+ }
+ _title.dispose();
+ _location.dispose();
+ _description.dispose();
+ _guests.dispose();
+ _categories.dispose();
+ super.dispose();
+ }
+
+ CalendarSourceEntity? get _source => widget.sources
+ .where((source) => source.id == _draft.sourceId)
+ .firstOrNull;
+
+ BusyProvider get _provider =>
+ _source?.provider ??
+ _draft.originalDetail?.provider ??
+ BusyProvider.google;
+
+ bool get _canEdit {
+ final source = _source;
+ if (source == null) return false;
+ return _draft.eventId == null
+ ? source.capabilities.canCreateEvents
+ : source.capabilities.canEditEvents;
+ }
+
+ bool get _canDelete =>
+ _draft.eventId != null &&
+ (_source?.capabilities.canDeleteEvents ?? false);
+
+ bool get _hasPendingEdits =>
+ _draft != widget.draft ||
+ _guests.text !=
+ widget.draft.attendees
+ .where((attendee) => !attendee.self && !attendee.organizer)
+ .map((attendee) => attendee.email)
+ .join(', ') ||
+ _categories.text != widget.draft.categories.join(', ') ||
+ _reminderMinutes != null ||
+ _recurrenceChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ return PopScope(
+ canPop: _allowPop || !_hasPendingEdits,
+ onPopInvokedWithResult: (didPop, _) async {
+ if (didPop || _saving || _allowPop) return;
+ if (await _confirmDiscardChanges() && mounted) {
+ await _clearRecovery(finalCleanup: true);
+ if (!mounted) return;
+ setState(() => _allowPop = true);
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted) Navigator.pop(context);
+ });
+ }
+ },
+ child: Scaffold(
+ appBar: AppBar(
+ leading: IconButton(
+ tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
+ onPressed: _saving ? null : _requestClose,
+ icon: const Icon(Icons.close),
+ ),
+ title: Text(
+ _draft.eventId == null
+ ? context.l10n.newEvent
+ : context.l10n.editEvent,
+ ),
+ actions: [
+ TextButton(
+ onPressed: _saving || !_canEdit ? null : _save,
+ child: Text(context.l10n.save),
+ ),
+ ],
+ ),
+ body: Form(
+ child: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ TextFormField(
+ controller: _title,
+ enabled: _canEdit,
+ decoration: InputDecoration(labelText: context.l10n.title),
+ autofocus: _draft.eventId == null,
+ onChanged: (v) =>
+ setState(() => _draft = _draft.copyWith(title: v)),
+ ),
+ const SizedBox(height: 12),
+ DropdownButtonFormField(
+ initialValue: _source?.id,
+ decoration: InputDecoration(labelText: context.l10n.calendar),
+ items: [
+ for (final source in widget.sources)
+ DropdownMenuItem(
+ value: source.id,
+ child: Text(
+ '${source.summary} · ${source.authenticatedAccountEmail ?? source.accountId}',
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ onChanged: _draft.eventId != null || !_canEdit
+ ? null
+ : (id) {
+ final source = widget.sources.firstWhere(
+ (s) => s.id == id,
+ );
+ setState(
+ () => _draft = _draft.copyWith(
+ accountId: source.accountId,
+ sourceId: source.id,
+ providerCalendarId: source.providerCalendarId,
+ clearShowAs: true,
+ clearVisibilityOrSensitivity: true,
+ ),
+ );
+ },
+ ),
+ SwitchListTile(
+ contentPadding: EdgeInsets.zero,
+ title: Text(context.l10n.allDay),
+ value: _draft.allDay,
+ onChanged: !_canEdit
+ ? null
+ : (value) => setState(
+ () => _draft = _draft.copyWith(allDay: value),
+ ),
+ ),
+ _DateTimeTile(
+ label: context.l10n.startDateTime,
+ value: _draft.start!,
+ allDay: _draft.allDay,
+ onChanged: !_canEdit
+ ? null
+ : (value) => setState(
+ () => _draft = _draft.copyWith(start: value),
+ ),
+ ),
+ _DateTimeTile(
+ label: context.l10n.endDateTime,
+ value: _draft.end!,
+ allDay: _draft.allDay,
+ onChanged: !_canEdit
+ ? null
+ : (value) =>
+ setState(() => _draft = _draft.copyWith(end: value)),
+ ),
+ const SizedBox(height: 12),
+ DropdownButtonFormField(
+ key: ValueKey('android-event-show-as-${_provider.name}'),
+ initialValue:
+ _eventShowAsValues(_provider).contains(_draft.showAs)
+ ? _draft.showAs
+ : _eventShowAsValues(_provider).first,
+ decoration: InputDecoration(
+ labelText: context.l10n.availabilityShowAs,
+ ),
+ items: [
+ for (final value in _eventShowAsValues(_provider))
+ DropdownMenuItem(
+ value: value,
+ child: Text(_eventAvailabilityLabel(context, value)),
+ ),
+ ],
+ onChanged: !_canEdit
+ ? null
+ : (value) => setState(
+ () => _draft = _draft.copyWith(showAs: value),
+ ),
+ ),
+ const SizedBox(height: 12),
+ DropdownButtonFormField(
+ key: ValueKey('android-event-visibility-${_provider.name}'),
+ initialValue:
+ _eventVisibilityValues(
+ _provider,
+ ).contains(_draft.visibilityOrSensitivity)
+ ? _draft.visibilityOrSensitivity
+ : _eventVisibilityValues(_provider).first,
+ decoration: InputDecoration(labelText: context.l10n.visibility),
+ items: [
+ for (final value in _eventVisibilityValues(_provider))
+ DropdownMenuItem(
+ value: value,
+ child: Text(_eventVisibilityLabel(context, value)),
+ ),
+ ],
+ onChanged: !_canEdit
+ ? null
+ : (value) => setState(
+ () => _draft = _draft.copyWith(
+ visibilityOrSensitivity: value,
+ ),
+ ),
+ ),
+ const SizedBox(height: 12),
+ TextFormField(
+ controller: _location,
+ enabled: _canEdit,
+ decoration: InputDecoration(
+ labelText: context.l10n.location,
+ prefixIcon: const Icon(Icons.place_outlined),
+ ),
+ onChanged: (v) =>
+ setState(() => _draft = _draft.copyWith(location: v)),
+ ),
+ const SizedBox(height: 12),
+ TextFormField(
+ controller: _description,
+ enabled: _canEdit,
+ minLines: 3,
+ maxLines: 7,
+ decoration: InputDecoration(
+ labelText: context.l10n.description,
+ ),
+ onChanged: (v) =>
+ setState(() => _draft = _draft.copyWith(description: v)),
+ ),
+ const SizedBox(height: 12),
+ DropdownButtonFormField(
+ initialValue: _frequency,
+ decoration: InputDecoration(labelText: context.l10n.repeat),
+ items: [
+ DropdownMenuItem(
+ value: RecurrenceFrequency.none,
+ child: Text(context.l10n.repeatNone),
+ ),
+ DropdownMenuItem(
+ value: RecurrenceFrequency.daily,
+ child: Text(context.l10n.repeatDaily),
+ ),
+ DropdownMenuItem(
+ value: RecurrenceFrequency.weekly,
+ child: Text(context.l10n.repeatWeekly),
+ ),
+ DropdownMenuItem(
+ value: RecurrenceFrequency.monthly,
+ child: Text(context.l10n.repeatMonthly),
+ ),
+ DropdownMenuItem(
+ value: RecurrenceFrequency.yearly,
+ child: Text(context.l10n.repeatYearly),
+ ),
+ ],
+ onChanged: !_canEdit
+ ? null
+ : (value) => setState(() {
+ _frequency = value ?? RecurrenceFrequency.none;
+ _recurrenceChanged = true;
+ }),
+ ),
+ if (_draft.providerRecurringEventId != null) ...[
+ const SizedBox(height: 12),
+ ListTile(
+ contentPadding: EdgeInsets.zero,
+ leading: const Icon(Icons.call_split),
+ title: Text(context.l10n.chooseRecurringEventScope),
+ subtitle: _draft.recurringMutationScope == null
+ ? null
+ : Text(
+ _scopeLabel(context, _draft.recurringMutationScope!),
+ ),
+ onTap: _canEdit ? _selectRecurringScope : null,
+ ),
+ ],
+ const SizedBox(height: 12),
+ DropdownButtonFormField(
+ initialValue: _reminderMinutes,
+ decoration: InputDecoration(labelText: context.l10n.reminder),
+ items: [
+ DropdownMenuItem(
+ value: 0,
+ child: Text(context.l10n.repeatNone),
+ ),
+ for (final value in const [5, 10, 30, 60, 1440])
+ DropdownMenuItem(
+ value: value,
+ child: Text(context.l10n.reminderMinutesBefore(value)),
+ ),
+ ],
+ onChanged: !_canEdit
+ ? null
+ : (value) => setState(() => _reminderMinutes = value),
+ ),
+ const SizedBox(height: 12),
+ TextFormField(
+ controller: _guests,
+ enabled: _canEdit && _draft.canManageAttendees,
+ decoration: InputDecoration(labelText: context.l10n.guests),
+ keyboardType: TextInputType.emailAddress,
+ onChanged: (_) => setState(() {}),
+ ),
+ if (_canCheckGuestAvailability) ...[
+ const SizedBox(height: 4),
+ Align(
+ alignment: AlignmentDirectional.centerStart,
+ child: TextButton.icon(
+ onPressed: _showGuestAvailability,
+ icon: const Icon(Icons.event_available_outlined),
+ label: Text(context.l10n.nextcloudGuestAvailability),
+ ),
+ ),
+ ],
+ const SizedBox(height: 12),
+ TextFormField(
+ controller: _categories,
+ enabled: _canEdit,
+ decoration: InputDecoration(labelText: context.l10n.categories),
+ onChanged: (_) => setState(() {}),
+ ),
+ if (_canDelete) ...[
+ const SizedBox(height: 24),
+ OutlinedButton.icon(
+ onPressed: _saving ? null : _delete,
+ icon: const Icon(Icons.delete_outline),
+ label: Text(context.l10n.deleteEvent),
+ ),
+ ],
+ const SizedBox(height: 40),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Future _save() async {
+ final start = _draft.start;
+ if (_title.text.trim().isEmpty || start == null) return;
+ final attendeeEdit = mergeAndroidEventAttendees(
+ widget.draft.attendees,
+ _guests.text,
+ );
+ final categories = _categories.text
+ .split(',')
+ .map((value) => value.trim())
+ .where((value) => value.isNotEmpty)
+ .toList();
+ var draft = _draft.copyWith(
+ title: _title.text.trim(),
+ location: _location.text.trim(),
+ description: _description.text.trim(),
+ attendees: attendeeEdit.attendees,
+ attendeesChanged: attendeeEdit.changed,
+ categories: categories,
+ categoriesChanged: !listEquals(categories, widget.draft.categories),
+ );
+ if (_reminderMinutes != null) {
+ final value = _reminderMinutes!;
+ draft = draft.copyWith(
+ reminders: _eventReminders(_provider, value),
+ remindersChanged: true,
+ clearReminders: value == 0,
+ );
+ }
+ if (_recurrenceChanged) {
+ final rule = _simpleRecurrenceRule(_frequency, start);
+ draft = draft.copyWith(
+ recurrence: rule.repeats
+ ? EventRecurrenceCodec.encode(
+ _provider,
+ rule,
+ baseDate: start,
+ allDay: draft.allDay,
+ timeZone: draft.startTimeZone,
+ )
+ : null,
+ recurrenceChanged: true,
+ clearRecurrence: !rule.repeats,
+ );
+ }
+ if (!draft.canSave) return;
+ if (draft.providerRecurringEventId != null &&
+ draft.recurringMutationScope == null) {
+ final scope = await _selectRecurringScope();
+ if (scope == null) return;
+ draft = draft.copyWith(recurringMutationScope: scope);
+ }
+ setState(() => _saving = true);
+ try {
+ if ((_reminderMinutes ?? 0) > 0) {
+ await ref
+ .read(androidNotificationServiceProvider)
+ .requestNotificationPermission();
+ }
+ if (draft.eventId == null) {
+ await ref.read(calendarRepositoryProvider).createLocalEvent(draft);
+ } else {
+ await ref.read(calendarRepositoryProvider).updateLocalEvent(draft);
+ }
+ ref
+ .read(
+ pendingCalendarMutationSyncRequesterForAccountProvider(
+ draft.accountId,
+ ),
+ )
+ .request();
+ if (mounted) {
+ await _clearRecovery(finalCleanup: true);
+ if (!mounted) return;
+ setState(() => _allowPop = true);
+ Navigator.pop(context);
+ }
+ } on Object catch (error) {
+ if (mounted) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('$error')));
+ }
+ } finally {
+ if (mounted) setState(() => _saving = false);
+ }
+ }
+
+ bool get _canCheckGuestAvailability {
+ final source = _source;
+ if (source == null ||
+ _draft.start == null ||
+ _draft.end == null ||
+ !_draft.end!.isAfter(_draft.start!)) {
+ return false;
+ }
+ final hasGuests = mergeAndroidEventAttendees(
+ _draft.attendees,
+ _guests.text,
+ ).attendees.any((attendee) => !attendee.self && !attendee.organizer);
+ if (!hasGuests) return false;
+ if (source.provider == BusyProvider.google) return true;
+ return source.provider == BusyProvider.nextcloud &&
+ source.davCollectionId != null &&
+ source.davEffectivePermissions['canQueryFreeBusy'] == true;
+ }
+
+ Future _showGuestAvailability() async {
+ final source = _source;
+ if (source == null || !_canCheckGuestAvailability) return;
+ final attendees = mergeAndroidEventAttendees(
+ _draft.attendees,
+ _guests.text,
+ ).attendees;
+ await showAndroidGuestAvailabilityDialog(
+ context,
+ accountId: source.accountId,
+ provider: source.provider,
+ collectionId: source.davCollectionId,
+ draft: _draft.copyWith(attendees: attendees),
+ );
+ }
+
+ Future _delete() async {
+ var draft = _draft;
+ if (draft.providerRecurringEventId != null &&
+ draft.recurringMutationScope == null) {
+ final scope = await _selectRecurringScope();
+ if (scope == null) return;
+ draft = draft.copyWith(recurringMutationScope: scope);
+ }
+ if (!mounted) return;
+ final confirmed = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: Text(context.l10n.deleteEvent),
+ content: Text(draft.title),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context, false),
+ child: Text(context.l10n.cancel),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.pop(context, true),
+ child: Text(context.l10n.delete),
+ ),
+ ],
+ ),
+ );
+ if (confirmed != true || _draft.eventId == null) return;
+ setState(() => _saving = true);
+ try {
+ await ref
+ .read(calendarRepositoryProvider)
+ .deleteLocalEvent(
+ _draft.eventId!,
+ recurringScope: draft.recurringMutationScope,
+ );
+ ref
+ .read(
+ pendingCalendarMutationSyncRequesterForAccountProvider(
+ draft.accountId,
+ ),
+ )
+ .request();
+ if (mounted) {
+ await _clearRecovery(finalCleanup: true);
+ if (!mounted) return;
+ setState(() => _allowPop = true);
+ Navigator.pop(context);
+ }
+ } on Object catch (error) {
+ if (mounted) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('$error')));
+ }
+ } finally {
+ if (mounted) setState(() => _saving = false);
+ }
+ }
+
+ Future _selectRecurringScope() async {
+ final scope = await showModalBottomSheet(
+ context: context,
+ showDragHandle: true,
+ builder: (context) => SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ListTile(title: Text(context.l10n.chooseRecurringEventScope)),
+ for (final value in RecurringEventMutationScope.values)
+ if (value != RecurringEventMutationScope.thisAndFuture ||
+ supportsThisAndFollowingEventMutation(_provider))
+ ListTile(
+ leading: Icon(
+ _draft.recurringMutationScope == value
+ ? Icons.radio_button_checked
+ : Icons.radio_button_unchecked,
+ ),
+ title: Text(_scopeLabel(context, value)),
+ onTap: () => Navigator.pop(context, value),
+ ),
+ ],
+ ),
+ ),
+ );
+ if (scope != null && mounted) {
+ setState(() => _draft = _draft.copyWith(recurringMutationScope: scope));
+ }
+ return scope;
+ }
+
+ Future _requestClose() async {
+ if (!_hasPendingEdits || await _confirmDiscardChanges()) {
+ if (!mounted) return;
+ await _clearRecovery(finalCleanup: true);
+ if (!mounted) return;
+ setState(() => _allowPop = true);
+ Navigator.pop(context);
+ }
+ }
+
+ Future _confirmDiscardChanges() async =>
+ await showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (dialogContext) => AlertDialog(
+ title: Text(context.l10n.discardChanges),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, false),
+ child: Text(context.l10n.cancel),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.pop(dialogContext, true),
+ child: Text(context.l10n.discardChangesAction),
+ ),
+ ],
+ ),
+ ) ??
+ false;
+
+ Future _restoreRecovery() async {
+ try {
+ final raw = await ref.read(secureStorageProvider).read(key: _recoveryKey);
+ if (raw == null) return;
+ final value = jsonDecode(raw);
+ if (value is! Map) return;
+ final map = value.cast();
+ final savedAt = DateTime.tryParse(map['savedAt']?.toString() ?? '');
+ final version = map['v'];
+ final editingExistingEvent = widget.draft.eventId != null;
+ final storedEventId = map['eventId']?.toString();
+ final identityMatches = editingExistingEvent
+ ? (version == 2 || storedEventId == widget.draft.eventId) &&
+ map['baseTitle'] == widget.draft.title &&
+ map['baseStart'] == widget.draft.start?.toIso8601String()
+ : storedEventId == null;
+ if (savedAt == null ||
+ DateTime.now().difference(savedAt).abs() > const Duration(days: 14) ||
+ (version != 2 && version != 3) ||
+ !identityMatches) {
+ await _clearRecovery();
+ return;
+ }
+ final start = DateTime.tryParse(map['start']?.toString() ?? '');
+ final end = DateTime.tryParse(map['end']?.toString() ?? '');
+ if (start == null || end == null) {
+ await _clearRecovery();
+ return;
+ }
+ final recoveredSource = widget.sources
+ .where(
+ (source) =>
+ source.id == map['sourceId'] &&
+ source.accountId == map['accountId'] &&
+ source.providerCalendarId == map['providerCalendarId'] &&
+ (widget.draft.eventId == null
+ ? source.capabilities.canCreateEvents
+ : source.capabilities.canEditEvents),
+ )
+ .firstOrNull;
+ if (recoveredSource == null) {
+ await _clearRecovery();
+ return;
+ }
+ final frequency = RecurrenceFrequency.values
+ .where((value) => value.name == map['frequency'])
+ .firstOrNull;
+ final recurringScope = RecurringEventMutationScope.values
+ .where((value) => value.name == map['recurringMutationScope'])
+ .firstOrNull;
+ if (!mounted) return;
+ await WidgetsBinding.instance.endOfFrame;
+ if (!mounted) return;
+ final recover = await showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (dialogContext) => AlertDialog(
+ title: Text(context.l10n.discardChanges),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, false),
+ child: Text(context.l10n.discardChangesAction),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.pop(dialogContext, true),
+ child: Text(context.l10n.nextcloudRestore),
+ ),
+ ],
+ ),
+ );
+ if (recover != true) {
+ await _clearRecovery();
+ return;
+ }
+ if (!mounted) return;
+ setState(() {
+ _title.text = map['title']?.toString() ?? _title.text;
+ _location.text = map['location']?.toString() ?? _location.text;
+ _description.text = map['description']?.toString() ?? _description.text;
+ _guests.text = map['guests']?.toString() ?? _guests.text;
+ _categories.text = map['categories']?.toString() ?? _categories.text;
+ _reminderMinutes = map['reminderMinutes'] as int?;
+ if (frequency != null && frequency != _frequency) {
+ _frequency = frequency;
+ _recurrenceChanged = true;
+ }
+ _draft = _draft.copyWith(
+ accountId: recoveredSource.accountId,
+ sourceId: recoveredSource.id,
+ providerCalendarId: recoveredSource.providerCalendarId,
+ title: _title.text,
+ location: _location.text,
+ description: _description.text,
+ allDay: map['allDay'] as bool? ?? _draft.allDay,
+ start: start,
+ end: end,
+ recurringMutationScope: recurringScope,
+ clearRecurringMutationScope: recurringScope == null,
+ showAs: map['showAs']?.toString(),
+ clearShowAs: map['showAs'] == null,
+ visibilityOrSensitivity: map['visibility']?.toString(),
+ clearVisibilityOrSensitivity: map['visibility'] == null,
+ );
+ });
+ } on Object {
+ // A malformed/locked recovery entry must never block the editor.
+ } finally {
+ _recoveryLoaded = true;
+ }
+ }
+
+ Future _persistRecovery() async {
+ if (!_recoveryLoaded ||
+ !_hasPendingEdits ||
+ _allowPop ||
+ _recoveryWritesBlocked) {
+ return;
+ }
+ final payload = jsonEncode({
+ 'v': 3,
+ 'savedAt': DateTime.now().toIso8601String(),
+ 'eventId': widget.draft.eventId,
+ 'baseTitle': widget.draft.title,
+ 'baseStart': widget.draft.start?.toIso8601String(),
+ 'accountId': _draft.accountId,
+ 'sourceId': _draft.sourceId,
+ 'providerCalendarId': _draft.providerCalendarId,
+ 'recurringMutationScope': _draft.recurringMutationScope?.name,
+ 'showAs': _draft.showAs,
+ 'visibility': _draft.visibilityOrSensitivity,
+ 'title': _title.text,
+ 'location': _location.text,
+ 'description': _description.text,
+ 'guests': _guests.text,
+ 'categories': _categories.text,
+ 'allDay': _draft.allDay,
+ 'start': _draft.start?.toIso8601String(),
+ 'end': _draft.end?.toIso8601String(),
+ 'reminderMinutes': _reminderMinutes,
+ 'frequency': _frequency.name,
+ });
+ await _enqueueRecovery(() async {
+ if (_recoveryWritesBlocked) return;
+ await ref
+ .read(secureStorageProvider)
+ .write(key: _recoveryKey, value: payload);
+ });
+ }
+
+ Future _clearRecovery({bool finalCleanup = false}) {
+ if (finalCleanup) {
+ _recoveryWritesBlocked = true;
+ _recoveryTimer?.cancel();
+ }
+ return _enqueueRecovery(
+ () => ref.read(secureStorageProvider).delete(key: _recoveryKey),
+ );
+ }
+
+ Future _enqueueRecovery(Future Function() operation) {
+ final result = _recoveryIo.then((_) => operation());
+ _recoveryIo = result.catchError((_) {});
+ return result.catchError((_) {});
+ }
+}
+
+final class AndroidEventAttendeeEdit {
+ const AndroidEventAttendeeEdit({
+ required this.attendees,
+ required this.changed,
+ });
+
+ final List attendees;
+ final bool changed;
+}
+
+/// Applies the Android email editor without destroying provider-owned guest
+/// metadata. Existing matching guests retain names, optional/required state,
+/// response status and organizer/self flags.
+AndroidEventAttendeeEdit mergeAndroidEventAttendees(
+ List original,
+ String editedEmails,
+) {
+ final requested = {};
+ for (final value in editedEmails.split(RegExp(r'[,;\n]'))) {
+ final email = value.trim();
+ if (email.isNotEmpty) {
+ requested.putIfAbsent(email.toLowerCase(), () => email);
+ }
+ }
+ final result = [];
+ final matched = {};
+ for (final attendee in original) {
+ if (attendee.self || attendee.organizer) {
+ result.add(attendee);
+ continue;
+ }
+ final key = attendee.email.trim().toLowerCase();
+ if (requested.containsKey(key) && matched.add(key)) result.add(attendee);
+ }
+ for (final entry in requested.entries) {
+ if (matched.add(entry.key)) {
+ result.add(EventAttendeeDraft(email: entry.value));
+ }
+ }
+ return AndroidEventAttendeeEdit(
+ attendees: listEquals(result, original) ? original : result,
+ changed: !listEquals(result, original),
+ );
+}
+
+String _scopeLabel(BuildContext context, RecurringEventMutationScope scope) =>
+ switch (scope) {
+ RecurringEventMutationScope.entireSeries => context.l10n.entireSeries,
+ RecurringEventMutationScope.singleOccurrence =>
+ context.l10n.singleOccurrence,
+ RecurringEventMutationScope.thisAndFuture =>
+ context.l10n.thisAndFollowingEvents,
+ };
+
+RecurrenceRule _simpleRecurrenceRule(
+ RecurrenceFrequency frequency,
+ DateTime base,
+) {
+ final weekday = const [
+ 'MO',
+ 'TU',
+ 'WE',
+ 'TH',
+ 'FR',
+ 'SA',
+ 'SU',
+ ][base.weekday - 1];
+ return const RecurrenceRule.none().copyWith(
+ frequency: frequency,
+ byDay: frequency == RecurrenceFrequency.weekly ? [weekday] : const [],
+ byMonthDay:
+ frequency == RecurrenceFrequency.monthly ||
+ frequency == RecurrenceFrequency.yearly
+ ? [base.day]
+ : const [],
+ byMonth: frequency == RecurrenceFrequency.yearly ? [base.month] : const [],
+ );
+}
+
+class _DateTimeTile extends StatelessWidget {
+ const _DateTimeTile({
+ required this.label,
+ required this.value,
+ required this.allDay,
+ required this.onChanged,
+ });
+ final String label;
+ final DateTime value;
+ final bool allDay;
+ final ValueChanged? onChanged;
+ @override
+ Widget build(BuildContext context) => ListTile(
+ contentPadding: EdgeInsets.zero,
+ leading: const Icon(Icons.schedule),
+ title: Text(label),
+ subtitle: Text(
+ allDay
+ ? DateFormat.yMMMd().format(value)
+ : formatClockDateTime(
+ context,
+ value,
+ DateFormat.yMMMd().format(value),
+ ),
+ ),
+ enabled: onChanged != null,
+ onTap: onChanged == null
+ ? null
+ : () async {
+ final date = await showBusyMaxDatePicker(
+ context: context,
+ initialDate: value,
+ firstDate: DateTime(1970),
+ lastDate: DateTime(2200),
+ );
+ if (date == null || !context.mounted) return;
+ if (allDay) return onChanged!(date);
+ final time = await showTimePicker(
+ context: context,
+ initialTime: TimeOfDay.fromDateTime(value),
+ );
+ if (time != null) {
+ onChanged!(
+ DateTime(
+ date.year,
+ date.month,
+ date.day,
+ time.hour,
+ time.minute,
+ ),
+ );
+ }
+ },
+ );
+}
+
+class _Message extends StatelessWidget {
+ const _Message({
+ required this.icon,
+ required this.title,
+ required this.detail,
+ });
+ final IconData icon;
+ final String title;
+ final String detail;
+ @override
+ Widget build(BuildContext context) => Center(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(32),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: 52),
+ const SizedBox(height: 16),
+ Text(
+ title,
+ style: Theme.of(context).textTheme.titleLarge,
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 8),
+ Text(detail, textAlign: TextAlign.center),
+ ],
+ ),
+ ),
+ );
+}
+
+ScheduleRange _rangeFor(
+ DateTime anchor,
+ ScheduleViewMode mode, {
+ required int firstWeekday,
+}) => switch (mode) {
+ ScheduleViewMode.day => ScheduleRange.day(anchor),
+ ScheduleViewMode.week => ScheduleRange.week(
+ anchor,
+ firstWeekday: firstWeekday,
+ ),
+ ScheduleViewMode.month => androidMonthGridRange(
+ anchor,
+ firstWeekday: firstWeekday,
+ ),
+ ScheduleViewMode.year => androidYearGridRange(
+ anchor,
+ firstWeekday: firstWeekday,
+ ),
+ ScheduleViewMode.agenda => ScheduleRange(
+ start: DateTime(anchor.year, anchor.month, anchor.day - 30),
+ end: DateTime(anchor.year, anchor.month, anchor.day + 91),
+ ),
+};
+
+/// The exact civil-date interval rendered by the Android six-week month grid.
+ScheduleRange androidMonthGridRange(
+ DateTime month, {
+ required int firstWeekday,
+}) {
+ final first = DateTime(month.year, month.month);
+ final offset = (first.weekday - firstWeekday) % DateTime.daysPerWeek;
+ final start = DateTime(first.year, first.month, first.day - offset);
+ return ScheduleRange(
+ start: start,
+ end: DateTime(start.year, start.month, start.day + 42),
+ );
+}
+
+/// Covers every leading and trailing date drawn by all twelve year miniatures.
+ScheduleRange androidYearGridRange(DateTime year, {required int firstWeekday}) {
+ final january = androidMonthGridRange(
+ DateTime(year.year),
+ firstWeekday: firstWeekday,
+ );
+ final december = androidMonthGridRange(
+ DateTime(year.year, DateTime.december),
+ firstWeekday: firstWeekday,
+ );
+ return ScheduleRange(start: january.start, end: december.end);
+}
+
+DateTime _move(DateTime date, ScheduleViewMode mode, int amount) =>
+ switch (mode) {
+ ScheduleViewMode.day || ScheduleViewMode.agenda => DateTime(
+ date.year,
+ date.month,
+ date.day + amount,
+ ),
+ ScheduleViewMode.week => DateTime(
+ date.year,
+ date.month,
+ date.day + 7 * amount,
+ ),
+ ScheduleViewMode.month => DateTime(date.year, date.month + amount, 1),
+ ScheduleViewMode.year => DateTime(date.year + amount, date.month, 1),
+ };
+
+String _periodLabel(
+ BuildContext context,
+ DateTime date,
+ ScheduleViewMode mode,
+) => switch (mode) {
+ ScheduleViewMode.day ||
+ ScheduleViewMode.agenda => DateFormat.yMMMMd().format(date),
+ ScheduleViewMode.week =>
+ '${DateFormat.MMMd().format(ScheduleRange.week(date, firstWeekday: _firstWeekday(context)).start)} – '
+ '${DateFormat.MMMd().format(_previousCivilDay(ScheduleRange.week(date, firstWeekday: _firstWeekday(context)).end))}',
+ ScheduleViewMode.month => DateFormat.yMMMM().format(date),
+ ScheduleViewMode.year => '${date.year}',
+};
+
+DateTime _previousCivilDay(DateTime value) =>
+ DateTime(value.year, value.month, value.day - 1);
+
+String _modeLabel(BuildContext context, ScheduleViewMode mode) =>
+ switch (mode) {
+ ScheduleViewMode.day => context.l10n.viewDay,
+ ScheduleViewMode.week => context.l10n.viewWeek,
+ ScheduleViewMode.month => context.l10n.viewMonth,
+ ScheduleViewMode.year => context.l10n.viewYear,
+ ScheduleViewMode.agenda => context.l10n.viewAgenda,
+ };
+
+bool _sameDay(DateTime a, DateTime b) =>
+ a.year == b.year && a.month == b.month && a.day == b.day;
+
+int _firstWeekday(BuildContext context) {
+ return BusyMaxWeekPreferencesScope.firstWeekdayOf(context);
+}
+
+Object? _eventReminders(BusyProvider provider, int minutes) {
+ if (minutes <= 0) return null;
+ return provider == BusyProvider.microsoft
+ ? {'isReminderOn': true, 'reminderMinutesBeforeStart': minutes}
+ : {
+ 'useDefault': false,
+ 'overrides': [
+ {'method': 'popup', 'minutes': minutes},
+ ],
+ };
+}
+
+List _eventShowAsValues(BusyProvider provider) =>
+ provider == BusyProvider.microsoft
+ ? const ['free', 'tentative', 'busy', 'oof', 'workingElsewhere']
+ : const ['opaque', 'transparent'];
+
+String _eventAvailabilityLabel(BuildContext context, String value) =>
+ switch (value) {
+ 'opaque' || 'busy' => context.l10n.busy,
+ 'transparent' || 'free' => context.l10n.availabilityFree,
+ 'tentative' => context.l10n.availabilityTentative,
+ 'oof' => context.l10n.availabilityOutOfOffice,
+ 'workingElsewhere' => context.l10n.availabilityWorkingElsewhere,
+ _ => value,
+ };
+
+List _eventVisibilityValues(BusyProvider provider) =>
+ provider == BusyProvider.microsoft
+ ? const ['normal', 'personal', 'private', 'confidential']
+ : const ['default', 'public', 'private', 'confidential'];
+
+String _eventVisibilityLabel(BuildContext context, String value) =>
+ switch (value) {
+ 'default' => context.l10n.visibilityDefault,
+ 'public' => context.l10n.visibilityPublic,
+ 'private' => context.l10n.visibilityPrivate,
+ 'confidential' => context.l10n.visibilityConfidential,
+ 'normal' => context.l10n.sensitivityNormal,
+ 'personal' => context.l10n.sensitivityPersonal,
+ _ => value,
+ };
diff --git a/lib/src/android/presentation/android_schedule_search_filters.dart b/lib/src/android/presentation/android_schedule_search_filters.dart
new file mode 100644
index 00000000..215c3f1d
--- /dev/null
+++ b/lib/src/android/presentation/android_schedule_search_filters.dart
@@ -0,0 +1,260 @@
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+
+import '../../l10n/l10n.dart';
+import '../../schedule/schedule_search_criteria.dart';
+import '../../schedule/schedule_filters.dart';
+import '../../features/accounts/data/accounts_repository.dart';
+import '../../features/calendar/data/calendar_repository.dart';
+import '../../features/task_lists/data/task_lists_repository.dart';
+import '../../features/schedule/presentation/schedule_search_labels.dart';
+import 'android_date_picker.dart';
+
+/// Material search controls for the Android bottom sheet.
+/// This widget only emits immutable criteria; it never writes source settings.
+class AndroidScheduleSearchFilters extends StatelessWidget {
+ const AndroidScheduleSearchFilters({
+ super.key,
+ required this.value,
+ required this.onChanged,
+ required this.onClear,
+ required this.accounts,
+ required this.sources,
+ required this.taskLists,
+ });
+ final ScheduleSearchCriteria value;
+ final ValueChanged onChanged;
+ final VoidCallback onClear;
+ final List accounts;
+ final List sources;
+ final List taskLists;
+
+ @override
+ Widget build(BuildContext context) {
+ final l = context.l10n;
+ return ListView(
+ key: const ValueKey('schedule-search-filters'),
+ padding: const EdgeInsetsDirectional.all(16),
+ children: [
+ Text(l.searchFilters, style: Theme.of(context).textTheme.titleMedium),
+ const SizedBox(height: 16),
+ _select(
+ l.searchType,
+ value.type,
+ ScheduleSearchType.values,
+ (v) => searchTypeLabel(l, v),
+ (v) => onChanged(value.copyWith(type: v)),
+ ),
+ _select(
+ l.searchDate,
+ value.date,
+ ScheduleSearchDate.values,
+ (v) => searchDateLabel(l, v),
+ (v) => onChanged(value.copyWith(date: v)),
+ ),
+ if (value.date == ScheduleSearchDate.custom) ...[
+ _date(
+ context,
+ l.startDate,
+ value.customStart ?? value.referenceDate,
+ true,
+ ),
+ _date(
+ context,
+ l.endDate,
+ value.customEnd ?? value.referenceDate,
+ false,
+ ),
+ ],
+ if (value.includesTasks) ...[
+ _select(
+ l.searchTaskStatus,
+ value.taskCompletion,
+ ScheduleTaskCompletion.values,
+ (v) => searchCompletionLabel(l, v),
+ (v) => onChanged(value.copyWith(taskCompletion: v)),
+ ),
+ _select(
+ l.searchTaskDue,
+ value.taskDueState,
+ ScheduleTaskDueState.values,
+ (v) => searchDueLabel(l, v),
+ (v) => onChanged(value.copyWith(taskDueState: v)),
+ ),
+ ],
+ if (value.includesEvents)
+ _text(
+ l.searchPerson,
+ value.person,
+ (v) => onChanged(value.copyWith(person: v)),
+ ),
+ _text(
+ l.location,
+ value.location,
+ (v) => onChanged(value.copyWith(location: v)),
+ ),
+ Text(l.searchSources, style: Theme.of(context).textTheme.titleSmall),
+ for (final account in accounts) ...[
+ Padding(
+ padding: const EdgeInsetsDirectional.only(top: 12, bottom: 4),
+ child: Text(
+ account.displayLabel,
+ style: Theme.of(context).textTheme.labelLarge,
+ ),
+ ),
+ if (value.includesEvents)
+ for (final source in sources.where(
+ (s) => s.accountId == account.id && !s.isDeleted,
+ ))
+ CheckboxListTile(
+ contentPadding: EdgeInsets.zero,
+ controlAffinity: ListTileControlAffinity.leading,
+ title: Text(source.summary),
+ secondary: const Icon(Icons.calendar_month_outlined, size: 18),
+ value: value.sourceIds.contains(source.id),
+ onChanged: (selected) {
+ final ids = {...value.sourceIds};
+ if (selected == true) {
+ ids.add(source.id);
+ } else {
+ ids.remove(source.id);
+ }
+ onChanged(value.copyWith(sourceIds: ids));
+ },
+ ),
+ if (value.includesTasks)
+ for (final list in taskLists.where(
+ (s) => s.accountId == account.id && !s.pendingDelete,
+ ))
+ CheckboxListTile(
+ contentPadding: EdgeInsets.zero,
+ controlAffinity: ListTileControlAffinity.leading,
+ title: Text(list.title),
+ secondary: const Icon(Icons.checklist, size: 18),
+ value: value.taskListKeys.contains(
+ ScheduleTaskListKey(
+ accountId: list.accountId,
+ taskListId: list.id,
+ ),
+ ),
+ onChanged: (selected) {
+ final keys = {...value.taskListKeys};
+ final key = ScheduleTaskListKey(
+ accountId: list.accountId,
+ taskListId: list.id,
+ );
+ if (selected == true) {
+ keys.add(key);
+ } else {
+ keys.remove(key);
+ }
+ onChanged(value.copyWith(taskListKeys: keys));
+ },
+ ),
+ ],
+ if (!value.hasSources)
+ Padding(
+ padding: const EdgeInsetsDirectional.symmetric(vertical: 12),
+ child: Text(l.searchNoSources),
+ ),
+ const SizedBox(height: 12),
+ TextButton(onPressed: onClear, child: Text(l.searchClearFilters)),
+ ],
+ );
+ }
+
+ Widget _select(
+ String label,
+ T selected,
+ List values,
+ String Function(T) name,
+ ValueChanged changed,
+ ) => Padding(
+ padding: const EdgeInsetsDirectional.only(bottom: 12),
+ child: DropdownButtonFormField(
+ key: ValueKey((label, selected)),
+ initialValue: selected,
+ isExpanded: true,
+ decoration: InputDecoration(labelText: label),
+ items: [
+ for (final v in values)
+ DropdownMenuItem(value: v, child: Text(name(v))),
+ ],
+ onChanged: (v) {
+ if (v != null) changed(v);
+ },
+ ),
+ );
+
+ Widget _text(String label, String text, ValueChanged changed) =>
+ Padding(
+ padding: const EdgeInsetsDirectional.only(bottom: 16),
+ child: _SearchFilterText(label: label, value: text, onChanged: changed),
+ );
+
+ Widget _date(
+ BuildContext context,
+ String label,
+ DateTime date,
+ bool start,
+ ) => Padding(
+ padding: const EdgeInsetsDirectional.only(bottom: 8),
+ child: OutlinedButton(
+ onPressed: () async {
+ final picked = await showBusyMaxDatePicker(
+ context: context,
+ initialDate: date,
+ firstDate: DateTime(1900),
+ lastDate: DateTime(2200),
+ );
+ if (picked == null) return;
+ final from = start ? picked : value.customStart ?? value.referenceDate;
+ final to = start ? value.customEnd ?? value.referenceDate : picked;
+ onChanged(
+ value.copyWith(
+ customStart: from.isAfter(to) ? picked : from,
+ customEnd: to.isBefore(from) ? picked : to,
+ ),
+ );
+ },
+ child: Text(
+ '$label: ${DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag()).format(date)}',
+ ),
+ ),
+ );
+}
+
+class _SearchFilterText extends StatefulWidget {
+ const _SearchFilterText({
+ required this.label,
+ required this.value,
+ required this.onChanged,
+ });
+ final String label;
+ final String value;
+ final ValueChanged onChanged;
+ @override
+ State<_SearchFilterText> createState() => _SearchFilterTextState();
+}
+
+class _SearchFilterTextState extends State<_SearchFilterText> {
+ late final _controller = TextEditingController(text: widget.value);
+ @override
+ void didUpdateWidget(covariant _SearchFilterText oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (_controller.text != widget.value) _controller.text = widget.value;
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) => TextField(
+ controller: _controller,
+ decoration: InputDecoration(labelText: widget.label),
+ onChanged: widget.onChanged,
+ );
+}
diff --git a/lib/src/android/presentation/android_settings_screen.dart b/lib/src/android/presentation/android_settings_screen.dart
new file mode 100644
index 00000000..6da02e64
--- /dev/null
+++ b/lib/src/android/presentation/android_settings_screen.dart
@@ -0,0 +1,2245 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:busymax_android_platform/busymax_android_platform.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:intl/intl.dart';
+import 'package:package_info_plus/package_info_plus.dart';
+
+import '../../app/app_bootstrap.dart';
+import '../../calendar_providers/calendar_colors.dart';
+import '../../core/auth/oauth_models.dart';
+import '../../dav/dav_errors.dart';
+import '../../dav/mutation/dav_conflict_repository.dart';
+import '../../dav/nextcloud/nextcloud_sharing_service.dart';
+import '../../dav/nextcloud/nextcloud_trash_service.dart';
+import '../../features/accounts/data/accounts_repository.dart';
+import '../../features/accounts/domain/account_collection_creation_capabilities.dart';
+import '../../features/calendar/data/calendar_repository.dart';
+import '../../features/task_lists/data/task_lists_repository.dart';
+import '../../features/tasks/domain/task_capabilities.dart';
+import '../../ical/ical_import_service.dart';
+import '../../ical/ical_ingestion.dart';
+import '../../l10n/app_locale.dart';
+import '../../l10n/l10n.dart';
+import '../../l10n/week_preferences_scope.dart';
+import '../../providers/busy_provider.dart';
+import '../../webcal/webcal_subscription_service.dart';
+import '../android_background.dart';
+import '../android_notifications.dart';
+import 'android_diagnostics_screen.dart';
+import 'android_feedback_screen.dart';
+import 'android_nextcloud_scheduling_screen.dart';
+
+class AndroidSettingsScreen extends ConsumerStatefulWidget {
+ const AndroidSettingsScreen({super.key});
+
+ @override
+ ConsumerState createState() =>
+ _AndroidSettingsScreenState();
+}
+
+class _AndroidSettingsScreenState extends ConsumerState {
+ bool _syncing = false;
+ BusyProvider? _connecting;
+ final _removing = {};
+
+ @override
+ Widget build(BuildContext context) {
+ final accounts =
+ ref.watch(accountManagementStreamProvider).valueOrNull ?? const [];
+ final sources =
+ ref.watch(calendarSourcesStreamProvider).valueOrNull ?? const [];
+ final lists = ref.watch(scheduleTaskListsProvider).valueOrNull ?? const [];
+ final subscriptions =
+ ref.watch(webCalSubscriptionsProvider).valueOrNull ?? const [];
+ final conflicts =
+ ref.watch(davConflictsStreamProvider).valueOrNull ?? const [];
+ final settings = ref.watch(appSettingsControllerProvider);
+
+ return Scaffold(
+ appBar: AppBar(title: Text(context.l10n.settings)),
+ body: ListView(
+ padding: const EdgeInsets.fromLTRB(12, 8, 12, 96),
+ children: [
+ _Section(
+ title: context.l10n.accounts,
+ children: [
+ if (accounts.isEmpty)
+ ListTile(
+ leading: const Icon(Icons.account_circle_outlined),
+ title: Text(context.l10n.providerConnectionDescription),
+ ),
+ for (final account in accounts)
+ ListTile(
+ leading: Icon(_providerIcon(account.provider)),
+ title: Text(account.displayLabel),
+ subtitle: Text(_accountSubtitle(account)),
+ trailing: _removing.contains(account.id)
+ ? const SizedBox.square(
+ dimension: 22,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : PopupMenuButton(
+ onSelected: (value) {
+ if (value == 'sync') {
+ unawaited(_syncAccount(account.id));
+ } else if (value == 'reconnect') {
+ unawaited(_connect(account.provider, account));
+ } else if (value == 'remove') {
+ unawaited(_removeAccount(account));
+ }
+ },
+ itemBuilder: (context) => [
+ PopupMenuItem(
+ value: 'sync',
+ child: Text(context.l10n.sync),
+ ),
+ if (account.needsReconnect)
+ PopupMenuItem(
+ value: 'reconnect',
+ child: Text(context.l10n.connectAccountAction),
+ ),
+ PopupMenuItem(
+ value: 'remove',
+ child: Text(context.l10n.removeAccount),
+ ),
+ ],
+ ),
+ ),
+ const Divider(height: 1),
+ _AccountButtons(
+ connecting: _connecting,
+ onConnect: (provider) => unawaited(_connect(provider)),
+ ),
+ ],
+ ),
+ _Section(
+ title: context.l10n.collectionSettings,
+ children: [
+ if (accounts.any(
+ (account) =>
+ account.isSignedIn &&
+ account.calendarsEnabled &&
+ accountCollectionCreationModes(
+ account.provider,
+ ).calendarMode !=
+ CalendarCollectionCreationMode.unavailable,
+ ))
+ ListTile(
+ leading: const Icon(Icons.add_box_outlined),
+ title: Text(context.l10n.newCalendar),
+ onTap: () =>
+ unawaited(_createCollection(accounts, calendar: true)),
+ ),
+ if (accounts.any(
+ (account) =>
+ account.isSignedIn &&
+ account.tasksEnabled &&
+ accountCollectionCreationModes(
+ account.provider,
+ ).taskListMode !=
+ TaskListCreationMode.unavailable,
+ ))
+ ListTile(
+ leading: const Icon(Icons.playlist_add),
+ title: Text(context.l10n.newTaskList),
+ onTap: () =>
+ unawaited(_createCollection(accounts, calendar: false)),
+ ),
+ if (sources.isEmpty && lists.isEmpty)
+ ListTile(title: Text(context.l10n.noCalendarsSynced)),
+ for (final source in sources)
+ ListTile(
+ leading: const Icon(Icons.calendar_today_outlined),
+ title: Text(source.summary),
+ subtitle: Text(source.provider.displayName),
+ trailing: Switch(
+ value: source.selected,
+ onChanged: (value) => unawaited(
+ ref
+ .read(calendarRepositoryProvider)
+ .setSourceSelected(source.id, value),
+ ),
+ ),
+ onTap: () => unawaited(_editCalendarSource(source)),
+ ),
+ for (final list in lists)
+ ListTile(
+ leading: const Icon(Icons.checklist_outlined),
+ title: Text(list.title),
+ subtitle: Text(context.l10n.showInSchedule),
+ trailing: Switch(
+ value: settings.isTaskListVisibleInSchedule(
+ list.accountId,
+ list.id,
+ ),
+ onChanged: (value) => unawaited(
+ ref
+ .read(appSettingsControllerProvider.notifier)
+ .setTaskListVisibleInSchedule(
+ accountId: list.accountId,
+ taskListId: list.id,
+ visible: value,
+ ),
+ ),
+ ),
+ onTap: () => unawaited(_editTaskList(list, accounts)),
+ ),
+ for (final account in accounts.where(
+ (account) => account.provider == BusyProvider.nextcloud,
+ ))
+ ListTile(
+ leading: const Icon(Icons.delete_sweep_outlined),
+ title: Text(context.l10n.nextcloudTrash),
+ subtitle: Text(account.displayLabel),
+ onTap: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) =>
+ AndroidNextcloudTrashScreen(accountId: account.id),
+ ),
+ ),
+ ),
+ ],
+ ),
+ _Section(
+ title: context.l10n.calendarSubscriptions,
+ children: [
+ ListTile(
+ leading: const Icon(Icons.add_link),
+ title: Text(context.l10n.addCalendarSubscription),
+ subtitle: Text(context.l10n.calendarSubscriptionsDescription),
+ onTap: () => unawaited(_addSubscription()),
+ ),
+ for (final subscription in subscriptions)
+ ListTile(
+ leading: const Icon(Icons.public),
+ title: Text(subscription.name),
+ subtitle: Text(
+ subscription.lastFailureCode == null
+ ? context.l10n.subscriptionSafeOrigin(
+ subscription.safeOrigin,
+ )
+ : context.l10n.subscriptionStatusIssue(
+ subscription.lastFailureCode!,
+ ),
+ ),
+ trailing: PopupMenuButton(
+ onSelected: (value) =>
+ unawaited(_subscriptionAction(subscription, value)),
+ itemBuilder: (context) => [
+ PopupMenuItem(
+ value: 'refresh',
+ child: Text(context.l10n.refreshNow),
+ ),
+ PopupMenuItem(
+ value: 'rename',
+ child: Text(context.l10n.rename),
+ ),
+ PopupMenuItem(
+ value: 'remove',
+ child: Text(context.l10n.unsubscribe),
+ ),
+ ],
+ ),
+ ),
+ ListTile(
+ leading: const Icon(Icons.file_open_outlined),
+ title: Text(context.l10n.importIcsFile),
+ subtitle: Text(context.l10n.calendarImportDescription),
+ onTap: () => unawaited(showAndroidIcsImport(context, ref)),
+ ),
+ ],
+ ),
+ _Section(
+ title: context.l10n.appearance,
+ children: [
+ ListTile(
+ leading: const Icon(Icons.palette_outlined),
+ title: Text(context.l10n.theme),
+ trailing: DropdownButton(
+ value: settings.themeModePreference,
+ onChanged: (value) {
+ if (value != null) {
+ unawaited(
+ ref
+ .read(appSettingsControllerProvider.notifier)
+ .setThemeModePreference(value),
+ );
+ }
+ },
+ items: [
+ DropdownMenuItem(
+ value: BusyMaxThemeModePreference.system,
+ child: Text(context.l10n.themeSystem),
+ ),
+ DropdownMenuItem(
+ value: BusyMaxThemeModePreference.light,
+ child: Text(context.l10n.themeLight),
+ ),
+ DropdownMenuItem(
+ value: BusyMaxThemeModePreference.dark,
+ child: Text(context.l10n.themeDark),
+ ),
+ ],
+ ),
+ ),
+ ListTile(
+ leading: const Icon(Icons.language),
+ title: Text(context.l10n.currentLocale),
+ trailing: DropdownButton(
+ value: settings.localeTag ?? 'system',
+ onChanged: (value) => unawaited(
+ ref
+ .read(appSettingsControllerProvider.notifier)
+ .setLocaleTag(value == 'system' ? null : value),
+ ),
+ items: [
+ DropdownMenuItem(
+ value: 'system',
+ child: Text(context.l10n.themeSystem),
+ ),
+ for (final option in busyMaxLocaleOptions)
+ DropdownMenuItem(
+ value: option.tag,
+ child: Text(option.endonym),
+ ),
+ ],
+ ),
+ ),
+ ListTile(
+ leading: const Icon(Icons.schedule),
+ title: Text(context.l10n.timeFormat),
+ trailing: DropdownButton(
+ value: settings.timeFormatPreference,
+ onChanged: (value) {
+ if (value != null) {
+ unawaited(
+ ref
+ .read(appSettingsControllerProvider.notifier)
+ .setTimeFormatPreference(value),
+ );
+ }
+ },
+ items: [
+ DropdownMenuItem(
+ value: BusyMaxTimeFormatPreference.system,
+ child: Text(context.l10n.themeSystem),
+ ),
+ DropdownMenuItem(
+ value: BusyMaxTimeFormatPreference.twelveHour,
+ child: Text(context.l10n.timeFormatTwelveHour),
+ ),
+ DropdownMenuItem(
+ value: BusyMaxTimeFormatPreference.twentyFourHour,
+ child: Text(context.l10n.timeFormatTwentyFourHour),
+ ),
+ ],
+ ),
+ ),
+ ListTile(
+ leading: const Icon(Icons.calendar_view_week_outlined),
+ title: Text(context.l10n.firstDayOfWeek),
+ subtitle: DropdownButtonHideUnderline(
+ child: DropdownButton(
+ isExpanded: true,
+ value: settings.firstDayOfWeekPreference,
+ onChanged: (value) {
+ if (value != null) {
+ unawaited(
+ ref
+ .read(appSettingsControllerProvider.notifier)
+ .setFirstDayOfWeekPreference(value),
+ );
+ }
+ },
+ items: [
+ for (final value
+ in BusyMaxFirstDayOfWeekPreference.values)
+ DropdownMenuItem(
+ value: value,
+ child: Text(
+ busyMaxFirstDayOfWeekPreferenceLabel(
+ context,
+ value,
+ ),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ _Section(
+ title: context.l10n.notifications,
+ children: [
+ _NotificationSwitch(
+ title: context.l10n.eventReminders,
+ value: settings.notifyEventReminders,
+ onChanged: (value) => _setNotificationSetting(
+ value,
+ ref
+ .read(appSettingsControllerProvider.notifier)
+ .setNotifyEventReminders,
+ ),
+ ),
+ _NotificationSwitch(
+ title: context.l10n.taskReminders,
+ value: settings.notifyTaskReminders,
+ onChanged: (value) => _setNotificationSetting(
+ value,
+ ref
+ .read(appSettingsControllerProvider.notifier)
+ .setNotifyTaskReminders,
+ ),
+ ),
+ SwitchListTile(
+ secondary: const Icon(Icons.today_outlined),
+ title: Text(context.l10n.notifyDueToday),
+ value: settings.notifyDueToday,
+ onChanged: (value) => unawaited(
+ _setNotificationSetting(
+ value,
+ ref
+ .read(appSettingsControllerProvider.notifier)
+ .setNotifyDueToday,
+ ),
+ ),
+ ),
+ _NotificationSwitch(
+ title: context.l10n.notifySyncFailures,
+ value: settings.notifySyncFailures,
+ onChanged: (value) => _setNotificationSetting(
+ value,
+ ref
+ .read(appSettingsControllerProvider.notifier)
+ .setNotifySyncFailures,
+ ),
+ ),
+ _NotificationSwitch(
+ title: context.l10n.notifyConflicts,
+ value: settings.notifyConflicts,
+ onChanged: (value) => _setNotificationSetting(
+ value,
+ ref
+ .read(appSettingsControllerProvider.notifier)
+ .setNotifyConflicts,
+ ),
+ ),
+ SwitchListTile(
+ secondary: const Icon(Icons.visibility_off_outlined),
+ title: Text(context.l10n.notificationDetailLevel),
+ subtitle: Text(context.l10n.notificationDetailPrivate),
+ value:
+ settings.notificationDetailLevel ==
+ NotificationDetailLevel.private,
+ onChanged: (value) => unawaited(
+ ref
+ .read(appSettingsControllerProvider.notifier)
+ .setNotificationDetailLevel(
+ value
+ ? NotificationDetailLevel.private
+ : NotificationDetailLevel.normal,
+ )
+ .then(
+ (_) => ref
+ .read(notificationReconcilerProvider)
+ .reconcile(),
+ ),
+ ),
+ ),
+ ListTile(
+ leading: const Icon(Icons.alarm),
+ title: Text(context.l10n.reminders),
+ subtitle: Text(
+ ref
+ .read(androidNotificationServiceProvider)
+ .precisionDiagnostic,
+ ),
+ trailing: TextButton(
+ onPressed: () => unawaited(_requestExactAlarms()),
+ child: Text(context.l10n.settingsSystem),
+ ),
+ ),
+ SwitchListTile(
+ secondary: const Icon(Icons.bedtime_outlined),
+ title: Text(context.l10n.quietHours),
+ subtitle: Text(context.l10n.quietHoursDescription),
+ value: settings.quietHoursEnabled,
+ onChanged: (value) => unawaited(
+ ref
+ .read(appSettingsControllerProvider.notifier)
+ .setQuietHoursEnabled(value)
+ .then(
+ (_) => ref
+ .read(notificationReconcilerProvider)
+ .reconcile(),
+ ),
+ ),
+ ),
+ if (settings.quietHoursEnabled) ...[
+ ListTile(
+ leading: const Icon(Icons.nights_stay_outlined),
+ title: Text(context.l10n.quietHoursStart),
+ trailing: Text(settings.quietHoursStart),
+ onTap: () => unawaited(_pickQuietTime(true)),
+ ),
+ ListTile(
+ leading: const Icon(Icons.wb_sunny_outlined),
+ title: Text(context.l10n.quietHoursEnd),
+ trailing: Text(settings.quietHoursEnd),
+ onTap: () => unawaited(_pickQuietTime(false)),
+ ),
+ ],
+ ListTile(
+ leading: const Icon(Icons.notifications_active_outlined),
+ title: Text(context.l10n.notifications),
+ trailing: const Icon(Icons.open_in_new),
+ onTap: () => unawaited(
+ BusyMaxAndroidPlatform.instance.openNotificationSettings(),
+ ),
+ ),
+ ],
+ ),
+ _Section(
+ title: context.l10n.sync,
+ children: [
+ ListTile(
+ leading: _syncing
+ ? const SizedBox.square(
+ dimension: 24,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Icon(Icons.sync),
+ title: Text(context.l10n.sync),
+ subtitle: Text(context.l10n.davCachedOfflineNotice),
+ enabled: !_syncing,
+ onTap: _syncing ? null : () => unawaited(_syncAll()),
+ ),
+ ListTile(
+ leading: const Icon(Icons.battery_saver_outlined),
+ title: Text(context.l10n.runInBackgroundWhenClosed),
+ subtitle: Text(context.l10n.launchAtLoginDescription),
+ onTap: () => unawaited(enqueueImmediateBusyMaxSync()),
+ ),
+ ListTile(
+ leading: const Icon(Icons.monitor_heart_outlined),
+ title: Text(context.l10n.diagnostics),
+ onTap: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => const AndroidDiagnosticsScreen(),
+ ),
+ ),
+ ),
+ ],
+ ),
+ _Section(
+ title: context.l10n.syncConflicts,
+ children: [
+ if (conflicts.isEmpty)
+ ListTile(
+ leading: const Icon(Icons.check_circle_outline),
+ title: Text(context.l10n.noBlockedPendingOperations),
+ ),
+ for (final conflict in conflicts)
+ ListTile(
+ leading: const Icon(Icons.warning_amber),
+ title: Text(conflict.itemTitle),
+ subtitle: Text(
+ '${conflict.accountLabel} · ${conflict.collectionName}\n${conflict.localEditSummary}',
+ ),
+ isThreeLine: true,
+ onTap: () => unawaited(_resolveConflict(conflict)),
+ ),
+ ],
+ ),
+ _Section(
+ title: context.l10n.privacy,
+ children: [
+ SwitchListTile(
+ secondary: const Icon(Icons.shield_outlined),
+ title: Text(context.l10n.redactTaskContentInDiagnostics),
+ value: settings.redactTaskContentInDiagnostics,
+ onChanged: (value) => unawaited(
+ ref
+ .read(appSettingsControllerProvider.notifier)
+ .setRedactTaskContentInDiagnostics(value),
+ ),
+ ),
+ ListTile(
+ leading: const Icon(Icons.privacy_tip_outlined),
+ title: Text(context.l10n.privacy),
+ enabled: ref
+ .read(buildConfigProvider)
+ .privacyPolicyUrl
+ .isNotEmpty,
+ onTap: () => unawaited(_openConfiguredLink('privacy')),
+ ),
+ ListTile(
+ leading: const Icon(Icons.support_agent_outlined),
+ title: Text(context.l10n.website),
+ enabled: ref.read(buildConfigProvider).supportUrl.isNotEmpty,
+ onTap: () => unawaited(_openConfiguredLink('support')),
+ ),
+ ListTile(
+ leading: const Icon(Icons.home_outlined),
+ title: Text(context.l10n.website),
+ onTap: () => unawaited(_openConfiguredLink('homepage')),
+ ),
+ ListTile(
+ leading: const Icon(Icons.info_outline),
+ title: Text(context.l10n.aboutBusyMax),
+ subtitle: Text(context.l10n.aboutBusyMaxDescription),
+ onTap: _showAbout,
+ ),
+ ListTile(
+ leading: const Icon(Icons.feedback_outlined),
+ title: Text(context.l10n.sendFeedback),
+ enabled: ref
+ .read(buildConfigProvider)
+ .feedbackEndpoint
+ .isNotEmpty,
+ onTap: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => const AndroidFeedbackScreen(),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+
+ String _accountSubtitle(AccountEntity account) {
+ final state = account.isSignedIn
+ ? context.l10n.signedInAccount
+ : account.authState;
+ final synced = account.lastSuccessfulSyncAtUtc;
+ return synced == null
+ ? '${account.provider.displayName} · $state'
+ : '${account.provider.displayName} · $state\n${context.l10n.davLastSuccessfulSync(DateFormat.yMd().add_jm().format(synced.toLocal()))}';
+ }
+
+ Future _connect(
+ BusyProvider provider, [
+ AccountEntity? reconnecting,
+ ]) async {
+ if (_connecting != null) return;
+ final localNetworkDenied = context.l10n.nextcloudOperationDenied;
+ String? email;
+ String? password;
+ String? server;
+ if (provider == BusyProvider.appleICloud) {
+ final input = await _appleCredentials(reconnecting?.email);
+ if (input == null) return;
+ (email, password) = input;
+ } else if (provider == BusyProvider.nextcloud) {
+ server = await _textPrompt(
+ title: context.l10n.connectNextcloudTitle,
+ label: context.l10n.nextcloudServerUrl,
+ initialValue: reconnecting?.authority,
+ helper: context.l10n.nextcloudBrowserAuthorizationHelp,
+ );
+ if (server == null || !mounted) return;
+ final uri = Uri.tryParse(server);
+ if (uri != null && await requiresAndroidLocalNetworkAccess(uri.host)) {
+ try {
+ final granted = await BusyMaxAndroidPlatform.instance
+ .requestLocalNetworkAccess();
+ if (!granted) {
+ _message(localNetworkDenied);
+ return;
+ }
+ } on PlatformException catch (error) {
+ _message(error.message ?? localNetworkDenied);
+ return;
+ }
+ }
+ }
+ setState(() => _connecting = provider);
+ try {
+ String? accountId;
+ switch (provider) {
+ case BusyProvider.google:
+ accountId =
+ (await ref.read(authRepositoryProvider).signIn()).accountId;
+ case BusyProvider.microsoft:
+ accountId =
+ (await ref.read(authRepositoryProvider).signInWithMicrosoft())
+ .accountId;
+ case BusyProvider.appleICloud:
+ final service = ref.read(davAccountOnboardingServiceProvider);
+ accountId = reconnecting == null
+ ? (await service.connectAppleICloud(
+ email: email!,
+ appSpecificPassword: password!,
+ )).accountId
+ : (await service.replaceAppleAppSpecificPassword(
+ accountId: reconnecting.id,
+ appSpecificPassword: password!,
+ )).accountId;
+ case BusyProvider.nextcloud:
+ final service = ref.read(davAccountOnboardingServiceProvider);
+ accountId = reconnecting == null
+ ? (await service.connectNextcloud(
+ enteredServer: server!,
+ )).accountId
+ : (await service.reconnectNextcloud(
+ accountId: reconnecting.id,
+ enteredServer: server!,
+ )).accountId;
+ case BusyProvider.webCal:
+ return;
+ }
+ if (accountId != null) unawaited(_syncAccount(accountId));
+ } on Object catch (error) {
+ if (error is OAuthException && error.code == 'OAuthSignInCancelled') {
+ return;
+ }
+ if (error is DavException && error.kind == DavErrorKind.cancelled) return;
+ _message('$error');
+ } finally {
+ if (mounted) setState(() => _connecting = null);
+ }
+ }
+
+ Future<(String, String)?> _appleCredentials(String? fixedEmail) {
+ return showDialog<(String, String)>(
+ context: context,
+ builder: (_) => _AndroidAppleCredentialsDialog(fixedEmail: fixedEmail),
+ );
+ }
+
+ Future _removeAccount(AccountEntity account) async {
+ final l10n = context.l10n;
+ var revoke = false;
+ final confirmed = await showDialog(
+ context: context,
+ builder: (dialogContext) => StatefulBuilder(
+ builder: (context, setDialogState) => AlertDialog(
+ title: Text(context.l10n.removeAccountTitle(account.displayLabel)),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(context.l10n.removeAccountConfirmation),
+ if (account.provider == BusyProvider.google)
+ CheckboxListTile(
+ contentPadding: EdgeInsets.zero,
+ title: Text(context.l10n.revokeGoogleAccess),
+ subtitle: Text(context.l10n.revokeGoogleAccessDescription),
+ value: revoke,
+ onChanged: (value) =>
+ setDialogState(() => revoke = value == true),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, false),
+ child: Text(context.l10n.cancel),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.pop(dialogContext, true),
+ child: Text(context.l10n.removeAccountAction),
+ ),
+ ],
+ ),
+ ),
+ );
+ if (confirmed != true) return;
+ setState(() => _removing.add(account.id));
+ try {
+ await ref.read(crossEngineAccountGateProvider).run(account.id, () async {
+ if (account.provider == BusyProvider.appleICloud ||
+ account.provider == BusyProvider.nextcloud) {
+ await ref
+ .read(davAccountOnboardingServiceProvider)
+ .removeAccount(account.id);
+ } else {
+ await ref
+ .read(authRepositoryProvider)
+ .removeAccount(
+ accountId: account.id,
+ revokeAuthorization: revoke,
+ );
+ }
+ });
+ await ref.read(notificationReconcilerProvider).reconcile();
+ } on Object {
+ _message(l10n.removeAccountFailed);
+ } finally {
+ if (mounted) setState(() => _removing.remove(account.id));
+ }
+ }
+
+ Future _syncAll() async {
+ final l10n = context.l10n;
+ setState(() => _syncing = true);
+ try {
+ await ref.read(allAccountsSyncRunnerProvider)();
+ await ref.read(notificationReconcilerProvider).reconcile();
+ _message(l10n.syncComplete);
+ } on Object catch (error) {
+ _message(l10n.syncFailed('$error'));
+ } finally {
+ if (mounted) setState(() => _syncing = false);
+ }
+ }
+
+ Future _syncAccount(String accountId) async {
+ final l10n = context.l10n;
+ try {
+ await ref.read(signedInSyncRunnerProvider)(accountId, true);
+ await ref.read(notificationReconcilerProvider).reconcile();
+ _message(l10n.syncComplete);
+ } on Object catch (error) {
+ _message(l10n.syncFailed('$error'));
+ }
+ }
+
+ Future _editCalendarSource(CalendarSourceEntity source) async {
+ final l10n = context.l10n;
+ final action = await showModalBottomSheet(
+ context: context,
+ showDragHandle: true,
+ builder: (context) => SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ListTile(title: Text(source.summary)),
+ if (source.capabilities.canRenameCalendar)
+ ListTile(
+ leading: const Icon(Icons.edit_outlined),
+ title: Text(context.l10n.rename),
+ onTap: () => Navigator.pop(context, 'rename'),
+ ),
+ if (source.capabilities.canChangeCalendarColor)
+ ListTile(
+ leading: const Icon(Icons.palette_outlined),
+ title: Text(context.l10n.calendarColor),
+ onTap: () => Navigator.pop(context, 'color'),
+ ),
+ ListTile(
+ leading: const Icon(Icons.notifications_outlined),
+ title: Text(context.l10n.reminders),
+ onTap: () => Navigator.pop(context, 'reminders'),
+ ),
+ if (source.provider == BusyProvider.nextcloud &&
+ source.davCollectionId != null)
+ ListTile(
+ leading: const Icon(Icons.inbox_outlined),
+ title: Text(context.l10n.nextcloudSchedulingInbox),
+ onTap: () => Navigator.pop(context, 'scheduling'),
+ ),
+ if (source.provider == BusyProvider.nextcloud &&
+ source.davCollectionId != null)
+ ListTile(
+ leading: const Icon(Icons.people_outline),
+ title: Text(context.l10n.nextcloudSharing),
+ onTap: () => Navigator.pop(context, 'sharing'),
+ ),
+ if (source.provider == BusyProvider.nextcloud &&
+ source.davCollectionId != null)
+ ListTile(
+ leading: const Icon(Icons.file_download_outlined),
+ title: Text(context.l10n.export),
+ onTap: () => Navigator.pop(context, 'export'),
+ ),
+ if (source.capabilities.canRemoveCalendar)
+ ListTile(
+ leading: const Icon(Icons.delete_outline),
+ title: Text(context.l10n.delete),
+ textColor: Theme.of(context).colorScheme.error,
+ iconColor: Theme.of(context).colorScheme.error,
+ onTap: () => Navigator.pop(context, 'delete'),
+ ),
+ ],
+ ),
+ ),
+ );
+ if (action == null) return;
+ try {
+ if (action == 'rename') {
+ final name = await _textPrompt(
+ title: l10n.calendar,
+ label: l10n.setCustomCalendarName,
+ initialValue: source.summary,
+ );
+ if (name != null && name != source.summary) {
+ await ref
+ .read(calendarRepositoryProvider)
+ .renameLocalSource(source.id, name);
+ _requestQueuedCalendarSync(source);
+ }
+ } else if (action == 'reminders') {
+ final reminders = await _booleanPrompt(
+ l10n.reminders,
+ source.remindersEnabled,
+ );
+ if (reminders == null) return;
+ await ref
+ .read(calendarRepositoryProvider)
+ .setSourceRemindersEnabled(source.id, reminders);
+ } else if (action == 'color') {
+ final choice = await _selectCalendarColor(source);
+ if (choice == null) return;
+ await ref
+ .read(calendarRepositoryProvider)
+ .setSourceColor(source.id, choice);
+ _requestQueuedCalendarSync(source);
+ } else if (action == 'export') {
+ await _exportCollection(source.accountId, source.davCollectionId!);
+ } else if (action == 'sharing') {
+ if (!mounted) return;
+ await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => AndroidNextcloudSharingScreen(
+ accountId: source.accountId,
+ collectionId: source.davCollectionId!,
+ title: source.summary,
+ ),
+ ),
+ );
+ } else if (action == 'scheduling') {
+ if (!mounted) return;
+ await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => AndroidNextcloudSchedulingScreen(
+ accountId: source.accountId,
+ collectionId: source.davCollectionId!,
+ ),
+ ),
+ );
+ } else if (action == 'delete') {
+ final remove =
+ source.capabilities.removalMode ==
+ CalendarRemovalMode.removeFromList;
+ final confirmed = await _confirm(
+ remove ? l10n.removeFromMyCalendars : l10n.delete,
+ remove
+ ? l10n.removeCalendarConfirmation(source.summary)
+ : l10n.deleteCalendarConfirmation(source.summary),
+ remove ? l10n.removeAction : l10n.delete,
+ );
+ if (!confirmed) return;
+ await ref.read(calendarRepositoryProvider).deleteLocalSource(source.id);
+ _requestQueuedCalendarSync(source);
+ }
+ } on Object catch (error) {
+ _message(l10n.calendarUpdateFailed('$error'));
+ }
+ }
+
+ Future