diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index c305212..25b9da6 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -23,6 +23,7 @@ jobs: ninja-build \ pkg-config \ libgtk-3-dev \ + libhandy-1-dev \ libstdc++-12-dev \ libsecret-1-dev \ libjsoncpp-dev \ diff --git a/README.md b/README.md index 08cdca8..2dfc930 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ It brings calendar events and tasks into a native-feeling Linux desktop interfac - Calendar views for day, week, month, year, and agenda planning. - Task creation with lists, due dates, reminders, and repeat options. - Event editing with calendar selection, time controls, repeat rules, and reminders. -- Compact agenda window for quick access to upcoming work. +- Tray shortcut for opening the main Agenda view. - Integrations with Google Calendar, Google Tasks, Microsoft Calendar, and Microsoft To Do. ## Screenshots @@ -67,22 +67,28 @@ It brings calendar events and tasks into a native-feeling Linux desktop interfac BusyMax event editor

-

- BusyMax compact agenda window - BusyMax compact agenda event details - BusyMax compact agenda event editor -

- ## Prerequisites - Flutter: https://docs.flutter.dev/install +- GTK 3 and libhandy development packages (`libgtk-3-dev` and + `libhandy-1-dev` on Ubuntu/Debian) - `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`, see [Google Setup](docs/google_setup.md) - `MICROSOFT_OAUTH_CLIENT_ID`, see [Microsoft Setup](docs/microsoft_setup.md) ## Run locally +Register the development launcher once so GNOME can associate BusyMax's native +Wayland windows with its desktop icon. The helper is idempotent and defaults to +the Flutter debug bundle: + +```bash +tools/install_linux_dev_desktop.sh +``` + +Then run BusyMax normally: + ```bash flutter run -d linux \ --dart-define=GOOGLE_OAUTH_CLIENT_ID= \ @@ -90,6 +96,10 @@ flutter run -d linux \ --dart-define=MICROSOFT_OAUTH_CLIENT_ID= ``` +Use `tools/install_linux_dev_desktop.sh --uninstall` to remove the development +launcher. Remove it before testing an installed Snap so the user-level launcher +does not take precedence; packaged Snaps register their own launcher. + ## Feedback submissions The native **Send feedback** form in the About dialog sends JSON to diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md index ef14f09..84c0fa8 100644 --- a/docs/beta_snap_release.md +++ b/docs/beta_snap_release.md @@ -7,7 +7,8 @@ OAuth-enabled Flutter build must run first. ## Prepare Required: Linux amd64, the Flutter Linux toolchain, snapd, Snapcraft with LXD, -`unsquashfs` from `squashfs-tools`, and BusyMax Store access for publishing. +the `libhandy-1-dev` build package, `unsquashfs` from `squashfs-tools`, and +BusyMax Store access for publishing. Check that the package versions match: @@ -113,12 +114,14 @@ snap run busymax Before upload, verify: -- Desktop search shows one BusyMax launcher; both main and Agenda windows open. +- Desktop search shows one BusyMax launcher; the tray Agenda action opens the + Agenda view in the main window. - Google and Microsoft sign-in complete successfully. - Tasks and events can be created, edited, completed, and deleted; a task created in Agenda appears immediately without manual refresh. - Accounts, settings, and data survive restart. -- Notifications and tray actions, including Agenda and Quit, work. +- Notifications and tray actions, including opening Agenda in the main window + and Quit, work. ## Upload To Beta diff --git a/docs/screenshots/agenda_window_agenda.png b/docs/screenshots/agenda_window_agenda.png deleted file mode 100644 index 6ec1ad3..0000000 Binary files a/docs/screenshots/agenda_window_agenda.png and /dev/null differ diff --git a/docs/screenshots/agenda_window_agenda_event_details.png b/docs/screenshots/agenda_window_agenda_event_details.png deleted file mode 100644 index bb5afac..0000000 Binary files a/docs/screenshots/agenda_window_agenda_event_details.png and /dev/null differ diff --git a/docs/screenshots/agenda_window_edit_event.png b/docs/screenshots/agenda_window_edit_event.png deleted file mode 100644 index 1fd96cb..0000000 Binary files a/docs/screenshots/agenda_window_edit_event.png and /dev/null differ diff --git a/l10n.yaml b/l10n.yaml index ee89381..80ed2b7 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -3,3 +3,7 @@ template-arb-file: app_en.arb output-dir: lib/l10n/generated output-localization-file: app_localizations.dart nullable-getter: false +header: | + // coverage:ignore-file + // GENERATED CODE - DO NOT MODIFY BY HAND + // ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb new file mode 100644 index 0000000..6a96363 --- /dev/null +++ b/lib/l10n/app_ar.arb @@ -0,0 +1,374 @@ +{ + "@@locale": "ar", + "appTitle": "BusyMax", + "connectGoogleAccount": "اربط حسابات Google وMicrosoft لمزامنة التقويمات والمهام.", + "googlePermissionsConsentNotice": "في شاشة أذونات Google، حدّد أذونات التقويم والمهام معًا.", + "googlePermissionsRequiredRetry": "أذونات تقويم Google وGoogle Tasks مطلوبة. حاول مرة أخرى وحدّد مربعي الاختيار.", + "finishSetup": "إنهاء الإعداد", + "continueSetup": "متابعة", + "onboardingSetupTitle": "إعداد BusyMax", + "onboardingAccountsStepTitle": "ربط الحسابات", + "onboardingAccountsStepDescription": "أضف جميع حسابات Google وMicrosoft التي تريد استخدامها. يزامن BusyMax التقويمات والأحداث وقوائم المهام والمهام من كل حساب.", + "onboardingPreferencesStepTitle": "اختيار إعدادات النظام", + "onboardingPreferencesStepDescription": "اضبط سلوك التطبيق على سطح المكتب والتذكيرات ومستوى تفاصيل الإشعارات والمظهر قبل فتح جدولك.", + "signInWithGoogle": "تسجيل الدخول باستخدام Google", + "signInWithMicrosoft": "تسجيل الدخول باستخدام Microsoft", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "هذه الخدمة غير مهيأة.", + "waitingForGoogleSignIn": "في انتظار تسجيل الدخول إلى Google...", + "waitingForMicrosoftSignIn": "في انتظار تسجيل الدخول إلى Microsoft...", + "microsoftSignInNotConfigured": "تسجيل الدخول إلى Microsoft غير مهيأ. اضبط MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "إلغاء", + "close": "إغلاق", + "exit": "خروج", + "options": "خيارات", + "hide": "إخفاء", + "show": "إظهار", + "export": "تصدير", + "save": "حفظ", + "settings": "الإعدادات", + "all": "الكل", + "calendarEvents": "الأحداث", + "calendarTasks": "المهام", + "calendar": "التقويم", + "calendars": "التقويمات", + "newEvent": "حدث جديد", + "refreshCalendar": "تحديث التقويم", + "openInProvider": "فتح في الخدمة", + "hideFromSchedule": "إخفاء من الجدول", + "showInSchedule": "إظهار في الجدول", + "noCalendarsSynced": "لم تتم مزامنة أي تقويمات بعد.", + "allDay": "طوال اليوم", + "moreItems": "+\u2068{count}\u2069 عناصر أخرى", + "noEventsOrTasks": "لا توجد أحداث أو مهام", + "scheduleLoading": "جارٍ تحميل الجدول...", + "scheduleUnavailable": "الجدول غير متاح", + "scheduleNoSources": "لا توجد تقويمات أو قوائم مهام ظاهرة", + "scheduleNoSourcesDescription": "اختر ما تريد إظهاره في الإعدادات، ثم حدّث الجدول.", + "scheduleSignInRequired": "ربط حساب", + "scheduleSignInDescription": "سجّل الدخول لمزامنة التقويمات والمهام.", + "scheduleNoSearchResults": "لا توجد أحداث أو مهام مطابقة", + "scheduleNoSearchResultsDescription": "جرّب بحثًا مختلفًا أو امسح عوامل التصفية الحالية.", + "refresh": "تحديث", + "trayOpenBusyMax": "فتح BusyMax", + "agendaLoadMoreOverdue": "تحميل المزيد من المهام المتأخرة", + "agendaLoadMoreNoDate": "تحميل المزيد من المهام بلا تاريخ", + "viewDay": "يوم", + "viewWeek": "أسبوع", + "viewMonth": "شهر", + "viewYear": "سنة", + "viewAgenda": "جدول الأعمال", + "scheduleSettings": "الجدول", + "scheduleDisplaySettings": "عرض الجدول", + "scheduleDisplayHoursDescription": "تفتح طريقتا عرض اليوم والأسبوع ضمن هذه الساعات. توسّع العناصر المبكرة والمتأخرة النطاق عند الحاجة.", + "scheduleDayStartsAt": "يبدأ اليوم في", + "scheduleDayEndsAt": "ينتهي اليوم في", + "sourceCalendar": "التقويم", + "sourceTaskList": "قائمة المهام", + "createChoiceTitle": "إنشاء", + "createEventAtTime": "حدث", + "createTaskAtDate": "مهمة", + "editEvent": "تعديل الحدث", + "eventTitle": "عنوان الحدث", + "location": "الموقع", + "timeSlot": "الفترة الزمنية", + "startDateTime": "تاريخ/وقت البدء", + "endDateTime": "تاريخ/وقت الانتهاء", + "doesNotRepeat": "لا يتكرر", + "defaultReminder": "التذكير الافتراضي", + "guests": "المدعوون", + "noGuests": "لا يوجد مدعوون", + "description": "الوصف", + "availabilityShowAs": "التوفر / إظهار كـ", + "busy": "مشغول", + "visibility": "إمكانية العرض", + "defaultVisibility": "إمكانية العرض الافتراضية", + "conference": "اجتماع", + "noConference": "لا يوجد اجتماع", + "providerCalendar": "تقويم الخدمة", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "عريض", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "مائل", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "تحته خط", + "reminderMinutesBefore": "{minutes, plural, =0{عند البدء} =1{قبل دقيقة واحدة} =2{قبل دقيقتين} few{قبل \u2068{minutes}\u2069 دقائق} many{قبل \u2068{minutes}\u2069 دقيقة} other{قبل \u2068{minutes}\u2069 دقيقة}}", + "reminderAtStart": "عند البدء", + "reminderHoursBefore": "{hours, plural, =0{عند البدء} =1{قبل ساعة واحدة} =2{قبل ساعتين} few{قبل \u2068{hours}\u2069 ساعات} many{قبل \u2068{hours}\u2069 ساعة} other{قبل \u2068{hours}\u2069 ساعة}}", + "reminderDaysBefore": "{days, plural, =0{في اليوم نفسه} =1{قبل يوم واحد} =2{قبل يومين} few{قبل \u2068{days}\u2069 أيام} many{قبل \u2068{days}\u2069 يومًا} other{قبل \u2068{days}\u2069 يوم}}", + "availabilityFree": "متاح", + "availabilityTentative": "مبدئي", + "availabilityOutOfOffice": "خارج المكتب", + "availabilityWorkingElsewhere": "العمل من مكان آخر", + "visibilityDefault": "افتراضي", + "visibilityPublic": "عام", + "visibilityPrivate": "خاص", + "visibilityConfidential": "سري", + "sensitivityNormal": "عادي", + "sensitivityPersonal": "شخصي", + "tasks": "المهام", + "allTasks": "كل المهام", + "tasksInList": "المهام في \u2068{title}\u2069", + "taskLists": "قوائم المهام", + "navigation": "التنقل", + "mainMenu": "القائمة الرئيسية", + "keyboardShortcuts": "اختصارات لوحة المفاتيح", + "shortcutGroupGeneral": "عام", + "shortcutKeyboardShortcutsDescription": "إظهار مرجع الاختصارات هذا", + "shortcutGroupNavigation": "التنقل", + "shortcutNextPeriod": "الفترة التالية", + "shortcutNextPeriodDescription": "الأسبوع التالي في عرض الأسبوع، والشهر التالي في عرض الشهر، وهكذا", + "shortcutPreviousPeriod": "الفترة السابقة", + "shortcutPreviousPeriodDescription": "الأسبوع السابق في عرض الأسبوع، والشهر السابق في عرض الشهر، وهكذا", + "shortcutJumpToToday": "الانتقال إلى اليوم", + "shortcutGroupView": "العرض", + "shortcutDayView": "عرض اليوم", + "shortcutWeekView": "عرض الأسبوع", + "shortcutMonthView": "عرض الشهر", + "shortcutYearView": "عرض السنة", + "shortcutAgendaView": "عرض جدول الأعمال", + "shortcutGroupCreateAndEdit": "الإنشاء والتعديل", + "shortcutSaveItem": "حفظ الحدث أو المهمة", + "shortcutDeleteItem": "حذف الحدث أو المهمة", + "shortcutGroupTaskEditing": "تعديل المهام", + "shortcutCancelEditing": "إلغاء التعديل", + "shortcutCancelEditingDescription": "إغلاق تعديل المهمة أو تفاصيلها", + "aboutBusyMax": "حول BusyMax", + "aboutBusyMaxDescription": "التقويم والمهام", + "license": "الترخيص", + "apacheLicenseName": "Apache License 2.0", + "website": "الموقع الإلكتروني", + "sourceCode": "الشيفرة المصدرية", + "reportAnIssue": "الإبلاغ عن مشكلة", + "sendFeedback": "إرسال الملاحظات", + "feedbackSubmit": "إرسال", + "feedbackCategory": "الفئة", + "feedbackSelectCategory": "اختر فئة", + "feedbackCategoryProblem": "مشكلة أو خلل", + "feedbackCategoryFeature": "طلب ميزة", + "feedbackCategoryPrivacySecurity": "مشكلة تتعلق بالخصوصية أو الأمان", + "feedbackCategoryUsability": "مشكلة في سهولة الاستخدام", + "feedbackCategoryOther": "أخرى", + "feedbackSubject": "الموضوع", + "feedbackDetailedMessage": "رسالة مفصّلة", + "feedbackReplyEmail": "البريد الإلكتروني للرد (اختياري)", + "feedbackIncludeTechnicalDetails": "تضمين التفاصيل التقنية", + "feedbackTechnicalDetailsDisclosure": "يضيف فقط إصدار نظام التشغيل Linux ولغة التطبيق ومنطقته. لا يتم تضمين أي سجلات أو بيانات حسابات أو أسماء ملفات أو معلومات تشخيصية أخرى.", + "feedbackCategoryRequired": "اختر فئة.", + "feedbackSubjectLengthError": "يجب أن يتراوح الموضوع بين 3 و120 حرفًا.", + "feedbackMessageLengthError": "يجب أن تتراوح الرسالة بين 10 و5,000 حرف.", + "feedbackInvalidEmail": "أدخل عنوان بريد إلكتروني صالحًا.", + "feedbackConnectionError": "تعذر الاتصال بـ BusyStack. تحقق من اتصالك وحاول مرة أخرى.", + "feedbackTimeoutError": "انتهت مهلة الطلب. لم تُمسح ملاحظاتك؛ حاول مرة أخرى.", + "feedbackRateLimitedError": "أُرسلت ملاحظات كثيرة جدًا من هذه الشبكة. انتظر وحاول مرة أخرى.", + "feedbackRejectedError": "رفض الخادم الإرسال. راجع الحقول وحاول مرة أخرى.", + "feedbackServerError": "يتعذر على BusyStack قبول ملاحظاتك الآن. لم تُمسح ملاحظاتك؛ حاول مرة أخرى.", + "feedbackSuccess": "تم إرسال الملاحظات. المرجع: \u2068{id}\u2069", + "toggleSidebar": "إظهار الشريط الجانبي أو إخفاؤه", + "showSidebar": "إظهار اللوحة الجانبية", + "hideSidebar": "إخفاء اللوحة الجانبية", + "accounts": "الحسابات", + "currentAccount": "الحساب الحالي", + "switchAccount": "تبديل الحساب", + "addGoogleAccount": "إضافة حساب Google", + "addMicrosoftAccount": "إضافة حساب Microsoft", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "تم تسجيل الدخول", + "removeAccount": "إزالة الحساب…", + "removingAccount": "جارٍ إزالة الحساب…", + "removeAccountDescription": "إيقاف المزامنة وإزالة بيانات هذا الحساب من هذا الجهاز.", + "removeAccountTitle": "إزالة \u2068{account}\u2069 من BusyMax؟", + "removeAccountConfirmation": "سيؤدي ذلك إلى حذف المهام والتقويمات والأحداث والتذكيرات والتغييرات غير المتصلة المعلّقة المخزّنة مؤقتًا من هذا الجهاز. ستُفقد التغييرات غير المتزامنة. لن يُحذف أي شيء من Google أو Microsoft.", + "revokeGoogleAccess": "إلغاء وصول BusyMax إلى حساب Google هذا أيضًا", + "revokeGoogleAccessDescription": "ستحتاج إلى منح الوصول مرة أخرى قبل إعادة الاتصال.", + "removeAccountAction": "إزالة الحساب", + "removeAccountFailed": "تعذر إكمال إزالة الحساب. حاول مرة أخرى.", + "accountRemovedGoogleRevokeFailed": "تمت إزالة الحساب من هذا الجهاز، لكن تعذر على BusyMax إلغاء الوصول إلى Google. يمكنك إلغاء الوصول من حسابك على Google.", + "newList": "قائمة جديدة", + "signInToViewTaskLists": "سجّل الدخول لعرض قوائم المهام.", + "noTaskListsSynced": "لم تتم مزامنة أي قوائم مهام بعد.", + "listActions": "إجراءات القائمة", + "rename": "إعادة تسمية", + "delete": "حذف", + "renameList": "إعادة تسمية القائمة", + "deleteList": "حذف القائمة", + "builtInMicrosoftList": "مدمجة", + "builtInMicrosoftListCannotRenameDelete": "لا يمكن إعادة تسمية قوائم Microsoft To Do المدمجة أو حذفها.", + "deleteListConfirmation": "حذف «\u2068{title}\u2069» من Google Tasks؟", + "deleteEvent": "حذف الحدث", + "title": "العنوان", + "create": "إنشاء", + "newTask": "مهمة جديدة", + "clearCompleted": "مسح المهام المكتملة", + "refreshList": "تحديث القائمة", + "refreshAll": "تحديث الكل", + "listRefreshed": "تم تحديث القائمة.", + "allTasksRefreshed": "تم تحديث جميع الحسابات.", + "exportedFile": "تم التصدير إلى \u2068{path}\u2069", + "exportFailed": "فشل التصدير: \u2068{error}\u2069", + "refreshFailed": "فشل التحديث: \u2068{error}\u2069", + "selectOrCreateTaskList": "اختر قائمة مهام أو أنشئ واحدة للبدء.", + "signInToViewTasks": "سجّل الدخول لعرض المهام.", + "noTasks": "لا توجد مهام.", + "noTasksYet": "لا توجد مهام بعد", + "noTasksYetMessage": "أنشئ مهمة أو حدّث حساباتك للبدء.", + "noTasksInList": "لا توجد مهام في هذه القائمة.", + "overdue": "متأخرة", + "today": "اليوم", + "tomorrow": "غدًا", + "upcoming": "القادمة", + "noDate": "بلا تاريخ", + "completed": "مكتملة", + "duePrefix": "مستحقة في \u2068{date}\u2069", + "dateTimeDisplay": "\u2068{date}\u2069 · \u2068{time}\u2069", + "taskDetails": "تفاصيل المهمة", + "editTask": "تعديل المهمة", + "noTaskSelected": "لم يتم تحديد مهمة.", + "noTaskSelectedHelper": "حدّد مهمة لعرض تفاصيلها وتعديلها.", + "taskUnavailable": "المهمة غير متاحة.", + "signInToEditTasks": "سجّل الدخول لتعديل المهام.", + "refreshTask": "تحديث المهمة", + "primarySection": "أساسي", + "statusSection": "الحالة", + "openStatus": "مفتوحة", + "doneStatus": "منجزة", + "notes": "ملاحظات", + "dueDate": "تاريخ الاستحقاق", + "clearDueDate": "مسح تاريخ الاستحقاق", + "dueTime": "وقت الاستحقاق", + "startDate": "تاريخ البدء", + "startTime": "وقت البدء", + "endDate": "تاريخ الانتهاء", + "endTime": "وقت الانتهاء", + "reminderDate": "تاريخ التذكير", + "reminderTime": "وقت التذكير", + "reminder": "تذكير", + "addReminder": "إضافة تذكير", + "addGuest": "إضافة مدعو", + "addGuestEmail": "إضافة بريد المدعو الإلكتروني", + "removeReminder": "إزالة التذكير", + "off": "إيقاف", + "repeat": "التكرار", + "repeatNone": "بلا تكرار", + "noneValue": "لا شيء", + "repeatDaily": "يوميًا", + "repeatWeekly": "أسبوعيًا", + "repeatMonthly": "شهريًا", + "repeatYearly": "سنويًا", + "importance": "الأهمية", + "importanceLow": "منخفضة", + "importanceNormal": "عادية", + "importanceHigh": "مرتفعة", + "categories": "الفئات", + "scheduleSection": "الجدول", + "dueGroup": "الاستحقاق", + "startGroup": "البدء", + "reminderGroup": "التذكير", + "organizationSection": "التنظيم", + "actionsSection": "الإجراءات", + "advancedSection": "متقدم", + "addCategory": "إضافة فئة", + "list": "القائمة", + "microsoftMoveUnsupported": "نقل المهام بين القوائم غير مدعوم لحسابات Microsoft To Do في هذا الإصدار.", + "createSubtask": "إنشاء مهمة فرعية", + "moveToTop": "نقل إلى الأعلى", + "deleteTask": "حذف المهمة", + "newSubtask": "مهمة فرعية جديدة", + "deleteTaskConfirmation": "حذف «\u2068{title}\u2069» من Google Tasks؟", + "metadata": "البيانات الوصفية", + "id": "المعرّف", + "etag": "ETag", + "updated": "آخر تحديث", + "parent": "المهمة الأصلية", + "position": "الموضع", + "webLink": "رابط الويب", + "assignment": "التعيين", + "localState": "الحالة المحلية", + "pendingSync": "في انتظار المزامنة", + "synced": "تمت المزامنة", + "account": "الحساب", + "sync": "المزامنة", + "manualFullSync": "مزامنة كاملة يدويًا", + "runInBackgroundWhenClosed": "متابعة التشغيل عند إغلاق النافذة", + "showTrayIcon": "إظهار أيقونة شريط النظام", + "startMinimizedToTray": "البدء مصغّرًا في شريط النظام", + "requiresTrayIcon": "يتطلب أيقونة شريط النظام.", + "syncComplete": "اكتملت المزامنة.", + "syncFailed": "فشلت المزامنة: \u2068{error}\u2069", + "notifySyncFailures": "إشعارات عند فشل المزامنة", + "notifyConflicts": "إشعارات عند حدوث تعارضات", + "notifyDueToday": "إشعارات المهام المستحقة اليوم", + "eventReminders": "تذكيرات الأحداث", + "taskReminders": "تذكيرات المهام", + "notificationDetailLevel": "مستوى تفاصيل الإشعارات", + "notificationDetailPrivate": "خاص", + "notificationDetailNormal": "عادي", + "quietHours": "ساعات الهدوء", + "quietHoursDescription": "إيقاف الإشعارات مؤقتًا خلال هذه الفترة.", + "quietHoursStart": "بداية ساعات الهدوء", + "quietHoursEnd": "نهاية ساعات الهدوء", + "notifications": "الإشعارات", + "appearance": "المظهر", + "theme": "السمة", + "themeSystem": "النظام", + "themeLight": "فاتحة", + "themeDark": "داكنة", + "themeFamily": "عائلة السمات", + "themeFamilyYaru": "سمة Ubuntu الأصلية (Yaru)", + "localization": "اللغة والمنطقة", + "currentLocale": "اللغة والمنطقة الحالية", + "privacy": "الخصوصية", + "redactTaskContentInDiagnostics": "إخفاء محتوى المهام في معلومات التشخيص", + "developerDiagnostics": "تشخيصات المطور", + "diagnostics": "التشخيصات", + "apiInspectorDisabled": "إظهار فاحص API", + "googleTasksApi": "واجهة Google Tasks API", + "discoveryRevision": "مراجعة Discovery: \u2068{revision}\u2069", + "implementedMethods": "الطرق المنفذة", + "supportsTasksScopes": "يدعم نطاقَي tasks وtasks.readonly", + "requiresTasksScope": "يتطلب نطاق tasks", + "blockedPendingOperations": "العمليات المعلّقة المحظورة", + "signInToInspectPendingOperations": "سجّل الدخول لفحص العمليات المعلّقة.", + "noBlockedPendingOperations": "لا توجد عمليات معلّقة محظورة.", + "operationActions": "إجراءات العملية", + "pendingOpListId": "القائمة=\u2068{id}\u2069", + "pendingOpTaskId": "المهمة=\u2068{id}\u2069", + "pendingOpAttempts": "المحاولات=\u2068{count}\u2069", + "retry": "إعادة المحاولة", + "discard": "تجاهل", + "discardChangesAction": "تجاهل التغييرات", + "discardChanges": "تجاهل التغييرات؟", + "discardChangesConfirmation": "سيؤدي ذلك إلى تجاهل التعديلات غير المحفوظة على هذه المهمة.", + "retryCompleted": "اكتملت إعادة المحاولة.", + "discardPendingOperation": "تجاهل العملية المعلّقة؟", + "discardPendingOperationConfirmation": "سيؤدي ذلك إلى إزالة العملية المحلية المحظورة. ستُحدّث البيانات من Google Tasks في المزامنة التالية.", + "pendingOperationDiscarded": "تم تجاهل العملية المعلّقة.", + "syncFailureNotificationTitle": "فشلت مزامنة BusyMax", + "syncFailureNotificationBody": "فشلت المزامنة في الخلفية. \u2068{message}\u2069", + "conflictNotificationTitle": "تعارض في مزامنة BusyMax", + "conflictNotificationBody": "تم حظر تغيير محلي معلّق. \u2068{summary}\u2069", + "dueTodayNotificationTitle": "المهام المستحقة اليوم", + "dueTodayNotificationBody": "{count, plural, =0{لا توجد مهام مستحقة اليوم.} =1{هناك مهمة واحدة مستحقة اليوم.} =2{هناك مهمتان مستحقتان اليوم.} few{هناك \u2068{count}\u2069 مهام مستحقة اليوم.} many{هناك \u2068{count}\u2069 مهمة مستحقة اليوم.} other{هناك \u2068{count}\u2069 مهمة مستحقة اليوم.}}", + "eventReminderNotificationTitle": "تذكير بحدث", + "taskReminderNotificationTitle": "تذكير بمهمة", + "eventReminderNotificationBody": "سيبدأ الحدث قريبًا.", + "taskReminderNotificationBody": "ستحلّ مهلة المهمة قريبًا.", + "notificationOpenAction": "فتح", + "notificationDetailsHidden": "التفاصيل مخفية وفقًا لإعدادات الخصوصية.", + "previousMonth": "الشهر السابق", + "nextMonth": "الشهر التالي", + "openMonthView": "فتح عرض الشهر", + "previousYear": "السنة السابقة", + "nextYear": "السنة التالية", + "openYearView": "فتح عرض السنة", + "weekNumberTooltip": "الأسبوع \u2068{number}\u2069", + "resizeAllDayPanel": "تغيير حجم لوحة اليوم الكامل", + "scheduleItemCount": "{count, plural, =0{لا عناصر} =1{عنصر واحد} =2{عنصران} few{\u2068{count}\u2069 عناصر} many{\u2068{count}\u2069 عنصرًا} other{\u2068{count}\u2069 عنصر}}", + "readOnlyCalendar": "هذا التقويم للقراءة فقط.", + "selectTimeZone": "اختيار المنطقة الزمنية", + "searchLocations": "البحث عن مواقع", + "noLocationsFound": "لم يتم العثور على مواقع", + "deleteCalendarConfirmation": "حذف «\u2068{title}\u2069»؟" +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 73ef7f3..dd8a9bb 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3,7 +3,7 @@ "appTitle": "BusyMax", "connectGoogleAccount": "Verbinden Sie Google- und Microsoft-Konten, um Kalender und Aufgaben zu synchronisieren.", "googlePermissionsConsentNotice": "Wählen Sie auf dem Google-Berechtigungsbildschirm sowohl Kalender- als auch Aufgabenberechtigungen aus.", - "googlePermissionsRequiredRetry": "Google Calendar- und Google Tasks-Berechtigungen sind erforderlich. Versuchen Sie es erneut und wählen Sie beide Kontrollkästchen aus.", + "googlePermissionsRequiredRetry": "Die Berechtigungen für Google Kalender und Google Tasks sind erforderlich. Versuchen Sie es erneut und aktivieren Sie beide Kontrollkästchen.", "finishSetup": "Einrichtung abschließen", "continueSetup": "Weiter", "onboardingSetupTitle": "BusyMax einrichten", @@ -40,28 +40,18 @@ "showInSchedule": "Im Zeitplan anzeigen", "noCalendarsSynced": "Noch keine Kalender synchronisiert.", "allDay": "Ganztägig", - "moreItems": "+{count} weitere", + "moreItems": "+{count} mehr", "noEventsOrTasks": "Keine Termine oder Aufgaben", - "trayAgendaLoading": "Agenda wird geladen...", - "trayAgendaSignInRequired": "Melden Sie sich an, um die Agenda anzuzeigen.", - "trayAgendaNoSources": "Keine sichtbaren Kalender oder Aufgabenlisten.", - "trayAgendaOpenBusyMax": "App öffnen", - "trayAgendaRefresh": "Aktualisieren", - "trayAgendaError": "Agenda nicht verfügbar", - "compactAgendaTitle": "Agenda", - "compactAgendaSubtitle": "Anstehend", - "compactAgendaOverdue": "Überfällig", - "compactAgendaClear": "Im Moment frei", - "compactAgendaOpenBusyMax": "BusyMax öffnen", - "compactAgendaHide": "Ausblenden", - "compactAgendaNewTask": "Neue Aufgabe", - "compactAgendaRetry": "Erneut versuchen", - "compactAgendaRefresh": "Aktualisieren", - "compactAgendaAllDay": "Ganztägig", - "compactAgendaDueToday": "Heute fällig", - "compactAgendaDueTomorrow": "Morgen fällig", - "compactAgendaDueOn": "Fällig {date}", - "compactAgendaMoreOverdue": "Weitere überfällige Aufgaben laden", + "scheduleLoading": "Zeitplan wird geladen...", + "scheduleUnavailable": "Zeitplan nicht verfügbar", + "scheduleNoSources": "Keine sichtbaren Kalender oder Aufgabenlisten", + "scheduleNoSourcesDescription": "Wählen Sie in den Einstellungen aus, was angezeigt werden soll, und aktualisieren Sie anschließend den Zeitplan.", + "scheduleSignInRequired": "Konto verbinden", + "scheduleSignInDescription": "Melden Sie sich an, um Kalender und Aufgaben zu synchronisieren.", + "scheduleNoSearchResults": "Keine passenden Termine oder Aufgaben", + "scheduleNoSearchResultsDescription": "Versuchen Sie es mit einer anderen Suche oder setzen Sie die aktuellen Filter zurück.", + "refresh": "Aktualisieren", + "trayOpenBusyMax": "BusyMax öffnen", "agendaLoadMoreOverdue": "Weitere überfällige Aufgaben laden", "agendaLoadMoreNoDate": "Weitere Aufgaben ohne Datum laden", "viewDay": "Tag", @@ -71,7 +61,7 @@ "viewAgenda": "Agenda", "scheduleSettings": "Zeitplan", "scheduleDisplaySettings": "Zeitplananzeige", - "scheduleDisplayHoursDescription": "Tages- und Wochenansicht öffnen innerhalb dieser Zeiten. Frühe und späte Einträge erweitern den Bereich bei Bedarf.", + "scheduleDisplayHoursDescription": "In der Tages- und Wochenansicht wird zunächst dieser Zeitraum angezeigt. Frühere oder spätere Einträge erweitern ihn bei Bedarf.", "scheduleDayStartsAt": "Tag beginnt um", "scheduleDayEndsAt": "Tag endet um", "sourceCalendar": "Kalender", @@ -97,6 +87,29 @@ "conference": "Konferenz", "noConference": "Keine Konferenz", "providerCalendar": "Anbieterkalender", + "formatBoldShortLabel": "F", + "formatBoldTooltip": "Fett", + "formatItalicShortLabel": "K", + "formatItalicTooltip": "Kursiv", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "Unterstrichen", + "reminderMinutesBefore": "{minutes, plural, =1{1 Minute vorher} other{{minutes} Minuten vorher}}", + "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "reminderAtStart": "Zum Startzeitpunkt", + "reminderHoursBefore": "{hours, plural, =1{1 Stunde vorher} other{{hours} Stunden vorher}}", + "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, + "reminderDaysBefore": "{days, plural, =1{1 Tag vorher} other{{days} Tage vorher}}", + "@reminderDaysBefore": {"placeholders": {"days": {"type": "int"}}}, + "availabilityFree": "Frei", + "availabilityTentative": "Mit Vorbehalt", + "availabilityOutOfOffice": "Abwesend", + "availabilityWorkingElsewhere": "An einem anderen Ort tätig", + "visibilityDefault": "Standard", + "visibilityPublic": "Öffentlich", + "visibilityPrivate": "Privat", + "visibilityConfidential": "Vertraulich", + "sensitivityNormal": "Normal", + "sensitivityPersonal": "Persönlich", "tasks": "Aufgaben", "allTasks": "Alle Aufgaben", "tasksInList": "Aufgaben in {title}", @@ -111,7 +124,7 @@ "shortcutNextPeriodDescription": "Nächste Woche in der Wochenansicht, nächster Monat in der Monatsansicht usw.", "shortcutPreviousPeriod": "Vorheriger Zeitraum", "shortcutPreviousPeriodDescription": "Vorherige Woche in der Wochenansicht, vorheriger Monat in der Monatsansicht usw.", - "shortcutJumpToToday": "Zu Heute springen", + "shortcutJumpToToday": "Zum heutigen Tag springen", "shortcutGroupView": "Ansicht", "shortcutDayView": "Tagesansicht", "shortcutWeekView": "Wochenansicht", @@ -124,12 +137,12 @@ "shortcutGroupTaskEditing": "Aufgabenbearbeitung", "shortcutCancelEditing": "Bearbeitung abbrechen", "shortcutCancelEditingDescription": "Aufgabenbearbeitung oder Aufgabendetails schließen", - "shortcutGroupCompactAgenda": "Kompakte Agenda", - "shortcutRefreshCompactAgendaDescription": "Das kompakte Agenda-Fenster aktualisieren", - "shortcutHideCompactAgendaDescription": "Das kompakte Agenda-Fenster ausblenden", "aboutBusyMax": "Über BusyMax", - "aboutBusyMaxDescription": "ToDo und Kalender", + "aboutBusyMaxDescription": "Kalender und Aufgaben", + "license": "Lizenz", + "apacheLicenseName": "Apache License 2.0", "website": "Website", + "sourceCode": "Quellcode", "reportAnIssue": "Problem melden", "sendFeedback": "Feedback senden", "feedbackSubmit": "Senden", @@ -142,7 +155,7 @@ "feedbackCategoryOther": "Sonstiges", "feedbackSubject": "Betreff", "feedbackDetailedMessage": "Ausführliche Nachricht", - "feedbackReplyEmail": "E-Mail-Adresse für Antwort (optional)", + "feedbackReplyEmail": "E-Mail-Adresse für Antworten (optional)", "feedbackIncludeTechnicalDetails": "Technische Details hinzufügen", "feedbackTechnicalDetailsDisclosure": "Fügt nur die Version Ihres Linux-Betriebssystems und die Spracheinstellung der Anwendung hinzu. Es werden keine Protokolle, Kontodaten, Dateinamen oder anderen Diagnosedaten hinzugefügt.", "feedbackCategoryRequired": "Wählen Sie eine Kategorie aus.", @@ -156,6 +169,8 @@ "feedbackServerError": "BusyStack kann Ihr Feedback derzeit nicht annehmen. Ihr Feedback wurde nicht gelöscht; versuchen Sie es erneut.", "feedbackSuccess": "Feedback gesendet. Referenz: {id}", "toggleSidebar": "Seitenleiste umschalten", + "showSidebar": "Seitenbereich anzeigen", + "hideSidebar": "Seitenbereich ausblenden", "accounts": "Konten", "currentAccount": "Aktuelles Konto", "switchAccount": "Konto wechseln", @@ -164,10 +179,17 @@ "googleProvider": "Google", "microsoftProvider": "Microsoft", "signedInAccount": "Angemeldet", - "signOutThisAccount": "Dieses Konto abmelden", - "revokeThisAccount": "Dieses Konto widerrufen", - "disconnectThisAccount": "Dieses Konto trennen", - "deleteLocalDataForThisAccount": "Lokale Daten für dieses Konto löschen", + "removeAccount": "Konto entfernen…", + "removingAccount": "Konto wird entfernt…", + "removeAccountDescription": "Synchronisierung beenden und die Daten dieses Kontos von diesem Gerät entfernen.", + "removeAccountTitle": "{account} aus BusyMax entfernen?", + "@removeAccountTitle": {"placeholders": {"account": {"type": "String"}}}, + "removeAccountConfirmation": "Dadurch werden zwischengespeicherte Aufgaben, Kalender, Termine, Erinnerungen und ausstehende Offline-Änderungen von diesem Gerät gelöscht. Nicht synchronisierte Änderungen gehen verloren. Bei Google oder Microsoft wird nichts gelöscht.", + "revokeGoogleAccess": "BusyMax-Zugriff auf dieses Google-Konto ebenfalls widerrufen", + "revokeGoogleAccessDescription": "Vor einer erneuten Verbindung müssen Sie den Zugriff wieder gewähren.", + "removeAccountAction": "Konto entfernen", + "removeAccountFailed": "Das Konto konnte nicht vollständig entfernt werden. Versuchen Sie es erneut.", + "accountRemovedGoogleRevokeFailed": "Das Konto wurde von diesem Gerät entfernt, aber BusyMax konnte den Google-Zugriff nicht widerrufen. Sie können ihn in Ihrem Google-Konto widerrufen.", "newList": "Neue Liste", "signInToViewTaskLists": "Melden Sie sich an, um Aufgabenlisten zu sehen.", "noTaskListsSynced": "Noch keine Aufgabenlisten synchronisiert.", @@ -179,7 +201,7 @@ "builtInMicrosoftList": "Integriert", "builtInMicrosoftListCannotRenameDelete": "Integrierte Microsoft To Do-Listen können nicht umbenannt oder gelöscht werden.", "deleteListConfirmation": "\"{title}\" aus Google Tasks löschen?", - "deleteEvent": "Ereignis löschen", + "deleteEvent": "Termin löschen", "title": "Titel", "create": "Erstellen", "newTask": "Neue Aufgabe", @@ -193,11 +215,11 @@ "exportFailed": "Export fehlgeschlagen: {error}", "@exportFailed": {"placeholders": {"error": {"type": "String"}}}, "refreshFailed": "Aktualisierung fehlgeschlagen: {error}", - "selectOrCreateTaskList": "Wählen oder erstellen Sie eine Aufgabenliste.", + "selectOrCreateTaskList": "Wählen oder erstellen Sie zunächst eine Aufgabenliste.", "signInToViewTasks": "Melden Sie sich an, um Aufgaben zu sehen.", "noTasks": "Keine Aufgaben.", "noTasksYet": "Noch keine Aufgaben", - "noTasksYetMessage": "Erstellen Sie eine Aufgabe oder aktualisieren Sie Ihre Konten.", + "noTasksYetMessage": "Erstellen Sie eine Aufgabe oder aktualisieren Sie Ihre Konten, um loszulegen.", "noTasksInList": "Keine Aufgaben in dieser Liste.", "overdue": "Überfällig", "today": "Heute", @@ -207,11 +229,6 @@ "completed": "Erledigt", "duePrefix": "Fällig {date}", "dateTimeDisplay": "{date}, {time}", - "searchTasks": "Aufgaben suchen", - "advancedFilters": "Erweiterte Filter", - "showCompleted": "Erledigte anzeigen", - "showHidden": "Ausgeblendete anzeigen", - "showAssigned": "Zugewiesene anzeigen", "taskDetails": "Aufgabendetails", "editTask": "Aufgabe bearbeiten", "noTaskSelected": "Keine Aufgabe ausgewählt.", @@ -262,7 +279,7 @@ "list": "Liste", "microsoftMoveUnsupported": "Das Verschieben zwischen Listen wird für Microsoft To Do-Konten in dieser Version nicht unterstützt.", "createSubtask": "Unteraufgabe erstellen", - "moveToTop": "Nach oben verschieben", + "moveToTop": "Ganz nach oben verschieben", "deleteTask": "Aufgabe löschen", "newSubtask": "Neue Unteraufgabe", "deleteTaskConfirmation": "\"{title}\" aus Google Tasks löschen?", @@ -270,7 +287,7 @@ "id": "ID", "etag": "ETag", "updated": "Aktualisiert", - "parent": "Übergeordnet", + "parent": "Übergeordnete Aufgabe", "position": "Position", "webLink": "Weblink", "assignment": "Zuweisung", @@ -278,38 +295,46 @@ "pendingSync": "Synchronisierung ausstehend", "synced": "Synchronisiert", "account": "Konto", - "signOut": "Abmelden", - "revokeGoogleAuthorization": "Google-Autorisierung widerrufen", - "deleteLocalData": "Lokale Daten löschen", - "deleteLocalDataConfirmation": "Dies entfernt das lokale Konto, synchronisierte Aufgaben und ausstehende Offline-Änderungen von diesem Gerät.", "sync": "Synchronisierung", "manualFullSync": "Manuelle vollständige Synchronisierung", + "runInBackgroundWhenClosed": "Nach dem Schließen des Fensters weiter ausführen", + "showTrayIcon": "Symbol im Benachrichtigungsbereich anzeigen", + "startMinimizedToTray": "Minimiert im Benachrichtigungsbereich starten", + "requiresTrayIcon": "Erfordert das Symbol im Benachrichtigungsbereich.", "syncComplete": "Synchronisierung abgeschlossen.", "syncFailed": "Synchronisierung fehlgeschlagen: {error}", "notifySyncFailures": "Benachrichtigungen bei Synchronisierungsfehlern", "notifyConflicts": "Benachrichtigungen bei Konflikten", "notifyDueToday": "Benachrichtigungen für heute fällige Aufgaben", + "eventReminders": "Terminerinnerungen", + "taskReminders": "Aufgabenerinnerungen", + "notificationDetailLevel": "Detailgrad der Benachrichtigungen", + "notificationDetailPrivate": "Privat", + "notificationDetailNormal": "Normal", + "quietHours": "Ruhezeiten", + "quietHoursDescription": "Benachrichtigungen während dieses Zeitraums pausieren.", + "quietHoursStart": "Beginn der Ruhezeit", + "quietHoursEnd": "Ende der Ruhezeit", "notifications": "Benachrichtigungen", "appearance": "Darstellung", - "theme": "Theme", + "theme": "Design", "themeSystem": "System", "themeLight": "Hell", "themeDark": "Dunkel", - "themeFamily": "Theme-Familie", - "themeFamilyYaru": "Natives Ubuntu (Yaru)", + "themeFamily": "Designfamilie", + "themeFamilyYaru": "Natives Ubuntu-Design (Yaru)", "localization": "Lokalisierung", "currentLocale": "Aktuelle Sprache", "privacy": "Datenschutz", "redactTaskContentInDiagnostics": "Aufgabeninhalte in Diagnosen schwärzen", - "detailedNotifications": "Detaillierte Benachrichtigungstexte", "developerDiagnostics": "Entwicklerdiagnose", "diagnostics": "Diagnose", "apiInspectorDisabled": "API-Inspektor anzeigen", "googleTasksApi": "Google Tasks API", "discoveryRevision": "Discovery-Revision: {revision}", "implementedMethods": "Implementierte Methoden", - "supportsTasksScopes": "Unterstützt tasks- und tasks.readonly-Berechtigungen", - "requiresTasksScope": "Benötigt tasks-Berechtigung", + "supportsTasksScopes": "Unterstützt die Berechtigungsbereiche tasks und tasks.readonly", + "requiresTasksScope": "Erfordert den Berechtigungsbereich tasks", "blockedPendingOperations": "Blockierte ausstehende Vorgänge", "signInToInspectPendingOperations": "Melden Sie sich an, um ausstehende Vorgänge zu prüfen.", "noBlockedPendingOperations": "Keine blockierten ausstehenden Vorgänge.", @@ -319,11 +344,12 @@ "pendingOpAttempts": "Versuche={count}", "retry": "Erneut versuchen", "discard": "Verwerfen", + "discardChangesAction": "Verwerfen", "discardChanges": "Änderungen verwerfen?", "discardChangesConfirmation": "Dies verwirft ungespeicherte Änderungen an dieser Aufgabe.", "retryCompleted": "Erneuter Versuch abgeschlossen.", "discardPendingOperation": "Ausstehenden Vorgang verwerfen?", - "discardPendingOperationConfirmation": "Dies entfernt den blockierten lokalen Vorgang. Die nächste Synchronisierung aktualisiert von Google Tasks.", + "discardPendingOperationConfirmation": "Dadurch wird der blockierte lokale Vorgang entfernt. Bei der nächsten Synchronisierung werden die Daten aus Google Tasks neu geladen.", "pendingOperationDiscarded": "Ausstehender Vorgang verworfen.", "syncFailureNotificationTitle": "BusyMax-Synchronisierung fehlgeschlagen", "syncFailureNotificationBody": "Hintergrundsynchronisierung fehlgeschlagen. {message}", @@ -331,5 +357,27 @@ "conflictNotificationBody": "Eine ausstehende lokale Änderung wurde blockiert. {summary}", "dueTodayNotificationTitle": "Heute fällige Aufgaben", "dueTodayNotificationBody": "{count, plural, =1{Eine Aufgabe ist heute fällig.} other{{count} Aufgaben sind heute fällig.}}", - "notificationDetailsHidden": "Details werden durch Datenschutzeinstellungen ausgeblendet." + "eventReminderNotificationTitle": "Terminerinnerung", + "taskReminderNotificationTitle": "Aufgabenerinnerung", + "eventReminderNotificationBody": "Der Termin beginnt bald.", + "taskReminderNotificationBody": "Die Aufgabe ist bald fällig.", + "notificationOpenAction": "Öffnen", + "notificationDetailsHidden": "Details werden durch Datenschutzeinstellungen ausgeblendet.", + "previousMonth": "Vorheriger Monat", + "nextMonth": "Nächster Monat", + "openMonthView": "Monatsansicht öffnen", + "previousYear": "Vorheriges Jahr", + "nextYear": "Nächstes Jahr", + "openYearView": "Jahresansicht öffnen", + "weekNumberTooltip": "Woche {number}", + "@weekNumberTooltip": {"placeholders": {"number": {"type": "int"}}}, + "resizeAllDayPanel": "Ganztägigen Bereich vergrößern oder verkleinern", + "scheduleItemCount": "{count, plural, =1{1 Eintrag} other{{count} Einträge}}", + "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, + "readOnlyCalendar": "Dieser Kalender ist schreibgeschützt.", + "selectTimeZone": "Zeitzone auswählen", + "searchLocations": "Orte suchen", + "noLocationsFound": "Keine Orte gefunden", + "deleteCalendarConfirmation": "\"{title}\" löschen?", + "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}} } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9b283bd..db12d05 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -39,31 +39,20 @@ "hideFromSchedule": "Hide from schedule", "showInSchedule": "Show in schedule", "noCalendarsSynced": "No calendars synced yet.", - "allDay": "All Day", + "allDay": "All day", "moreItems": "+{count} more", "@moreItems": {"placeholders": {"count": {"type": "int"}}}, "noEventsOrTasks": "No events or tasks", - "trayAgendaLoading": "Loading agenda...", - "trayAgendaSignInRequired": "Sign in to show agenda.", - "trayAgendaNoSources": "No visible calendars or task lists.", - "trayAgendaOpenBusyMax": "Open app", - "trayAgendaRefresh": "Refresh", - "trayAgendaError": "Agenda unavailable", - "compactAgendaTitle": "Agenda", - "compactAgendaSubtitle": "Upcoming", - "compactAgendaOverdue": "Overdue", - "compactAgendaClear": "Clear for now", - "compactAgendaOpenBusyMax": "Open BusyMax", - "compactAgendaHide": "Hide", - "compactAgendaNewTask": "New task", - "compactAgendaRetry": "Retry", - "compactAgendaRefresh": "Refresh", - "compactAgendaAllDay": "All day", - "compactAgendaDueToday": "Due today", - "compactAgendaDueTomorrow": "Due tomorrow", - "compactAgendaDueOn": "Due {date}", - "@compactAgendaDueOn": {"placeholders": {"date": {"type": "String"}}}, - "compactAgendaMoreOverdue": "Load more overdue tasks", + "scheduleLoading": "Loading schedule...", + "scheduleUnavailable": "Schedule unavailable", + "scheduleNoSources": "No visible calendars or task lists", + "scheduleNoSourcesDescription": "Choose what to show in Settings, then refresh.", + "scheduleSignInRequired": "Connect an account", + "scheduleSignInDescription": "Sign in to sync calendars and tasks.", + "scheduleNoSearchResults": "No matching events or tasks", + "scheduleNoSearchResultsDescription": "Try a different search or clear the current filters.", + "refresh": "Refresh", + "trayOpenBusyMax": "Open BusyMax", "agendaLoadMoreOverdue": "Load more overdue tasks", "agendaLoadMoreNoDate": "Load more no-date tasks", "viewDay": "Day", @@ -84,7 +73,7 @@ "editEvent": "Edit event", "eventTitle": "Event title", "location": "Location", - "timeSlot": "Time Slot", + "timeSlot": "Time slot", "startDateTime": "Start date/time", "endDateTime": "End date/time", "doesNotRepeat": "Does not repeat", @@ -99,6 +88,29 @@ "conference": "Conference", "noConference": "No conference", "providerCalendar": "Provider calendar", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "Bold", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "Italic", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "Underline", + "reminderMinutesBefore": "{minutes, plural, =1{1 minute before} other{{minutes} minutes before}}", + "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "reminderAtStart": "At start", + "reminderHoursBefore": "{hours, plural, =1{1 hour before} other{{hours} hours before}}", + "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, + "reminderDaysBefore": "{days, plural, =1{1 day before} other{{days} days before}}", + "@reminderDaysBefore": {"placeholders": {"days": {"type": "int"}}}, + "availabilityFree": "Free", + "availabilityTentative": "Tentative", + "availabilityOutOfOffice": "Out of office", + "availabilityWorkingElsewhere": "Working elsewhere", + "visibilityDefault": "Default", + "visibilityPublic": "Public", + "visibilityPrivate": "Private", + "visibilityConfidential": "Confidential", + "sensitivityNormal": "Normal", + "sensitivityPersonal": "Personal", "tasks": "Tasks", "allTasks": "All tasks", "tasksInList": "Tasks in {title}", @@ -127,12 +139,12 @@ "shortcutGroupTaskEditing": "Task editing", "shortcutCancelEditing": "Cancel editing", "shortcutCancelEditingDescription": "Close task editing or task details", - "shortcutGroupCompactAgenda": "Compact agenda", - "shortcutRefreshCompactAgendaDescription": "Refresh the compact agenda window", - "shortcutHideCompactAgendaDescription": "Hide the compact agenda window", "aboutBusyMax": "About BusyMax", - "aboutBusyMaxDescription": "ToDo and Calendar", + "aboutBusyMaxDescription": "Calendar and tasks", + "license": "License", + "apacheLicenseName": "Apache License 2.0", "website": "Website", + "sourceCode": "Source code", "reportAnIssue": "Report an issue", "sendFeedback": "Send feedback", "feedbackSubmit": "Submit", @@ -160,6 +172,8 @@ "feedbackSuccess": "Feedback sent. Reference: {id}", "@feedbackSuccess": {"placeholders": {"id": {"type": "String"}}}, "toggleSidebar": "Toggle Sidebar", + "showSidebar": "Show sidebar panel", + "hideSidebar": "Hide sidebar panel", "accounts": "Accounts", "currentAccount": "Current account", "switchAccount": "Switch account", @@ -168,10 +182,17 @@ "googleProvider": "Google", "microsoftProvider": "Microsoft", "signedInAccount": "Signed in", - "signOutThisAccount": "Sign out this account", - "revokeThisAccount": "Revoke this account", - "disconnectThisAccount": "Disconnect this account", - "deleteLocalDataForThisAccount": "Delete local data for this account", + "removeAccount": "Remove account…", + "removingAccount": "Removing account…", + "removeAccountDescription": "Stop syncing and remove this account’s data from this device.", + "removeAccountTitle": "Remove {account} from BusyMax?", + "@removeAccountTitle": {"placeholders": {"account": {"type": "String"}}}, + "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Nothing will be deleted from Google or Microsoft.", + "revokeGoogleAccess": "Also revoke BusyMax’s access to this Google Account", + "revokeGoogleAccessDescription": "You will need to grant access again before reconnecting.", + "removeAccountAction": "Remove account", + "removeAccountFailed": "Could not finish removing the account. Try again.", + "accountRemovedGoogleRevokeFailed": "The account was removed from this device, but BusyMax could not revoke Google access. You can revoke it from your Google Account.", "newList": "New list", "signInToViewTaskLists": "Sign in to view task lists.", "noTaskListsSynced": "No task lists synced yet.", @@ -220,11 +241,6 @@ "time": {"type": "String"} } }, - "searchTasks": "Search tasks", - "advancedFilters": "Advanced filters", - "showCompleted": "Show completed", - "showHidden": "Show hidden", - "showAssigned": "Show assigned", "taskDetails": "Task details", "editTask": "Edit Task", "noTaskSelected": "No task selected.", @@ -292,18 +308,27 @@ "pendingSync": "Pending sync", "synced": "Synced", "account": "Account", - "signOut": "Sign out", - "revokeGoogleAuthorization": "Revoke Google authorization", - "deleteLocalData": "Delete local data", - "deleteLocalDataConfirmation": "This removes the local account, synced tasks, and pending offline changes from this device.", "sync": "Sync", "manualFullSync": "Manual full sync", + "runInBackgroundWhenClosed": "Continue running when the window is closed", + "showTrayIcon": "Show tray icon", + "startMinimizedToTray": "Start minimized to the tray", + "requiresTrayIcon": "Requires the tray icon.", "syncComplete": "Sync complete.", "syncFailed": "Sync failed: {error}", "@syncFailed": {"placeholders": {"error": {"type": "String"}}}, "notifySyncFailures": "Notifications on sync failure", "notifyConflicts": "Notifications on conflicts", "notifyDueToday": "Due-today notifications", + "eventReminders": "Event reminders", + "taskReminders": "Task reminders", + "notificationDetailLevel": "Notification detail level", + "notificationDetailPrivate": "Private", + "notificationDetailNormal": "Normal", + "quietHours": "Quiet hours", + "quietHoursDescription": "Pause notifications during this period.", + "quietHoursStart": "Quiet hours start", + "quietHoursEnd": "Quiet hours end", "notifications": "Notifications", "appearance": "Appearance", "theme": "Theme", @@ -316,7 +341,6 @@ "currentLocale": "Current locale", "privacy": "Privacy", "redactTaskContentInDiagnostics": "Redact task content in diagnostics", - "detailedNotifications": "Detailed notification text", "developerDiagnostics": "Developer diagnostics", "diagnostics": "Diagnostics", "apiInspectorDisabled": "Show API inspector", @@ -338,6 +362,10 @@ "@pendingOpAttempts": {"placeholders": {"count": {"type": "int"}}}, "retry": "Retry", "discard": "Discard", + "discardChangesAction": "Discard", + "@discardChangesAction": { + "description": "Destructive confirmation button that throws away unsaved edits. Translate it distinctly from Cancel." + }, "discardChanges": "Discard changes?", "discardChangesConfirmation": "This discards unsaved edits to this task.", "retryCompleted": "Retry completed.", @@ -353,5 +381,27 @@ "dueTodayNotificationTitle": "Tasks due today", "dueTodayNotificationBody": "{count, plural, =1{One task is due today.} other{{count} tasks are due today.}}", "@dueTodayNotificationBody": {"placeholders": {"count": {"type": "int"}}}, - "notificationDetailsHidden": "Details are hidden by privacy settings." + "eventReminderNotificationTitle": "Event reminder", + "taskReminderNotificationTitle": "Task reminder", + "eventReminderNotificationBody": "Event starts soon.", + "taskReminderNotificationBody": "Task is due soon.", + "notificationOpenAction": "Open", + "notificationDetailsHidden": "Details are hidden by privacy settings.", + "previousMonth": "Previous month", + "nextMonth": "Next month", + "openMonthView": "Open month view", + "previousYear": "Previous year", + "nextYear": "Next year", + "openYearView": "Open year view", + "weekNumberTooltip": "Week {number}", + "@weekNumberTooltip": {"placeholders": {"number": {"type": "int"}}}, + "resizeAllDayPanel": "Resize the all-day panel", + "scheduleItemCount": "{count, plural, =1{1 item} other{{count} items}}", + "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, + "readOnlyCalendar": "This calendar is read-only.", + "selectTimeZone": "Select Timezone", + "searchLocations": "Search locations", + "noLocationsFound": "No locations found", + "deleteCalendarConfirmation": "Delete \"{title}\"?", + "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}} } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index ddc7368..cfd3fef 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -10,7 +10,7 @@ "onboardingAccountsStepTitle": "Conectar cuentas", "onboardingAccountsStepDescription": "Añade todas las cuentas de Google y Microsoft que quieras usar. BusyMax sincroniza calendarios, eventos, listas de tareas y tareas de cada cuenta.", "onboardingPreferencesStepTitle": "Elegir ajustes del sistema", - "onboardingPreferencesStepDescription": "Configura el comportamiento de escritorio, recordatorios, detalle de notificaciones y apariencia antes de abrir tu agenda.", + "onboardingPreferencesStepDescription": "Configura el comportamiento de la aplicación en el escritorio, los recordatorios, el nivel de detalle de las notificaciones y la apariencia antes de abrir tu agenda.", "signInWithGoogle": "Iniciar sesión con Google", "signInWithMicrosoft": "Iniciar sesión con Microsoft", "googleTasksProvider": "Google Tasks", @@ -35,33 +35,23 @@ "calendars": "Calendarios", "newEvent": "Nuevo evento", "refreshCalendar": "Actualizar calendario", - "openInProvider": "Abrir en proveedor", + "openInProvider": "Abrir en el proveedor", "hideFromSchedule": "Ocultar de la agenda", "showInSchedule": "Mostrar en la agenda", "noCalendarsSynced": "Aún no hay calendarios sincronizados.", "allDay": "Todo el día", "moreItems": "+{count} más", "noEventsOrTasks": "No hay eventos ni tareas", - "trayAgendaLoading": "Cargando agenda...", - "trayAgendaSignInRequired": "Inicia sesión para mostrar la agenda.", - "trayAgendaNoSources": "No hay calendarios ni listas de tareas visibles.", - "trayAgendaOpenBusyMax": "Abrir app", - "trayAgendaRefresh": "Actualizar", - "trayAgendaError": "Agenda no disponible", - "compactAgendaTitle": "Agenda", - "compactAgendaSubtitle": "Próximamente", - "compactAgendaOverdue": "Vencidas", - "compactAgendaClear": "Libre por ahora", - "compactAgendaOpenBusyMax": "Abrir BusyMax", - "compactAgendaHide": "Ocultar", - "compactAgendaNewTask": "Nueva tarea", - "compactAgendaRetry": "Reintentar", - "compactAgendaRefresh": "Actualizar", - "compactAgendaAllDay": "Todo el día", - "compactAgendaDueToday": "Vence hoy", - "compactAgendaDueTomorrow": "Vence mañana", - "compactAgendaDueOn": "Vence {date}", - "compactAgendaMoreOverdue": "Cargar más tareas vencidas", + "scheduleLoading": "Cargando la agenda...", + "scheduleUnavailable": "Agenda no disponible", + "scheduleNoSources": "No hay calendarios ni listas de tareas visibles", + "scheduleNoSourcesDescription": "Elige qué mostrar en Configuración y, después, actualiza la agenda.", + "scheduleSignInRequired": "Conectar una cuenta", + "scheduleSignInDescription": "Inicia sesión para sincronizar calendarios y tareas.", + "scheduleNoSearchResults": "No hay eventos ni tareas coincidentes", + "scheduleNoSearchResultsDescription": "Prueba con otra búsqueda o borra los filtros actuales.", + "refresh": "Actualizar", + "trayOpenBusyMax": "Abrir BusyMax", "agendaLoadMoreOverdue": "Cargar más tareas vencidas", "agendaLoadMoreNoDate": "Cargar más tareas sin fecha", "viewDay": "Día", @@ -71,7 +61,7 @@ "viewAgenda": "Agenda", "scheduleSettings": "Agenda", "scheduleDisplaySettings": "Visualización de agenda", - "scheduleDisplayHoursDescription": "Las vistas Día y Semana se abren dentro de este horario. Los elementos tempranos y tardíos amplían el intervalo si hace falta.", + "scheduleDisplayHoursDescription": "Las vistas de día y semana muestran inicialmente este intervalo horario. Los elementos anteriores o posteriores lo amplían cuando es necesario.", "scheduleDayStartsAt": "El día empieza a las", "scheduleDayEndsAt": "El día termina a las", "sourceCalendar": "Calendario", @@ -97,6 +87,29 @@ "conference": "Conferencia", "noConference": "Sin conferencia", "providerCalendar": "Calendario del proveedor", + "formatBoldShortLabel": "N", + "formatBoldTooltip": "Negrita", + "formatItalicShortLabel": "C", + "formatItalicTooltip": "Cursiva", + "formatUnderlineShortLabel": "S", + "formatUnderlineTooltip": "Subrayado", + "reminderMinutesBefore": "{minutes, plural, =1{1 minuto antes} other{{minutes} minutos antes}}", + "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "reminderAtStart": "A la hora de inicio", + "reminderHoursBefore": "{hours, plural, =1{1 hora antes} other{{hours} horas antes}}", + "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, + "reminderDaysBefore": "{days, plural, =1{1 día antes} other{{days} días antes}}", + "@reminderDaysBefore": {"placeholders": {"days": {"type": "int"}}}, + "availabilityFree": "Libre", + "availabilityTentative": "Provisional", + "availabilityOutOfOffice": "Fuera de la oficina", + "availabilityWorkingElsewhere": "Trabajando en otro lugar", + "visibilityDefault": "Predeterminada", + "visibilityPublic": "Pública", + "visibilityPrivate": "Privada", + "visibilityConfidential": "Confidencial", + "sensitivityNormal": "Normal", + "sensitivityPersonal": "Personal", "tasks": "Tareas", "allTasks": "Todas las tareas", "tasksInList": "Tareas en {title}", @@ -111,7 +124,7 @@ "shortcutNextPeriodDescription": "Semana siguiente en la vista semanal, mes siguiente en la vista mensual, etc.", "shortcutPreviousPeriod": "Periodo anterior", "shortcutPreviousPeriodDescription": "Semana anterior en la vista semanal, mes anterior en la vista mensual, etc.", - "shortcutJumpToToday": "Ir a hoy", + "shortcutJumpToToday": "Ir a la fecha de hoy", "shortcutGroupView": "Vista", "shortcutDayView": "Vista de día", "shortcutWeekView": "Vista de semana", @@ -124,12 +137,12 @@ "shortcutGroupTaskEditing": "Edición de tareas", "shortcutCancelEditing": "Cancelar edición", "shortcutCancelEditingDescription": "Cerrar la edición o los detalles de la tarea", - "shortcutGroupCompactAgenda": "Agenda compacta", - "shortcutRefreshCompactAgendaDescription": "Actualizar la ventana de agenda compacta", - "shortcutHideCompactAgendaDescription": "Ocultar la ventana de agenda compacta", "aboutBusyMax": "Acerca de BusyMax", - "aboutBusyMaxDescription": "ToDo y calendario", + "aboutBusyMaxDescription": "Calendario y tareas", + "license": "Licencia", + "apacheLicenseName": "Apache License 2.0", "website": "Sitio web", + "sourceCode": "Código fuente", "reportAnIssue": "Informar de un problema", "sendFeedback": "Enviar comentarios", "feedbackSubmit": "Enviar", @@ -142,7 +155,7 @@ "feedbackCategoryOther": "Otro", "feedbackSubject": "Asunto", "feedbackDetailedMessage": "Mensaje detallado", - "feedbackReplyEmail": "Correo electrónico de respuesta (opcional)", + "feedbackReplyEmail": "Correo electrónico para recibir una respuesta (opcional)", "feedbackIncludeTechnicalDetails": "Incluir detalles técnicos", "feedbackTechnicalDetailsDisclosure": "Incluye únicamente la versión del sistema operativo Linux y la configuración regional de la aplicación. No se incluyen registros, datos de cuenta, nombres de archivos ni otros diagnósticos.", "feedbackCategoryRequired": "Selecciona una categoría.", @@ -150,12 +163,14 @@ "feedbackMessageLengthError": "El mensaje debe tener entre 10 y 5.000 caracteres.", "feedbackInvalidEmail": "Introduce una dirección de correo electrónico válida.", "feedbackConnectionError": "No se pudo conectar con BusyStack. Comprueba tu conexión e inténtalo de nuevo.", - "feedbackTimeoutError": "La solicitud agotó el tiempo de espera. Tus comentarios no se han borrado; inténtalo de nuevo.", + "feedbackTimeoutError": "Se agotó el tiempo de espera de la solicitud. Tus comentarios no se han borrado; inténtalo de nuevo.", "feedbackRateLimitedError": "Se han enviado demasiados comentarios desde esta red. Espera e inténtalo de nuevo.", "feedbackRejectedError": "El servidor rechazó el envío. Revisa los campos e inténtalo de nuevo.", "feedbackServerError": "BusyStack no puede aceptar tus comentarios ahora. Tus comentarios no se han borrado; inténtalo de nuevo.", "feedbackSuccess": "Comentarios enviados. Referencia: {id}", - "toggleSidebar": "Alternar barra lateral", + "toggleSidebar": "Mostrar u ocultar la barra lateral", + "showSidebar": "Mostrar panel lateral", + "hideSidebar": "Ocultar panel lateral", "accounts": "Cuentas", "currentAccount": "Cuenta actual", "switchAccount": "Cambiar cuenta", @@ -164,10 +179,17 @@ "googleProvider": "Google", "microsoftProvider": "Microsoft", "signedInAccount": "Sesión iniciada", - "signOutThisAccount": "Cerrar sesión de esta cuenta", - "revokeThisAccount": "Revocar esta cuenta", - "disconnectThisAccount": "Desconectar esta cuenta", - "deleteLocalDataForThisAccount": "Eliminar datos locales de esta cuenta", + "removeAccount": "Eliminar cuenta…", + "removingAccount": "Eliminando cuenta…", + "removeAccountDescription": "Detener la sincronización y eliminar de este dispositivo los datos de esta cuenta.", + "removeAccountTitle": "¿Eliminar {account} de BusyMax?", + "@removeAccountTitle": {"placeholders": {"account": {"type": "String"}}}, + "removeAccountConfirmation": "Esto elimina de este dispositivo las tareas, los calendarios, los eventos, los recordatorios y los cambios sin conexión pendientes almacenados en caché. Los cambios no sincronizados se perderán. No se eliminará nada de Google ni Microsoft.", + "revokeGoogleAccess": "Revocar también el acceso de BusyMax a esta cuenta de Google", + "revokeGoogleAccessDescription": "Tendrás que volver a conceder acceso antes de reconectar la cuenta.", + "removeAccountAction": "Eliminar cuenta", + "removeAccountFailed": "No se pudo terminar de eliminar la cuenta. Inténtalo de nuevo.", + "accountRemovedGoogleRevokeFailed": "La cuenta se eliminó de este dispositivo, pero BusyMax no pudo revocar su acceso a tu cuenta de Google. Puedes revocarlo desde tu cuenta de Google.", "newList": "Nueva lista", "signInToViewTaskLists": "Inicia sesión para ver las listas de tareas.", "noTaskListsSynced": "Aún no hay listas de tareas sincronizadas.", @@ -193,7 +215,7 @@ "exportFailed": "Error al exportar: {error}", "@exportFailed": {"placeholders": {"error": {"type": "String"}}}, "refreshFailed": "Error al actualizar: {error}", - "selectOrCreateTaskList": "Selecciona o crea una lista de tareas.", + "selectOrCreateTaskList": "Selecciona o crea una lista de tareas para empezar.", "signInToViewTasks": "Inicia sesión para ver las tareas.", "noTasks": "No hay tareas.", "noTasksYet": "Aún no hay tareas", @@ -207,11 +229,6 @@ "completed": "Completadas", "duePrefix": "Vence {date}", "dateTimeDisplay": "{date}, {time}", - "searchTasks": "Buscar tareas", - "advancedFilters": "Filtros avanzados", - "showCompleted": "Mostrar completadas", - "showHidden": "Mostrar ocultas", - "showAssigned": "Mostrar asignadas", "taskDetails": "Detalles de la tarea", "editTask": "Editar tarea", "noTaskSelected": "No hay tarea seleccionada.", @@ -260,9 +277,9 @@ "advancedSection": "Avanzado", "addCategory": "Añadir categoría", "list": "Lista", - "microsoftMoveUnsupported": "Mover tareas entre listas no es compatible con las cuentas de Microsoft To Do en esta versión.", + "microsoftMoveUnsupported": "En esta versión, no se pueden mover tareas entre listas en cuentas de Microsoft To Do.", "createSubtask": "Crear subtarea", - "moveToTop": "Mover arriba", + "moveToTop": "Mover al principio", "deleteTask": "Eliminar tarea", "newSubtask": "Nueva subtarea", "deleteTaskConfirmation": "¿Eliminar \"{title}\" de Google Tasks?", @@ -270,7 +287,7 @@ "id": "ID", "etag": "ETag", "updated": "Actualizada", - "parent": "Padre", + "parent": "Tarea principal", "position": "Posición", "webLink": "Enlace web", "assignment": "Asignación", @@ -278,30 +295,38 @@ "pendingSync": "Sincronización pendiente", "synced": "Sincronizada", "account": "Cuenta", - "signOut": "Cerrar sesión", - "revokeGoogleAuthorization": "Revocar autorización de Google", - "deleteLocalData": "Eliminar datos locales", - "deleteLocalDataConfirmation": "Esto elimina de este dispositivo la cuenta local, las tareas sincronizadas y los cambios sin conexión pendientes.", "sync": "Sincronización", "manualFullSync": "Sincronización completa manual", + "runInBackgroundWhenClosed": "Seguir ejecutándose al cerrar la ventana", + "showTrayIcon": "Mostrar el icono en la bandeja del sistema", + "startMinimizedToTray": "Iniciar minimizado en la bandeja del sistema", + "requiresTrayIcon": "Requiere el icono de la bandeja del sistema.", "syncComplete": "Sincronización completa.", "syncFailed": "Error de sincronización: {error}", "notifySyncFailures": "Notificaciones de errores de sincronización", "notifyConflicts": "Notificaciones de conflictos", - "notifyDueToday": "Notificaciones de tareas para hoy", + "notifyDueToday": "Notificaciones de tareas que vencen hoy", + "eventReminders": "Recordatorios de eventos", + "taskReminders": "Recordatorios de tareas", + "notificationDetailLevel": "Nivel de detalle de las notificaciones", + "notificationDetailPrivate": "Privado", + "notificationDetailNormal": "Normal", + "quietHours": "Horario de silencio", + "quietHoursDescription": "Pausar las notificaciones durante este período.", + "quietHoursStart": "Inicio del horario de silencio", + "quietHoursEnd": "Fin del horario de silencio", "notifications": "Notificaciones", "appearance": "Apariencia", "theme": "Tema", "themeSystem": "Sistema", "themeLight": "Claro", "themeDark": "Oscuro", - "themeFamily": "Familia de tema", - "themeFamilyYaru": "Ubuntu nativo (Yaru)", + "themeFamily": "Familia de temas", + "themeFamilyYaru": "Tema nativo de Ubuntu (Yaru)", "localization": "Localización", "currentLocale": "Configuración regional actual", "privacy": "Privacidad", "redactTaskContentInDiagnostics": "Ocultar contenido de tareas en diagnósticos", - "detailedNotifications": "Texto detallado en notificaciones", "developerDiagnostics": "Diagnósticos de desarrollo", "diagnostics": "Diagnósticos", "apiInspectorDisabled": "Mostrar inspector de API", @@ -313,17 +338,18 @@ "blockedPendingOperations": "Operaciones pendientes bloqueadas", "signInToInspectPendingOperations": "Inicia sesión para inspeccionar operaciones pendientes.", "noBlockedPendingOperations": "No hay operaciones pendientes bloqueadas.", - "operationActions": "Acciones de operación", + "operationActions": "Acciones de la operación", "pendingOpListId": "lista={id}", "pendingOpTaskId": "tarea={id}", "pendingOpAttempts": "intentos={count}", "retry": "Reintentar", "discard": "Descartar", + "discardChangesAction": "Descartar", "discardChanges": "¿Descartar cambios?", "discardChangesConfirmation": "Esto descarta las ediciones no guardadas de esta tarea.", "retryCompleted": "Reintento completado.", "discardPendingOperation": "¿Descartar operación pendiente?", - "discardPendingOperationConfirmation": "Esto elimina la operación local bloqueada. La próxima sincronización actualizará desde Google Tasks.", + "discardPendingOperationConfirmation": "Esto elimina la operación local bloqueada. En la próxima sincronización, se volverán a cargar los datos desde Google Tasks.", "pendingOperationDiscarded": "Operación pendiente descartada.", "syncFailureNotificationTitle": "Falló la sincronización de BusyMax", "syncFailureNotificationBody": "Falló la sincronización en segundo plano. {message}", @@ -331,5 +357,27 @@ "conflictNotificationBody": "Se bloqueó un cambio local pendiente. {summary}", "dueTodayNotificationTitle": "Tareas que vencen hoy", "dueTodayNotificationBody": "{count, plural, =1{Una tarea vence hoy.} other{{count} tareas vencen hoy.}}", - "notificationDetailsHidden": "Los detalles están ocultos por la configuración de privacidad." + "eventReminderNotificationTitle": "Recordatorio de evento", + "taskReminderNotificationTitle": "Recordatorio de tarea", + "eventReminderNotificationBody": "El evento empieza pronto.", + "taskReminderNotificationBody": "La tarea vence pronto.", + "notificationOpenAction": "Abrir", + "notificationDetailsHidden": "Los detalles están ocultos por la configuración de privacidad.", + "previousMonth": "Mes anterior", + "nextMonth": "Mes siguiente", + "openMonthView": "Abrir la vista mensual", + "previousYear": "Año anterior", + "nextYear": "Año siguiente", + "openYearView": "Abrir la vista anual", + "weekNumberTooltip": "Semana {number}", + "@weekNumberTooltip": {"placeholders": {"number": {"type": "int"}}}, + "resizeAllDayPanel": "Cambiar el tamaño del panel de todo el día", + "scheduleItemCount": "{count, plural, =1{1 elemento} other{{count} elementos}}", + "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, + "readOnlyCalendar": "Este calendario es de solo lectura.", + "selectTimeZone": "Seleccionar zona horaria", + "searchLocations": "Buscar ubicaciones", + "noLocationsFound": "No se encontraron ubicaciones", + "deleteCalendarConfirmation": "¿Eliminar \"{title}\"?", + "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}} } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb new file mode 100644 index 0000000..6b952dc --- /dev/null +++ b/lib/l10n/app_et.arb @@ -0,0 +1,404 @@ +{ + "@@locale": "et", + "appTitle": "BusyMax", + "connectGoogleAccount": "Ühendage Google'i ja Microsofti kontod kalendrite ja ülesannete sünkroonimiseks.", + "googlePermissionsConsentNotice": "Valige Google'i õiguste kuval nii kalendri kui ka ülesannete õigused.", + "googlePermissionsRequiredRetry": "Google Calendari ja Google Tasksi õigused on nõutavad. Proovige uuesti ja märkige mõlemad ruudud.", + "finishSetup": "Lõpeta seadistamine", + "continueSetup": "Jätka", + "onboardingSetupTitle": "BusyMaxi seadistamine", + "onboardingAccountsStepTitle": "Kontode ühendamine", + "onboardingAccountsStepDescription": "Lisage kõik Google'i ja Microsofti kontod, mida soovite kasutada. BusyMax sünkroonib iga konto kalendrid, sündmused, ülesandeloendid ja ülesanded.", + "onboardingPreferencesStepTitle": "Süsteemiseadete valimine", + "onboardingPreferencesStepDescription": "Enne ajakava avamist määrake töölauakäitumine, meeldetuletused, teavituste üksikasjalikkus ja välimus.", + "signInWithGoogle": "Logi Google'iga sisse", + "signInWithMicrosoft": "Logi Microsoftiga sisse", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "See teenusepakkuja pole seadistatud.", + "waitingForGoogleSignIn": "Google'isse sisselogimise ootel...", + "waitingForMicrosoftSignIn": "Microsofti sisselogimise ootel...", + "microsoftSignInNotConfigured": "Microsofti sisselogimine pole seadistatud. Määrake MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "Tühista", + "close": "Sulge", + "exit": "Välju", + "options": "Valikud", + "hide": "Peida", + "show": "Kuva", + "export": "Ekspordi", + "save": "Salvesta", + "settings": "Seaded", + "all": "Kõik", + "calendarEvents": "Sündmused", + "calendarTasks": "Ülesanded", + "calendar": "Kalender", + "calendars": "Kalendrid", + "newEvent": "Uus sündmus", + "refreshCalendar": "Värskenda kalendrit", + "openInProvider": "Ava teenuses", + "hideFromSchedule": "Peida ajakavast", + "showInSchedule": "Kuva ajakavas", + "noCalendarsSynced": "Ühtegi kalendrit pole veel sünkroonitud.", + "allDay": "Kogu päev", + "moreItems": "+{count} veel", + "@moreItems": {"placeholders": {"count": {"type": "int"}}}, + "noEventsOrTasks": "Sündmusi ega ülesandeid pole", + "scheduleLoading": "Ajakava laadimine...", + "scheduleUnavailable": "Ajakava pole saadaval", + "scheduleNoSources": "Nähtavaid kalendreid ega ülesandeloendeid pole", + "scheduleNoSourcesDescription": "Valige seadetes, mida kuvada, ja seejärel värskendage.", + "scheduleSignInRequired": "Ühendage konto", + "scheduleSignInDescription": "Kalendrite ja ülesannete sünkroonimiseks logige sisse.", + "scheduleNoSearchResults": "Sobivaid sündmusi ega ülesandeid ei leitud", + "scheduleNoSearchResultsDescription": "Proovige teistsugust otsingut või eemaldage praegused filtrid.", + "refresh": "Värskenda", + "trayOpenBusyMax": "Ava BusyMax", + "agendaLoadMoreOverdue": "Laadi veel tähtaja ületanud ülesandeid", + "agendaLoadMoreNoDate": "Laadi veel kuupäevata ülesandeid", + "viewDay": "Päev", + "viewWeek": "Nädal", + "viewMonth": "Kuu", + "viewYear": "Aasta", + "viewAgenda": "Päevakava", + "scheduleSettings": "Ajakava", + "scheduleDisplaySettings": "Ajakava kuvamine", + "scheduleDisplayHoursDescription": "Päeva- ja nädalavaade avanevad nende kellaaegade piires. Vajaduse korral laiendavad varasemad ja hilisemad kirjed vahemikku.", + "scheduleDayStartsAt": "Päev algab", + "scheduleDayEndsAt": "Päev lõpeb", + "sourceCalendar": "Kalender", + "sourceTaskList": "Ülesandeloend", + "createChoiceTitle": "Loo", + "createEventAtTime": "Sündmus", + "createTaskAtDate": "Ülesanne", + "editEvent": "Muuda sündmust", + "eventTitle": "Sündmuse pealkiri", + "location": "Asukoht", + "timeSlot": "Ajavahemik", + "startDateTime": "Alguskuupäev ja -kellaaeg", + "endDateTime": "Lõppkuupäev ja -kellaaeg", + "doesNotRepeat": "Ei kordu", + "defaultReminder": "Vaikemeeldetuletus", + "guests": "Külalised", + "noGuests": "Külalisi pole", + "description": "Kirjeldus", + "availabilityShowAs": "Hõivatus / Kuva kui", + "busy": "Hõivatud", + "visibility": "Nähtavus", + "defaultVisibility": "Vaikimisi nähtavus", + "conference": "Konverents", + "noConference": "Konverentsi pole", + "providerCalendar": "Teenuse kalender", + "formatBoldShortLabel": "R", + "formatBoldTooltip": "Rasvane", + "formatItalicShortLabel": "K", + "formatItalicTooltip": "Kursiiv", + "formatUnderlineShortLabel": "A", + "formatUnderlineTooltip": "Allajoonitud", + "reminderMinutesBefore": "{minutes, plural, =1{1 minut varem} other{{minutes} minutit varem}}", + "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "reminderAtStart": "Algusajal", + "reminderHoursBefore": "{hours, plural, =1{1 tund varem} other{{hours} tundi varem}}", + "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, + "reminderDaysBefore": "{days, plural, =1{1 päev varem} other{{days} päeva varem}}", + "@reminderDaysBefore": {"placeholders": {"days": {"type": "int"}}}, + "availabilityFree": "Vaba", + "availabilityTentative": "Esialgne", + "availabilityOutOfOffice": "Kontorist väljas", + "availabilityWorkingElsewhere": "Töötab mujal", + "visibilityDefault": "Vaikimisi", + "visibilityPublic": "Avalik", + "visibilityPrivate": "Privaatne", + "visibilityConfidential": "Konfidentsiaalne", + "sensitivityNormal": "Tavaline", + "sensitivityPersonal": "Isiklik", + "tasks": "Ülesanded", + "allTasks": "Kõik ülesanded", + "tasksInList": "Loendi „{title}” ülesanded", + "@tasksInList": {"placeholders": {"title": {"type": "String"}}}, + "taskLists": "Ülesandeloendid", + "navigation": "Navigeerimine", + "mainMenu": "Peamenüü", + "keyboardShortcuts": "Klaviatuuri otseteed", + "shortcutGroupGeneral": "Üldine", + "shortcutKeyboardShortcutsDescription": "Kuva klaviatuuri otseteede loend", + "shortcutGroupNavigation": "Navigeerimine", + "shortcutNextPeriod": "Järgmine periood", + "shortcutNextPeriodDescription": "Nädalavaates järgmine nädal, kuuvaates järgmine kuu jne", + "shortcutPreviousPeriod": "Eelmine periood", + "shortcutPreviousPeriodDescription": "Nädalavaates eelmine nädal, kuuvaates eelmine kuu jne", + "shortcutJumpToToday": "Mine tänasele kuupäevale", + "shortcutGroupView": "Vaade", + "shortcutDayView": "Päevavaade", + "shortcutWeekView": "Nädalavaade", + "shortcutMonthView": "Kuuvaade", + "shortcutYearView": "Aastavaade", + "shortcutAgendaView": "Päevakavavaade", + "shortcutGroupCreateAndEdit": "Loomine ja muutmine", + "shortcutSaveItem": "Salvesta sündmus või ülesanne", + "shortcutDeleteItem": "Kustuta sündmus või ülesanne", + "shortcutGroupTaskEditing": "Ülesande muutmine", + "shortcutCancelEditing": "Tühista muutmine", + "shortcutCancelEditingDescription": "Sulge ülesande muutmine või ülesande üksikasjad", + "aboutBusyMax": "Teave BusyMaxi kohta", + "aboutBusyMaxDescription": "Kalender ja ülesanded", + "license": "Litsents", + "apacheLicenseName": "Apache License 2.0", + "website": "Veebisait", + "sourceCode": "Lähtekood", + "reportAnIssue": "Teata probleemist", + "sendFeedback": "Saada tagasisidet", + "feedbackSubmit": "Saada", + "feedbackCategory": "Kategooria", + "feedbackSelectCategory": "Valige kategooria", + "feedbackCategoryProblem": "Probleem või viga", + "feedbackCategoryFeature": "Funktsioonisoov", + "feedbackCategoryPrivacySecurity": "Privaatsus- või turbeprobleem", + "feedbackCategoryUsability": "Kasutatavusprobleem", + "feedbackCategoryOther": "Muu", + "feedbackSubject": "Teema", + "feedbackDetailedMessage": "Üksikasjalik sõnum", + "feedbackReplyEmail": "Vastamise e-posti aadress (valikuline)", + "feedbackIncludeTechnicalDetails": "Lisa tehnilised üksikasjad", + "feedbackTechnicalDetailsDisclosure": "Lisatakse ainult teie Linuxi operatsioonisüsteemi versioon ning rakenduse keel ja piirkonnaseaded. Logisid, kontoandmeid, failinimesid ega muid diagnostikaandmeid ei lisata.", + "feedbackCategoryRequired": "Valige kategooria.", + "feedbackSubjectLengthError": "Teema peab olema 3–120 tähemärki pikk.", + "feedbackMessageLengthError": "Sõnum peab olema 10–5000 tähemärki pikk.", + "feedbackInvalidEmail": "Sisestage kehtiv e-posti aadress.", + "feedbackConnectionError": "BusyStackiga ei saanud ühendust luua. Kontrollige ühendust ja proovige uuesti.", + "feedbackTimeoutError": "Päring aegus. Teie tagasisidet ei kustutatud; proovige uuesti.", + "feedbackRateLimitedError": "Sellest võrgust on saadetud liiga palju tagasisidet. Oodake ja proovige uuesti.", + "feedbackRejectedError": "Server lükkas saatmise tagasi. Kontrollige välju ja proovige uuesti.", + "feedbackServerError": "BusyStack ei saa praegu teie tagasisidet vastu võtta. Teie tagasisidet ei kustutatud; proovige uuesti.", + "feedbackSuccess": "Tagasiside saadetud. Viide: {id}", + "@feedbackSuccess": {"placeholders": {"id": {"type": "String"}}}, + "toggleSidebar": "Kuva või peida külgriba", + "showSidebar": "Kuva külgpaneel", + "hideSidebar": "Peida külgpaneel", + "accounts": "Kontod", + "currentAccount": "Praegune konto", + "switchAccount": "Vaheta kontot", + "addGoogleAccount": "Lisa Google'i konto", + "addMicrosoftAccount": "Lisa Microsofti konto", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "Sisse logitud", + "removeAccount": "Eemalda konto…", + "removingAccount": "Konto eemaldamine…", + "removeAccountDescription": "Lõpeta sünkroonimine ja eemalda selle konto andmed seadmest.", + "removeAccountTitle": "Kas eemaldada {account} BusyMaxist?", + "@removeAccountTitle": {"placeholders": {"account": {"type": "String"}}}, + "removeAccountConfirmation": "See kustutab seadmest vahemällu salvestatud ülesanded, kalendrid, sündmused, meeldetuletused ja sünkroonimist ootavad võrguühenduseta muudatused. Sünkroonimata muudatused lähevad kaotsi. Google'ist ega Microsoftist midagi ei kustutata.", + "revokeGoogleAccess": "Tühista ka BusyMaxi juurdepääs sellele Google'i kontole", + "revokeGoogleAccessDescription": "Enne uuesti ühendamist peate juurdepääsu uuesti andma.", + "removeAccountAction": "Eemalda konto", + "removeAccountFailed": "Konto eemaldamist ei saanud lõpetada. Proovige uuesti.", + "accountRemovedGoogleRevokeFailed": "Konto eemaldati sellest seadmest, kuid BusyMaxi juurdepääsu teie Google'i kontole ei saanud tühistada. Saate selle oma Google'i kontol käsitsi tühistada.", + "newList": "Uus loend", + "signInToViewTaskLists": "Ülesandeloendite vaatamiseks logige sisse.", + "noTaskListsSynced": "Ühtegi ülesandeloendit pole veel sünkroonitud.", + "listActions": "Loendi toimingud", + "rename": "Nimeta ümber", + "delete": "Kustuta", + "renameList": "Nimeta loend ümber", + "deleteList": "Kustuta loend", + "builtInMicrosoftList": "Sisseehitatud", + "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do sisseehitatud loendeid ei saa ümber nimetada ega kustutada.", + "deleteListConfirmation": "Kas kustutada „{title}” Google Tasksist?", + "@deleteListConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "deleteEvent": "Kustuta sündmus", + "title": "Pealkiri", + "create": "Loo", + "newTask": "Uus ülesanne", + "clearCompleted": "Eemalda lõpetatud ülesanded", + "refreshList": "Värskenda loendit", + "refreshAll": "Värskenda kõiki", + "listRefreshed": "Loend on värskendatud.", + "allTasksRefreshed": "Kõik kontod on värskendatud.", + "exportedFile": "Eksporditud asukohta {path}", + "@exportedFile": {"placeholders": {"path": {"type": "String"}}}, + "exportFailed": "Eksportimine nurjus: {error}", + "@exportFailed": {"placeholders": {"error": {"type": "String"}}}, + "refreshFailed": "Värskendamine nurjus: {error}", + "@refreshFailed": {"placeholders": {"error": {"type": "String"}}}, + "selectOrCreateTaskList": "Alustamiseks valige või looge ülesandeloend.", + "signInToViewTasks": "Ülesannete vaatamiseks logige sisse.", + "noTasks": "Ülesandeid pole.", + "noTasksYet": "Ülesandeid veel pole", + "noTasksYetMessage": "Alustamiseks looge ülesanne või värskendage kontosid.", + "noTasksInList": "Selles loendis pole ülesandeid.", + "overdue": "Tähtaja ületanud", + "today": "Täna", + "tomorrow": "Homme", + "upcoming": "Tulekul", + "noDate": "Kuupäevata", + "completed": "Lõpetatud", + "duePrefix": "Tähtaeg {date}", + "@duePrefix": {"placeholders": {"date": {"type": "String"}}}, + "dateTimeDisplay": "{date} · {time}", + "@dateTimeDisplay": { + "placeholders": { + "date": {"type": "String"}, + "time": {"type": "String"} + } + }, + "taskDetails": "Ülesande üksikasjad", + "editTask": "Muuda ülesannet", + "noTaskSelected": "Ülesannet pole valitud.", + "noTaskSelectedHelper": "Üksikasjade vaatamiseks ja muutmiseks valige ülesanne.", + "taskUnavailable": "Ülesanne pole saadaval.", + "signInToEditTasks": "Ülesannete muutmiseks logige sisse.", + "refreshTask": "Värskenda ülesannet", + "primarySection": "Põhiteave", + "statusSection": "Olek", + "openStatus": "Pooleli", + "doneStatus": "Valmis", + "notes": "Märkmed", + "dueDate": "Tähtaeg", + "clearDueDate": "Eemalda tähtaeg", + "dueTime": "Tähtaja kellaaeg", + "startDate": "Alguskuupäev", + "startTime": "Alguskellaaeg", + "endDate": "Lõppkuupäev", + "endTime": "Lõppkellaaeg", + "reminderDate": "Meeldetuletuse kuupäev", + "reminderTime": "Meeldetuletuse kellaaeg", + "reminder": "Meeldetuletus", + "addReminder": "Lisa meeldetuletus", + "addGuest": "Lisa külaline", + "addGuestEmail": "Lisa külalise e-posti aadress", + "removeReminder": "Eemalda meeldetuletus", + "off": "Väljas", + "repeat": "Kordus", + "repeatNone": "Ei kordu", + "noneValue": "Puudub", + "repeatDaily": "Iga päev", + "repeatWeekly": "Iga nädal", + "repeatMonthly": "Iga kuu", + "repeatYearly": "Iga aasta", + "importance": "Tähtsus", + "importanceLow": "Madal", + "importanceNormal": "Tavaline", + "importanceHigh": "Kõrge", + "categories": "Kategooriad", + "scheduleSection": "Ajakava", + "dueGroup": "Tähtaeg", + "startGroup": "Algus", + "reminderGroup": "Meeldetuletus", + "organizationSection": "Korraldus", + "actionsSection": "Toimingud", + "advancedSection": "Täpsemad seaded", + "addCategory": "Lisa kategooria", + "list": "Loend", + "microsoftMoveUnsupported": "Selles versioonis ei toetata Microsoft To Do kontodel ülesannete teisaldamist loendite vahel.", + "createSubtask": "Loo alamülesanne", + "moveToTop": "Teisalda kõige üles", + "deleteTask": "Kustuta ülesanne", + "newSubtask": "Uus alamülesanne", + "deleteTaskConfirmation": "Kas kustutada „{title}” Google Tasksist?", + "@deleteTaskConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "metadata": "Metaandmed", + "id": "ID", + "etag": "ETag", + "updated": "Uuendatud", + "parent": "Ülemülesanne", + "position": "Asukoht", + "webLink": "Veebilink", + "assignment": "Määramine", + "localState": "Kohalik olek", + "pendingSync": "Sünkroonimise ootel", + "synced": "Sünkroonitud", + "account": "Konto", + "sync": "Sünkroonimine", + "manualFullSync": "Käsitsi täielik sünkroonimine", + "runInBackgroundWhenClosed": "Jätka töötamist, kui aken on suletud", + "showTrayIcon": "Kuva süsteemisalve ikoon", + "startMinimizedToTray": "Käivita minimeerituna süsteemisalves", + "requiresTrayIcon": "Nõuab süsteemisalve ikooni.", + "syncComplete": "Sünkroonimine on lõpetatud.", + "syncFailed": "Sünkroonimine nurjus: {error}", + "@syncFailed": {"placeholders": {"error": {"type": "String"}}}, + "notifySyncFailures": "Teavitused sünkroonimise nurjumisel", + "notifyConflicts": "Teavitused konfliktide korral", + "notifyDueToday": "Täna tähtuvate ülesannete teavitused", + "eventReminders": "Sündmuste meeldetuletused", + "taskReminders": "Ülesannete meeldetuletused", + "notificationDetailLevel": "Teavituste üksikasjalikkus", + "notificationDetailPrivate": "Privaatne", + "notificationDetailNormal": "Tavaline", + "quietHours": "Vaikne aeg", + "quietHoursDescription": "Peata teavitused selleks ajavahemikuks.", + "quietHoursStart": "Vaikse aja algus", + "quietHoursEnd": "Vaikse aja lõpp", + "notifications": "Teavitused", + "appearance": "Välimus", + "theme": "Kujundus", + "themeSystem": "Süsteem", + "themeLight": "Hele", + "themeDark": "Tume", + "themeFamily": "Kujunduse perekond", + "themeFamilyYaru": "Ubuntu algupärane kujundus (Yaru)", + "localization": "Keel ja piirkond", + "currentLocale": "Praegune lokaat", + "privacy": "Privaatsus", + "redactTaskContentInDiagnostics": "Peida diagnostikas ülesannete sisu", + "developerDiagnostics": "Arendaja diagnostika", + "diagnostics": "Diagnostika", + "apiInspectorDisabled": "Kuva API-inspektor", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "Discovery versioon: {revision}", + "@discoveryRevision": {"placeholders": {"revision": {"type": "String"}}}, + "implementedMethods": "Rakendatud meetodid", + "supportsTasksScopes": "Toetab õiguse ulatusi tasks ja tasks.readonly", + "requiresTasksScope": "Nõuab õiguse ulatust tasks", + "blockedPendingOperations": "Blokeeritud ootel toimingud", + "signInToInspectPendingOperations": "Ootel toimingute kontrollimiseks logige sisse.", + "noBlockedPendingOperations": "Blokeeritud ootel toiminguid pole.", + "operationActions": "Toimingu tegevused", + "pendingOpListId": "loend={id}", + "@pendingOpListId": {"placeholders": {"id": {"type": "String"}}}, + "pendingOpTaskId": "ülesanne={id}", + "@pendingOpTaskId": {"placeholders": {"id": {"type": "String"}}}, + "pendingOpAttempts": "katseid={count}", + "@pendingOpAttempts": {"placeholders": {"count": {"type": "int"}}}, + "retry": "Proovi uuesti", + "discard": "Hülga", + "discardChangesAction": "Hülga", + "discardChanges": "Kas hüljata muudatused?", + "discardChangesConfirmation": "See hülgab ülesande salvestamata muudatused.", + "retryCompleted": "Uuesti proovimine lõpetatud.", + "discardPendingOperation": "Kas hüljata ootel toiming?", + "discardPendingOperationConfirmation": "See eemaldab blokeeritud kohaliku toimingu. Järgmine sünkroonimine laadib andmed Google Tasksist uuesti.", + "pendingOperationDiscarded": "Ootel toiming hüljatud.", + "syncFailureNotificationTitle": "BusyMaxi sünkroonimine nurjus", + "syncFailureNotificationBody": "Taustal sünkroonimine nurjus. {message}", + "@syncFailureNotificationBody": {"placeholders": {"message": {"type": "String"}}}, + "conflictNotificationTitle": "BusyMaxi sünkroonimiskonflikt", + "conflictNotificationBody": "Ootel kohalik muudatus blokeeriti. {summary}", + "@conflictNotificationBody": {"placeholders": {"summary": {"type": "String"}}}, + "dueTodayNotificationTitle": "Täna tähtuvad ülesanded", + "dueTodayNotificationBody": "{count, plural, =1{Üks ülesanne tähtub täna.} other{{count} ülesannet tähtub täna.}}", + "@dueTodayNotificationBody": {"placeholders": {"count": {"type": "int"}}}, + "eventReminderNotificationTitle": "Sündmuse meeldetuletus", + "taskReminderNotificationTitle": "Ülesande meeldetuletus", + "eventReminderNotificationBody": "Sündmus algab varsti.", + "taskReminderNotificationBody": "Ülesande tähtaeg on varsti.", + "notificationOpenAction": "Ava", + "notificationDetailsHidden": "Üksikasjad on privaatsusseadete tõttu peidetud.", + "previousMonth": "Eelmine kuu", + "nextMonth": "Järgmine kuu", + "openMonthView": "Ava kuuvaade", + "previousYear": "Eelmine aasta", + "nextYear": "Järgmine aasta", + "openYearView": "Ava aastavaade", + "weekNumberTooltip": "Nädal {number}", + "@weekNumberTooltip": {"placeholders": {"number": {"type": "int"}}}, + "resizeAllDayPanel": "Muuda kogu päeva paneeli suurust", + "scheduleItemCount": "{count, plural, =1{1 kirje} other{{count} kirjet}}", + "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, + "readOnlyCalendar": "See kalender on kirjutuskaitstud.", + "selectTimeZone": "Valige ajavöönd", + "searchLocations": "Otsi asukohti", + "noLocationsFound": "Asukohti ei leitud", + "deleteCalendarConfirmation": "Kas kustutada „{title}”?", + "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}} +} diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb new file mode 100644 index 0000000..7caa2aa --- /dev/null +++ b/lib/l10n/app_fa.arb @@ -0,0 +1,438 @@ +{ + "@@locale": "fa", + "appTitle": "BusyMax", + "connectGoogleAccount": "حساب‌های Google و Microsoft را متصل کنید تا تقویم‌ها و کارها همگام شوند.", + "googlePermissionsConsentNotice": "در صفحهٔ مجوزهای Google، مجوزهای تقویم و کارها را هر دو انتخاب کنید.", + "googlePermissionsRequiredRetry": "مجوزهای Google Calendar و Google Tasks لازم هستند. دوباره تلاش کنید و هر دو کادر را علامت بزنید.", + "finishSetup": "پایان راه‌اندازی", + "continueSetup": "ادامه", + "onboardingSetupTitle": "راه‌اندازی BusyMax", + "onboardingAccountsStepTitle": "اتصال حساب‌ها", + "onboardingAccountsStepDescription": "همهٔ حساب‌های Google و Microsoft موردنظرتان را اضافه کنید. BusyMax تقویم‌ها، رویدادها، فهرست‌های کار و کارهای هر حساب را همگام می‌کند.", + "onboardingPreferencesStepTitle": "انتخاب تنظیمات سیستم", + "onboardingPreferencesStepDescription": "پیش از باز کردن برنامه، رفتار برنامه روی میزکار، یادآورها، سطح جزئیات اعلان‌ها و ظاهر را تنظیم کنید.", + "signInWithGoogle": "ورود با Google", + "signInWithMicrosoft": "ورود با Microsoft", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "این سرویس پیکربندی نشده است.", + "waitingForGoogleSignIn": "در انتظار ورود به Google...", + "waitingForMicrosoftSignIn": "در انتظار ورود به Microsoft...", + "microsoftSignInNotConfigured": "ورود به Microsoft پیکربندی نشده است. MICROSOFT_OAUTH_CLIENT_ID را تنظیم کنید.", + "cancel": "لغو", + "close": "بستن", + "exit": "خروج", + "options": "گزینه‌ها", + "hide": "پنهان کردن", + "show": "نمایش", + "export": "خروجی گرفتن", + "save": "ذخیره", + "settings": "تنظیمات", + "all": "همه", + "calendarEvents": "رویدادها", + "calendarTasks": "کارها", + "calendar": "تقویم", + "calendars": "تقویم‌ها", + "newEvent": "رویداد جدید", + "refreshCalendar": "تازه‌سازی تقویم", + "openInProvider": "باز کردن در سرویس", + "hideFromSchedule": "پنهان کردن از برنامه", + "showInSchedule": "نمایش در برنامه", + "noCalendarsSynced": "هنوز هیچ تقویمی همگام نشده است.", + "allDay": "تمام روز", + "moreItems": "+\u2068{count}\u2069 مورد دیگر", + "noEventsOrTasks": "هیچ رویداد یا کاری وجود ندارد", + "scheduleLoading": "در حال بارگیری برنامه...", + "scheduleUnavailable": "برنامه در دسترس نیست", + "scheduleNoSources": "هیچ تقویم یا فهرست کار قابل نمایشی وجود ندارد", + "scheduleNoSourcesDescription": "در تنظیمات انتخاب کنید چه چیزهایی نمایش داده شوند، سپس برنامه را تازه‌سازی کنید.", + "scheduleSignInRequired": "اتصال حساب", + "scheduleSignInDescription": "برای همگام‌سازی تقویم‌ها و کارها وارد شوید.", + "scheduleNoSearchResults": "هیچ رویداد یا کار منطبقی وجود ندارد", + "scheduleNoSearchResultsDescription": "جست‌وجوی دیگری را امتحان کنید یا پالایه‌های فعلی را پاک کنید.", + "refresh": "تازه‌سازی", + "trayOpenBusyMax": "باز کردن BusyMax", + "agendaLoadMoreOverdue": "بارگیری کارهای عقب‌افتادهٔ بیشتر", + "agendaLoadMoreNoDate": "بارگیری کارهای بدون تاریخ بیشتر", + "viewDay": "روز", + "viewWeek": "هفته", + "viewMonth": "ماه", + "viewYear": "سال", + "viewAgenda": "برنامه", + "scheduleSettings": "برنامه", + "scheduleDisplaySettings": "نمایش برنامه", + "scheduleDisplayHoursDescription": "نماهای روز و هفته ابتدا این بازهٔ زمانی را نشان می‌دهند. موارد زودتر یا دیرتر در صورت نیاز این بازه را گسترش می‌دهند.", + "scheduleDayStartsAt": "شروع روز از", + "scheduleDayEndsAt": "پایان روز در", + "sourceCalendar": "تقویم", + "sourceTaskList": "فهرست کار", + "createChoiceTitle": "ایجاد", + "createEventAtTime": "رویداد", + "createTaskAtDate": "کار", + "editEvent": "ویرایش رویداد", + "eventTitle": "عنوان رویداد", + "location": "مکان", + "timeSlot": "بازهٔ زمانی", + "startDateTime": "تاریخ/زمان شروع", + "endDateTime": "تاریخ/زمان پایان", + "doesNotRepeat": "تکرار نمی‌شود", + "defaultReminder": "یادآور پیش‌فرض", + "guests": "مهمانان", + "noGuests": "بدون مهمان", + "description": "توضیحات", + "availabilityShowAs": "وضعیت دسترسی / نمایش به‌عنوان", + "busy": "مشغول", + "visibility": "قابلیت مشاهده", + "defaultVisibility": "قابلیت مشاهدهٔ پیش‌فرض", + "conference": "جلسه", + "noConference": "بدون جلسه", + "providerCalendar": "تقویم سرویس", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "پررنگ", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "مورب", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "زیرخط", + "reminderMinutesBefore": "{minutes, plural, =0{هنگام شروع} =1{یک دقیقه قبل} other{\u2068{minutes}\u2069 دقیقه قبل}}", + "reminderAtStart": "هنگام شروع", + "reminderHoursBefore": "{hours, plural, =0{هنگام شروع} =1{یک ساعت قبل} other{\u2068{hours}\u2069 ساعت قبل}}", + "reminderDaysBefore": "{days, plural, =0{همان روز} =1{یک روز قبل} other{\u2068{days}\u2069 روز قبل}}", + "availabilityFree": "آزاد", + "availabilityTentative": "احتمالی", + "availabilityOutOfOffice": "خارج از دفتر", + "availabilityWorkingElsewhere": "مشغول به کار در مکانی دیگر", + "visibilityDefault": "پیش‌فرض", + "visibilityPublic": "عمومی", + "visibilityPrivate": "خصوصی", + "visibilityConfidential": "محرمانه", + "sensitivityNormal": "عادی", + "sensitivityPersonal": "شخصی", + "tasks": "کارها", + "allTasks": "همهٔ کارها", + "tasksInList": "کارهای \u2068{title}\u2069", + "taskLists": "فهرست‌های کار", + "navigation": "پیمایش", + "mainMenu": "منوی اصلی", + "keyboardShortcuts": "میان‌برهای صفحه‌کلید", + "shortcutGroupGeneral": "عمومی", + "shortcutKeyboardShortcutsDescription": "نمایش این راهنمای میان‌برها", + "shortcutGroupNavigation": "پیمایش", + "shortcutNextPeriod": "بازهٔ بعدی", + "shortcutNextPeriodDescription": "هفتهٔ بعد در نمای هفته، ماه بعد در نمای ماه و به همین ترتیب", + "shortcutPreviousPeriod": "بازهٔ قبلی", + "shortcutPreviousPeriodDescription": "هفتهٔ قبل در نمای هفته، ماه قبل در نمای ماه و به همین ترتیب", + "shortcutJumpToToday": "رفتن به امروز", + "shortcutGroupView": "نما", + "shortcutDayView": "نمای روز", + "shortcutWeekView": "نمای هفته", + "shortcutMonthView": "نمای ماه", + "shortcutYearView": "نمای سال", + "shortcutAgendaView": "نمای برنامه", + "shortcutGroupCreateAndEdit": "ایجاد و ویرایش", + "shortcutSaveItem": "ذخیرهٔ رویداد یا کار", + "shortcutDeleteItem": "حذف رویداد یا کار", + "shortcutGroupTaskEditing": "ویرایش کار", + "shortcutCancelEditing": "لغو ویرایش", + "shortcutCancelEditingDescription": "بستن ویرایش یا جزئیات کار", + "aboutBusyMax": "دربارهٔ BusyMax", + "aboutBusyMaxDescription": "تقویم و کارها", + "license": "مجوز", + "apacheLicenseName": "Apache License 2.0", + "website": "وب‌سایت", + "sourceCode": "کد منبع", + "reportAnIssue": "گزارش مشکل", + "sendFeedback": "ارسال بازخورد", + "feedbackSubmit": "ارسال", + "feedbackCategory": "دسته‌بندی", + "feedbackSelectCategory": "یک دسته‌بندی انتخاب کنید", + "feedbackCategoryProblem": "مشکل یا اشکال", + "feedbackCategoryFeature": "درخواست قابلیت", + "feedbackCategoryPrivacySecurity": "نگرانی دربارهٔ حریم خصوصی یا امنیت", + "feedbackCategoryUsability": "نگرانی دربارهٔ کاربردپذیری", + "feedbackCategoryOther": "سایر", + "feedbackSubject": "موضوع", + "feedbackDetailedMessage": "پیام با جزئیات", + "feedbackReplyEmail": "نشانی ایمیل برای پاسخ (اختیاری)", + "feedbackIncludeTechnicalDetails": "افزودن جزئیات فنی", + "feedbackTechnicalDetailsDisclosure": "فقط نسخهٔ سیستم‌عامل Linux و تنظیمات منطقه‌ای برنامه افزوده می‌شود. هیچ گزارش، دادهٔ حساب، نام پرونده یا اطلاعات تشخیصی دیگری افزوده نمی‌شود.", + "feedbackCategoryRequired": "یک دسته‌بندی انتخاب کنید.", + "feedbackSubjectLengthError": "موضوع باید بین ۳ تا ۱۲۰ نویسه باشد.", + "feedbackMessageLengthError": "پیام باید بین ۱۰ تا ۵٬۰۰۰ نویسه باشد.", + "feedbackInvalidEmail": "یک نشانی ایمیل معتبر وارد کنید.", + "feedbackConnectionError": "اتصال به BusyStack ممکن نشد. اتصال خود را بررسی و دوباره تلاش کنید.", + "feedbackTimeoutError": "مهلت درخواست پایان یافت. بازخورد شما پاک نشده است؛ دوباره تلاش کنید.", + "feedbackRateLimitedError": "بازخوردهای بیش از حدی از این شبکه ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.", + "feedbackRejectedError": "سرور ارسال را رد کرد. فیلدها را بررسی و دوباره تلاش کنید.", + "feedbackServerError": "BusyStack اکنون نمی‌تواند بازخورد شما را بپذیرد. بازخورد شما پاک نشده است؛ دوباره تلاش کنید.", + "feedbackSuccess": "بازخورد ارسال شد. شناسهٔ پیگیری: \u2068{id}\u2069", + "toggleSidebar": "نمایش یا پنهان کردن نوار کناری", + "showSidebar": "نمایش پنل کناری", + "hideSidebar": "پنهان کردن پنل کناری", + "accounts": "حساب‌ها", + "currentAccount": "حساب فعلی", + "switchAccount": "تعویض حساب", + "addGoogleAccount": "افزودن حساب Google", + "addMicrosoftAccount": "افزودن حساب Microsoft", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "وارد شده", + "removeAccount": "حذف حساب…", + "removingAccount": "در حال حذف حساب…", + "removeAccountDescription": "همگام‌سازی را متوقف و داده‌های این حساب را از این دستگاه حذف کنید.", + "removeAccountTitle": "حذف \u2068{account}\u2069 از BusyMax؟", + "removeAccountConfirmation": "با این کار، کارها، تقویم‌ها، رویدادها، یادآورها و تغییرات آفلاین در انتظار از حافظهٔ نهان این دستگاه حذف می‌شوند. تغییرات همگام‌نشده از دست می‌روند. هیچ چیزی از Google یا Microsoft حذف نمی‌شود.", + "revokeGoogleAccess": "دسترسی BusyMax به این حساب Google نیز لغو شود", + "revokeGoogleAccessDescription": "پیش از اتصال دوباره باید دسترسی را دوباره اعطا کنید.", + "removeAccountAction": "حذف حساب", + "removeAccountFailed": "حذف حساب کامل نشد. دوباره تلاش کنید.", + "accountRemovedGoogleRevokeFailed": "حساب از این دستگاه حذف شد، اما BusyMax نتوانست دسترسی Google را لغو کند. می‌توانید آن را از حساب Google خود لغو کنید.", + "newList": "فهرست جدید", + "signInToViewTaskLists": "برای دیدن فهرست‌های کار وارد شوید.", + "noTaskListsSynced": "هنوز هیچ فهرست کاری همگام نشده است.", + "listActions": "عملیات فهرست", + "rename": "تغییر نام", + "delete": "حذف", + "renameList": "تغییر نام فهرست", + "deleteList": "حذف فهرست", + "builtInMicrosoftList": "داخلی", + "builtInMicrosoftListCannotRenameDelete": "فهرست‌های داخلی Microsoft To Do را نمی‌توان تغییر نام داد یا حذف کرد.", + "deleteListConfirmation": "«\u2068{title}\u2069» از Google Tasks حذف شود؟", + "deleteEvent": "حذف رویداد", + "title": "عنوان", + "create": "ایجاد", + "newTask": "کار جدید", + "clearCompleted": "پاک کردن کارهای انجام‌شده", + "refreshList": "تازه‌سازی فهرست", + "refreshAll": "تازه‌سازی همه", + "listRefreshed": "فهرست تازه‌سازی شد.", + "allTasksRefreshed": "همهٔ حساب‌ها تازه‌سازی شدند.", + "exportedFile": "در \u2068{path}\u2069 خروجی گرفته شد", + "exportFailed": "خروجی گرفتن ناموفق بود: \u2068{error}\u2069", + "refreshFailed": "تازه‌سازی ناموفق بود: \u2068{error}\u2069", + "selectOrCreateTaskList": "برای شروع، یک فهرست کار انتخاب یا ایجاد کنید.", + "signInToViewTasks": "برای دیدن کارها وارد شوید.", + "noTasks": "هیچ کاری وجود ندارد.", + "noTasksYet": "هنوز کاری وجود ندارد", + "noTasksYetMessage": "برای شروع یک کار ایجاد کنید یا حساب‌هایتان را تازه‌سازی کنید.", + "noTasksInList": "هیچ کاری در این فهرست وجود ندارد.", + "overdue": "گذشته از موعد", + "today": "امروز", + "tomorrow": "فردا", + "upcoming": "پیش رو", + "noDate": "بدون تاریخ", + "completed": "انجام‌شده", + "duePrefix": "سررسید: \u2068{date}\u2069", + "dateTimeDisplay": "\u2068{date}\u2069 · \u2068{time}\u2069", + "taskDetails": "جزئیات کار", + "editTask": "ویرایش کار", + "noTaskSelected": "هیچ کاری انتخاب نشده است.", + "noTaskSelectedHelper": "برای دیدن و ویرایش جزئیات، کاری را انتخاب کنید.", + "taskUnavailable": "کار در دسترس نیست.", + "signInToEditTasks": "برای ویرایش کارها وارد شوید.", + "refreshTask": "تازه‌سازی کار", + "primarySection": "اصلی", + "statusSection": "وضعیت", + "openStatus": "باز", + "doneStatus": "انجام‌شده", + "notes": "یادداشت‌ها", + "dueDate": "تاریخ سررسید", + "clearDueDate": "پاک کردن تاریخ سررسید", + "dueTime": "زمان سررسید", + "startDate": "تاریخ شروع", + "startTime": "زمان شروع", + "endDate": "تاریخ پایان", + "endTime": "زمان پایان", + "reminderDate": "تاریخ یادآور", + "reminderTime": "زمان یادآور", + "reminder": "یادآور", + "addReminder": "افزودن یادآور", + "addGuest": "افزودن مهمان", + "addGuestEmail": "افزودن ایمیل مهمان", + "removeReminder": "حذف یادآور", + "off": "خاموش", + "repeat": "تکرار", + "repeatNone": "بدون تکرار", + "noneValue": "هیچ‌کدام", + "repeatDaily": "روزانه", + "repeatWeekly": "هفتگی", + "repeatMonthly": "ماهانه", + "repeatYearly": "سالانه", + "importance": "اهمیت", + "importanceLow": "کم", + "importanceNormal": "عادی", + "importanceHigh": "زیاد", + "categories": "دسته‌ها", + "scheduleSection": "برنامه", + "dueGroup": "سررسید", + "startGroup": "شروع", + "reminderGroup": "یادآور", + "organizationSection": "سازمان‌دهی", + "actionsSection": "عملیات", + "advancedSection": "پیشرفته", + "addCategory": "افزودن دسته", + "list": "فهرست", + "microsoftMoveUnsupported": "در این نسخه، جابه‌جایی کارها بین فهرست‌های حساب Microsoft To Do پشتیبانی نمی‌شود.", + "createSubtask": "ایجاد زیرکار", + "moveToTop": "انتقال به بالاترین جایگاه", + "deleteTask": "حذف کار", + "newSubtask": "زیرکار جدید", + "deleteTaskConfirmation": "«\u2068{title}\u2069» از Google Tasks حذف شود؟", + "metadata": "فراداده", + "id": "شناسه", + "etag": "ETag", + "updated": "به‌روزشده", + "parent": "کار والد", + "position": "جایگاه", + "webLink": "پیوند وب", + "assignment": "واگذاری", + "localState": "وضعیت محلی", + "pendingSync": "در انتظار همگام‌سازی", + "synced": "همگام‌شده", + "account": "حساب", + "sync": "همگام‌سازی", + "manualFullSync": "همگام‌سازی کامل دستی", + "runInBackgroundWhenClosed": "ادامهٔ اجرا پس از بسته شدن پنجره", + "showTrayIcon": "نمایش نماد سینی سیستم", + "startMinimizedToTray": "شروع به‌صورت کوچک‌شده در سینی سیستم", + "requiresTrayIcon": "به نماد سینی سیستم نیاز دارد.", + "syncComplete": "همگام‌سازی کامل شد.", + "syncFailed": "همگام‌سازی ناموفق بود: \u2068{error}\u2069", + "notifySyncFailures": "اعلان هنگام شکست همگام‌سازی", + "notifyConflicts": "اعلان هنگام تداخل", + "notifyDueToday": "اعلان کارهای دارای سررسید امروز", + "eventReminders": "یادآورهای رویداد", + "taskReminders": "یادآورهای کار", + "notificationDetailLevel": "سطح جزئیات اعلان", + "notificationDetailPrivate": "خصوصی", + "notificationDetailNormal": "عادی", + "quietHours": "ساعات سکوت", + "quietHoursDescription": "اعلان‌ها را در این بازه موقتاً متوقف کنید.", + "quietHoursStart": "شروع ساعات سکوت", + "quietHoursEnd": "پایان ساعات سکوت", + "notifications": "اعلان‌ها", + "appearance": "ظاهر", + "theme": "پوسته", + "themeSystem": "سیستم", + "themeLight": "روشن", + "themeDark": "تیره", + "themeFamily": "خانوادهٔ پوسته", + "themeFamilyYaru": "پوستهٔ بومی Ubuntu ‏(Yaru)", + "localization": "زبان و منطقه", + "currentLocale": "زبان و منطقهٔ فعلی", + "privacy": "حریم خصوصی", + "redactTaskContentInDiagnostics": "پنهان کردن محتوای کارها در اطلاعات تشخیصی", + "developerDiagnostics": "تشخیص‌های توسعه‌دهنده", + "diagnostics": "اطلاعات تشخیصی", + "apiInspectorDisabled": "نمایش بازرس API", + "googleTasksApi": "رابط Google Tasks API", + "discoveryRevision": "بازبینی Discovery: \u2068{revision}\u2069", + "implementedMethods": "روش‌های پیاده‌سازی‌شده", + "supportsTasksScopes": "از محدوده‌های tasks و tasks.readonly پشتیبانی می‌کند", + "requiresTasksScope": "به محدودهٔ tasks نیاز دارد", + "blockedPendingOperations": "عملیات در انتظار مسدودشده", + "signInToInspectPendingOperations": "برای بررسی عملیات در انتظار وارد شوید.", + "noBlockedPendingOperations": "هیچ عملیات در انتظار مسدودشده‌ای وجود ندارد.", + "operationActions": "اقدامات عملیات", + "pendingOpListId": "فهرست=\u2068{id}\u2069", + "pendingOpTaskId": "کار=\u2068{id}\u2069", + "pendingOpAttempts": "تلاش‌ها=\u2068{count}\u2069", + "retry": "تلاش دوباره", + "discard": "کنار گذاشتن", + "discardChangesAction": "ذخیره نشود", + "discardChanges": "تغییرات کنار گذاشته شوند؟", + "discardChangesConfirmation": "با این کار ویرایش‌های ذخیره‌نشدهٔ این کار کنار گذاشته می‌شوند.", + "retryCompleted": "تلاش دوباره کامل شد.", + "discardPendingOperation": "عملیات در انتظار کنار گذاشته شود؟", + "discardPendingOperationConfirmation": "با این کار عملیات محلی مسدودشده حذف می‌شود. در همگام‌سازی بعدی، داده‌ها از Google Tasks تازه‌سازی می‌شوند.", + "pendingOperationDiscarded": "عملیات در انتظار کنار گذاشته شد.", + "syncFailureNotificationTitle": "همگام‌سازی BusyMax ناموفق بود", + "syncFailureNotificationBody": "همگام‌سازی پس‌زمینه ناموفق بود. \u2068{message}\u2069", + "conflictNotificationTitle": "تداخل همگام‌سازی BusyMax", + "conflictNotificationBody": "یک تغییر محلی در انتظار مسدود شد. \u2068{summary}\u2069", + "dueTodayNotificationTitle": "کارهای دارای سررسید امروز", + "dueTodayNotificationBody": "{count, plural, =0{امروز هیچ کاری سررسید ندارد.} =1{امروز یک کار سررسید دارد.} other{امروز \u2068{count}\u2069 کار سررسید دارند.}}", + "eventReminderNotificationTitle": "یادآور رویداد", + "taskReminderNotificationTitle": "یادآور کار", + "eventReminderNotificationBody": "رویداد به‌زودی شروع می‌شود.", + "taskReminderNotificationBody": "سررسید کار نزدیک است.", + "notificationOpenAction": "باز کردن", + "notificationDetailsHidden": "جزئیات به‌دلیل تنظیمات حریم خصوصی پنهان شده‌اند.", + "previousMonth": "ماه قبل", + "nextMonth": "ماه بعد", + "openMonthView": "باز کردن نمای ماه", + "previousYear": "سال قبل", + "nextYear": "سال بعد", + "openYearView": "باز کردن نمای سال", + "weekNumberTooltip": "هفتهٔ \u2068{number}\u2069", + "resizeAllDayPanel": "تغییر اندازهٔ پنل تمام‌روز", + "scheduleItemCount": "{count, plural, =0{هیچ موردی} =1{یک مورد} other{\u2068{count}\u2069 مورد}}", + "readOnlyCalendar": "این تقویم فقط‌خواندنی است.", + "selectTimeZone": "انتخاب منطقهٔ زمانی", + "searchLocations": "جست‌وجوی مکان‌ها", + "noLocationsFound": "مکانی پیدا نشد", + "deleteCalendarConfirmation": "«\u2068{title}\u2069» حذف شود؟", + "@moreItems": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "@reminderMinutesBefore": { + "placeholders": { + "minutes": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "@reminderHoursBefore": { + "placeholders": { + "hours": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "@reminderDaysBefore": { + "placeholders": { + "days": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "@pendingOpAttempts": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "@dueTodayNotificationBody": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "@weekNumberTooltip": { + "placeholders": { + "number": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "@scheduleItemCount": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + } +} diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb new file mode 100644 index 0000000..900bd8d --- /dev/null +++ b/lib/l10n/app_fi.arb @@ -0,0 +1,374 @@ +{ + "@@locale": "fi", + "appTitle": "BusyMax", + "connectGoogleAccount": "Yhdistä Google- ja Microsoft-tilit kalenterien ja tehtävien synkronointia varten.", + "googlePermissionsConsentNotice": "Valitse Googlen käyttöoikeusnäkymässä sekä Kalenteri- että Tehtävät-käyttöoikeudet.", + "googlePermissionsRequiredRetry": "Google Kalenterin ja Google Tasksin käyttöoikeudet vaaditaan. Yritä uudelleen ja valitse molemmat valintaruudut.", + "finishSetup": "Viimeistele määritys", + "continueSetup": "Jatka", + "onboardingSetupTitle": "Määritä BusyMax", + "onboardingAccountsStepTitle": "Yhdistä tilit", + "onboardingAccountsStepDescription": "Lisää kaikki haluamasi Google- ja Microsoft-tilit. BusyMax synkronoi kunkin tilin kalenterit, tapahtumat, tehtäväluettelot ja tehtävät.", + "onboardingPreferencesStepTitle": "Valitse järjestelmäasetukset", + "onboardingPreferencesStepDescription": "Määritä sovelluksen toiminta työpöydällä, muistutukset, ilmoitusten yksityiskohtaisuus ja ulkoasu ennen aikataulun avaamista.", + "signInWithGoogle": "Kirjaudu Google-tilillä", + "signInWithMicrosoft": "Kirjaudu Microsoft-tilillä", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "Tätä palveluntarjoajaa ei ole määritetty.", + "waitingForGoogleSignIn": "Odotetaan Google-kirjautumista...", + "waitingForMicrosoftSignIn": "Odotetaan Microsoft-kirjautumista...", + "microsoftSignInNotConfigured": "Microsoft-kirjautumista ei ole määritetty. Aseta MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "Peruuta", + "close": "Sulje", + "exit": "Lopeta", + "options": "Valinnat", + "hide": "Piilota", + "show": "Näytä", + "export": "Vie", + "save": "Tallenna", + "settings": "Asetukset", + "all": "Kaikki", + "calendarEvents": "Tapahtumat", + "calendarTasks": "Tehtävät", + "calendar": "Kalenteri", + "calendars": "Kalenterit", + "newEvent": "Uusi tapahtuma", + "refreshCalendar": "Päivitä kalenteri", + "openInProvider": "Avaa palvelussa", + "hideFromSchedule": "Piilota aikataulusta", + "showInSchedule": "Näytä aikataulussa", + "noCalendarsSynced": "Kalentereita ei ole vielä synkronoitu.", + "allDay": "Koko päivä", + "moreItems": "+{count} muuta", + "noEventsOrTasks": "Ei tapahtumia tai tehtäviä", + "scheduleLoading": "Ladataan aikataulua...", + "scheduleUnavailable": "Aikataulu ei ole käytettävissä", + "scheduleNoSources": "Ei näkyviä kalentereita tai tehtäväluetteloita", + "scheduleNoSourcesDescription": "Valitse asetuksissa, mitä näytetään, ja päivitä sitten näkymä.", + "scheduleSignInRequired": "Yhdistä tili", + "scheduleSignInDescription": "Kirjaudu sisään synkronoidaksesi kalenterit ja tehtävät.", + "scheduleNoSearchResults": "Ei vastaavia tapahtumia tai tehtäviä", + "scheduleNoSearchResultsDescription": "Kokeile toista hakua tai tyhjennä nykyiset suodattimet.", + "refresh": "Päivitä", + "trayOpenBusyMax": "Avaa BusyMax", + "agendaLoadMoreOverdue": "Lataa lisää myöhässä olevia tehtäviä", + "agendaLoadMoreNoDate": "Lataa lisää päiväämättömiä tehtäviä", + "viewDay": "Päivä", + "viewWeek": "Viikko", + "viewMonth": "Kuukausi", + "viewYear": "Vuosi", + "viewAgenda": "Agenda", + "scheduleSettings": "Aikataulu", + "scheduleDisplaySettings": "Aikataulun näyttö", + "scheduleDisplayHoursDescription": "Päivä- ja viikkonäkymät näyttävät aluksi tämän aikavälin. Aikaisemmat ja myöhemmät kohteet laajentavat sitä tarvittaessa.", + "scheduleDayStartsAt": "Päivä alkaa", + "scheduleDayEndsAt": "Päivä päättyy", + "sourceCalendar": "Kalenteri", + "sourceTaskList": "Tehtäväluettelo", + "createChoiceTitle": "Luo", + "createEventAtTime": "Tapahtuma", + "createTaskAtDate": "Tehtävä", + "editEvent": "Muokkaa tapahtumaa", + "eventTitle": "Tapahtuman nimi", + "location": "Sijainti", + "timeSlot": "Ajankohta", + "startDateTime": "Alkamispäivä ja -aika", + "endDateTime": "Päättymispäivä ja -aika", + "doesNotRepeat": "Ei toistu", + "defaultReminder": "Oletusmuistutus", + "guests": "Vieraat", + "noGuests": "Ei vieraita", + "description": "Kuvaus", + "availabilityShowAs": "Saatavuus / Näytä tilana", + "busy": "Varattu", + "visibility": "Näkyvyys", + "defaultVisibility": "Oletusnäkyvyys", + "conference": "Verkkokokous", + "noConference": "Ei verkkokokousta", + "providerCalendar": "Palveluntarjoajan kalenteri", + "formatBoldShortLabel": "L", + "formatBoldTooltip": "Lihavointi", + "formatItalicShortLabel": "K", + "formatItalicTooltip": "Kursivointi", + "formatUnderlineShortLabel": "A", + "formatUnderlineTooltip": "Alleviivaus", + "reminderMinutesBefore": "{minutes, plural, =1{1 minuutti ennen} other{{minutes} minuuttia ennen}}", + "reminderAtStart": "Alkamishetkellä", + "reminderHoursBefore": "{hours, plural, =1{1 tunti ennen} other{{hours} tuntia ennen}}", + "reminderDaysBefore": "{days, plural, =1{1 päivä ennen} other{{days} päivää ennen}}", + "availabilityFree": "Vapaa", + "availabilityTentative": "Alustava", + "availabilityOutOfOffice": "Poissa toimistolta", + "availabilityWorkingElsewhere": "Työskentelee muualla", + "visibilityDefault": "Oletus", + "visibilityPublic": "Julkinen", + "visibilityPrivate": "Yksityinen", + "visibilityConfidential": "Luottamuksellinen", + "sensitivityNormal": "Normaali", + "sensitivityPersonal": "Henkilökohtainen", + "tasks": "Tehtävät", + "allTasks": "Kaikki tehtävät", + "tasksInList": "Luettelon {title} tehtävät", + "taskLists": "Tehtäväluettelot", + "navigation": "Siirtyminen", + "mainMenu": "Päävalikko", + "keyboardShortcuts": "Pikanäppäimet", + "shortcutGroupGeneral": "Yleiset", + "shortcutKeyboardShortcutsDescription": "Näytä tämä pikanäppäinluettelo", + "shortcutGroupNavigation": "Siirtyminen", + "shortcutNextPeriod": "Seuraava ajanjakso", + "shortcutNextPeriodDescription": "Seuraava viikko viikkonäkymässä, seuraava kuukausi kuukausinäkymässä ja niin edelleen", + "shortcutPreviousPeriod": "Edellinen ajanjakso", + "shortcutPreviousPeriodDescription": "Edellinen viikko viikkonäkymässä, edellinen kuukausi kuukausinäkymässä ja niin edelleen", + "shortcutJumpToToday": "Siirry tähän päivään", + "shortcutGroupView": "Näkymä", + "shortcutDayView": "Päivänäkymä", + "shortcutWeekView": "Viikkonäkymä", + "shortcutMonthView": "Kuukausinäkymä", + "shortcutYearView": "Vuosinäkymä", + "shortcutAgendaView": "Agendanäkymä", + "shortcutGroupCreateAndEdit": "Luominen ja muokkaaminen", + "shortcutSaveItem": "Tallenna tapahtuma tai tehtävä", + "shortcutDeleteItem": "Poista tapahtuma tai tehtävä", + "shortcutGroupTaskEditing": "Tehtävien muokkaaminen", + "shortcutCancelEditing": "Peruuta muokkaaminen", + "shortcutCancelEditingDescription": "Sulje tehtävän muokkaus tai tehtävän tiedot", + "aboutBusyMax": "Tietoja BusyMaxista", + "aboutBusyMaxDescription": "Kalenteri ja tehtävät", + "license": "Lisenssi", + "apacheLicenseName": "Apache License 2.0", + "website": "Verkkosivusto", + "sourceCode": "Lähdekoodi", + "reportAnIssue": "Ilmoita ongelmasta", + "sendFeedback": "Lähetä palautetta", + "feedbackSubmit": "Lähetä", + "feedbackCategory": "Luokka", + "feedbackSelectCategory": "Valitse luokka", + "feedbackCategoryProblem": "Ongelma tai virhe", + "feedbackCategoryFeature": "Ominaisuuspyyntö", + "feedbackCategoryPrivacySecurity": "Tietosuojaan tai tietoturvaan liittyvä huoli", + "feedbackCategoryUsability": "Käytettävyyshuoli", + "feedbackCategoryOther": "Muu", + "feedbackSubject": "Aihe", + "feedbackDetailedMessage": "Yksityiskohtainen viesti", + "feedbackReplyEmail": "Sähköpostiosoite vastausta varten (valinnainen)", + "feedbackIncludeTechnicalDetails": "Sisällytä tekniset tiedot", + "feedbackTechnicalDetailsDisclosure": "Lisää vain Linux-käyttöjärjestelmäsi version ja sovelluksen alueasetuksen. Lokeja, tilitietoja, tiedostonimiä tai muita diagnostiikkatietoja ei lisätä.", + "feedbackCategoryRequired": "Valitse luokka.", + "feedbackSubjectLengthError": "Aiheen pituuden on oltava 3–120 merkkiä.", + "feedbackMessageLengthError": "Viestin pituuden on oltava 10–5 000 merkkiä.", + "feedbackInvalidEmail": "Anna kelvollinen sähköpostiosoite.", + "feedbackConnectionError": "BusyStackiin ei saatu yhteyttä. Tarkista yhteys ja yritä uudelleen.", + "feedbackTimeoutError": "Pyyntö aikakatkaistiin. Palautettasi ei ole tyhjennetty. Yritä uudelleen.", + "feedbackRateLimitedError": "Tästä verkosta on lähetetty liian monta palautetta. Odota ja yritä uudelleen.", + "feedbackRejectedError": "Palvelin hylkäsi lähetyksen. Tarkista kentät ja yritä uudelleen.", + "feedbackServerError": "BusyStack ei voi vastaanottaa palautettasi juuri nyt. Palautettasi ei ole tyhjennetty. Yritä uudelleen.", + "feedbackSuccess": "Palaute lähetetty. Viite: {id}", + "toggleSidebar": "Näytä tai piilota sivupalkki", + "showSidebar": "Näytä sivupaneeli", + "hideSidebar": "Piilota sivupaneeli", + "accounts": "Tilit", + "currentAccount": "Nykyinen tili", + "switchAccount": "Vaihda tiliä", + "addGoogleAccount": "Lisää Google-tili", + "addMicrosoftAccount": "Lisää Microsoft-tili", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "Kirjautunut", + "removeAccount": "Poista tili…", + "removingAccount": "Poistetaan tiliä…", + "removeAccountDescription": "Lopeta synkronointi ja poista tämän tilin tiedot tältä laitteelta.", + "removeAccountTitle": "Poistetaanko {account} BusyMaxista?", + "removeAccountConfirmation": "Tämä poistaa välimuistissa olevat tehtävät, kalenterit, tapahtumat, muistutukset ja odottavat offline-muutokset tältä laitteelta. Synkronoimattomat muutokset menetetään. Mitään ei poisteta Googlesta tai Microsoftista.", + "revokeGoogleAccess": "Peruuta myös BusyMaxin käyttöoikeus tähän Google-tiliin", + "revokeGoogleAccessDescription": "Käyttöoikeus on myönnettävä uudelleen ennen tilin yhdistämistä.", + "removeAccountAction": "Poista tili", + "removeAccountFailed": "Tilin poistamista ei voitu viimeistellä. Yritä uudelleen.", + "accountRemovedGoogleRevokeFailed": "Tili poistettiin tältä laitteelta, mutta BusyMax ei voinut peruuttaa Google-käyttöoikeutta. Voit peruuttaa sen Google-tililtäsi.", + "newList": "Uusi luettelo", + "signInToViewTaskLists": "Kirjaudu sisään nähdäksesi tehtäväluettelot.", + "noTaskListsSynced": "Tehtäväluetteloita ei ole vielä synkronoitu.", + "listActions": "Luettelon toiminnot", + "rename": "Nimeä uudelleen", + "delete": "Poista", + "renameList": "Nimeä luettelo uudelleen", + "deleteList": "Poista luettelo", + "builtInMicrosoftList": "Sisäänrakennettu", + "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do -sovelluksen sisäänrakennettuja luetteloita ei voi nimetä uudelleen tai poistaa.", + "deleteListConfirmation": "Poistetaanko \"{title}\" Google Tasksista?", + "deleteEvent": "Poista tapahtuma", + "title": "Nimi", + "create": "Luo", + "newTask": "Uusi tehtävä", + "clearCompleted": "Tyhjennä valmiit", + "refreshList": "Päivitä luettelo", + "refreshAll": "Päivitä kaikki", + "listRefreshed": "Luettelo päivitetty.", + "allTasksRefreshed": "Kaikki tilit päivitetty.", + "exportedFile": "Viety kohteeseen {path}", + "exportFailed": "Vienti epäonnistui: {error}", + "refreshFailed": "Päivitys epäonnistui: {error}", + "selectOrCreateTaskList": "Valitse tai luo tehtäväluettelo aloittaaksesi.", + "signInToViewTasks": "Kirjaudu sisään nähdäksesi tehtävät.", + "noTasks": "Ei tehtäviä.", + "noTasksYet": "Ei vielä tehtäviä", + "noTasksYetMessage": "Luo tehtävä tai päivitä tilisi aloittaaksesi.", + "noTasksInList": "Tässä luettelossa ei ole tehtäviä.", + "overdue": "Myöhässä", + "today": "Tänään", + "tomorrow": "Huomenna", + "upcoming": "Tulossa", + "noDate": "Ei päivämäärää", + "completed": "Valmiit", + "duePrefix": "Eräpäivä {date}", + "dateTimeDisplay": "{date} klo {time}", + "taskDetails": "Tehtävän tiedot", + "editTask": "Muokkaa tehtävää", + "noTaskSelected": "Tehtävää ei ole valittu.", + "noTaskSelectedHelper": "Valitse tehtävä nähdäksesi ja muokataksesi sen tietoja.", + "taskUnavailable": "Tehtävä ei ole käytettävissä.", + "signInToEditTasks": "Kirjaudu sisään muokataksesi tehtäviä.", + "refreshTask": "Päivitä tehtävä", + "primarySection": "Ensisijaiset tiedot", + "statusSection": "Tila", + "openStatus": "Avoin", + "doneStatus": "Valmis", + "notes": "Muistiinpanot", + "dueDate": "Eräpäivä", + "clearDueDate": "Tyhjennä eräpäivä", + "dueTime": "Erääntymisaika", + "startDate": "Alkamispäivä", + "startTime": "Alkamisaika", + "endDate": "Päättymispäivä", + "endTime": "Päättymisaika", + "reminderDate": "Muistutuspäivä", + "reminderTime": "Muistutusaika", + "reminder": "Muistutus", + "addReminder": "Lisää muistutus", + "addGuest": "Lisää vieras", + "addGuestEmail": "Lisää vieraan sähköpostiosoite", + "removeReminder": "Poista muistutus", + "off": "Ei käytössä", + "repeat": "Toisto", + "repeatNone": "Ei toistoa", + "noneValue": "Ei mitään", + "repeatDaily": "Päivittäin", + "repeatWeekly": "Viikoittain", + "repeatMonthly": "Kuukausittain", + "repeatYearly": "Vuosittain", + "importance": "Tärkeys", + "importanceLow": "Pieni", + "importanceNormal": "Normaali", + "importanceHigh": "Suuri", + "categories": "Luokat", + "scheduleSection": "Aikataulu", + "dueGroup": "Eräpäivä", + "startGroup": "Alku", + "reminderGroup": "Muistutus", + "organizationSection": "Järjestely", + "actionsSection": "Toiminnot", + "advancedSection": "Lisäasetukset", + "addCategory": "Lisää luokka", + "list": "Luettelo", + "microsoftMoveUnsupported": "Luettelosta toiseen siirtämistä ei tueta Microsoft To Do -tileillä tässä versiossa.", + "createSubtask": "Luo alitehtävä", + "moveToTop": "Siirrä ylimmäksi", + "deleteTask": "Poista tehtävä", + "newSubtask": "Uusi alitehtävä", + "deleteTaskConfirmation": "Poistetaanko \"{title}\" Google Tasksista?", + "metadata": "Metatiedot", + "id": "Tunnus", + "etag": "ETag", + "updated": "Päivitetty", + "parent": "Ylätehtävä", + "position": "Sijainti", + "webLink": "Verkkolinkki", + "assignment": "Määritys", + "localState": "Paikallinen tila", + "pendingSync": "Odottaa synkronointia", + "synced": "Synkronoitu", + "account": "Tili", + "sync": "Synkronointi", + "manualFullSync": "Manuaalinen täysi synkronointi", + "runInBackgroundWhenClosed": "Jatka toimintaa, kun ikkuna suljetaan", + "showTrayIcon": "Näytä ilmoitusalueen kuvake", + "startMinimizedToTray": "Käynnistä pienennettynä ilmoitusalueelle", + "requiresTrayIcon": "Vaatii ilmoitusalueen kuvakkeen.", + "syncComplete": "Synkronointi valmis.", + "syncFailed": "Synkronointi epäonnistui: {error}", + "notifySyncFailures": "Ilmoita synkronointivirheistä", + "notifyConflicts": "Ilmoita ristiriidoista", + "notifyDueToday": "Ilmoita tänään erääntyvistä", + "eventReminders": "Tapahtumamuistutukset", + "taskReminders": "Tehtävämuistutukset", + "notificationDetailLevel": "Ilmoitusten yksityiskohtaisuus", + "notificationDetailPrivate": "Yksityinen", + "notificationDetailNormal": "Normaali", + "quietHours": "Hiljainen aika", + "quietHoursDescription": "Keskeytä ilmoitukset tällä ajanjaksolla.", + "quietHoursStart": "Hiljaisen ajan alku", + "quietHoursEnd": "Hiljaisen ajan loppu", + "notifications": "Ilmoitukset", + "appearance": "Ulkoasu", + "theme": "Teema", + "themeSystem": "Järjestelmä", + "themeLight": "Vaalea", + "themeDark": "Tumma", + "themeFamily": "Teemaperhe", + "themeFamilyYaru": "Ubuntun oma (Yaru)", + "localization": "Lokalisointi", + "currentLocale": "Nykyinen alueasetus", + "privacy": "Tietosuoja", + "redactTaskContentInDiagnostics": "Peitä tehtävien sisältö diagnostiikassa", + "developerDiagnostics": "Kehittäjän diagnostiikka", + "diagnostics": "Diagnostiikka", + "apiInspectorDisabled": "Näytä API-tarkastaja", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "Discovery-versio: {revision}", + "implementedMethods": "Toteutetut metodit", + "supportsTasksScopes": "Tukee tasks- ja tasks.readonly-käyttöoikeusalueita", + "requiresTasksScope": "Vaatii tasks-käyttöoikeusalueen", + "blockedPendingOperations": "Estetyt odottavat toiminnot", + "signInToInspectPendingOperations": "Kirjaudu sisään tarkastellaksesi odottavia toimintoja.", + "noBlockedPendingOperations": "Ei estettyjä odottavia toimintoja.", + "operationActions": "Toiminnot", + "pendingOpListId": "luettelo={id}", + "pendingOpTaskId": "tehtävä={id}", + "pendingOpAttempts": "yritykset={count}", + "retry": "Yritä uudelleen", + "discard": "Hylkää", + "discardChangesAction": "Hylkää", + "discardChanges": "Hylätäänkö muutokset?", + "discardChangesConfirmation": "Tämä hylkää tehtävän tallentamattomat muutokset.", + "retryCompleted": "Uudelleenyritys suoritettu.", + "discardPendingOperation": "Hylätäänkö odottava toiminto?", + "discardPendingOperationConfirmation": "Tämä poistaa estetyn paikallisen toiminnon. Seuraava synkronointi päivittää tiedot Google Tasksista.", + "pendingOperationDiscarded": "Odottava toiminto hylätty.", + "syncFailureNotificationTitle": "BusyMaxin synkronointi epäonnistui", + "syncFailureNotificationBody": "Taustasynkronointi epäonnistui. {message}", + "conflictNotificationTitle": "BusyMaxin synkronointiristiriita", + "conflictNotificationBody": "Odottava paikallinen muutos estettiin. {summary}", + "dueTodayNotificationTitle": "Tänään erääntyvät tehtävät", + "dueTodayNotificationBody": "{count, plural, =1{Yksi tehtävä erääntyy tänään.} other{{count} tehtävää erääntyy tänään.}}", + "eventReminderNotificationTitle": "Tapahtumamuistutus", + "taskReminderNotificationTitle": "Tehtävämuistutus", + "eventReminderNotificationBody": "Tapahtuma alkaa pian.", + "taskReminderNotificationBody": "Tehtävän määräaika lähestyy.", + "notificationOpenAction": "Avaa", + "notificationDetailsHidden": "Yksityiskohdat on piilotettu tietosuoja-asetusten vuoksi.", + "previousMonth": "Edellinen kuukausi", + "nextMonth": "Seuraava kuukausi", + "openMonthView": "Avaa kuukausinäkymä", + "previousYear": "Edellinen vuosi", + "nextYear": "Seuraava vuosi", + "openYearView": "Avaa vuosinäkymä", + "weekNumberTooltip": "Viikko {number}", + "resizeAllDayPanel": "Muuta koko päivän paneelin kokoa", + "scheduleItemCount": "{count, plural, =1{1 kohde} other{{count} kohdetta}}", + "readOnlyCalendar": "Tämä kalenteri on kirjoitussuojattu.", + "selectTimeZone": "Valitse aikavyöhyke", + "searchLocations": "Hae sijainteja", + "noLocationsFound": "Sijainteja ei löytynyt", + "deleteCalendarConfirmation": "Poistetaanko \"{title}\"?" +} diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index ef966a6..7d4d9a8 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -10,7 +10,7 @@ "onboardingAccountsStepTitle": "Connecter des comptes", "onboardingAccountsStepDescription": "Ajoutez tous les comptes Google et Microsoft que vous voulez utiliser. BusyMax synchronise les calendriers, événements, listes de tâches et tâches de chaque compte.", "onboardingPreferencesStepTitle": "Choisir les paramètres système", - "onboardingPreferencesStepDescription": "Réglez le comportement du bureau, les rappels, le détail des notifications et l’apparence avant d’ouvrir votre planning.", + "onboardingPreferencesStepDescription": "Réglez le comportement de l’application sur le bureau, les rappels, le niveau de détail des notifications et l’apparence avant d’ouvrir votre planning.", "signInWithGoogle": "Se connecter avec Google", "signInWithMicrosoft": "Se connecter avec Microsoft", "googleTasksProvider": "Google Tasks", @@ -35,33 +35,23 @@ "calendars": "Calendriers", "newEvent": "Nouvel événement", "refreshCalendar": "Actualiser le calendrier", - "openInProvider": "Ouvrir chez le fournisseur", + "openInProvider": "Ouvrir dans le service", "hideFromSchedule": "Masquer du planning", "showInSchedule": "Afficher dans le planning", "noCalendarsSynced": "Aucun calendrier synchronisé.", "allDay": "Toute la journée", "moreItems": "+{count} de plus", "noEventsOrTasks": "Aucun événement ni tâche", - "trayAgendaLoading": "Chargement de l’agenda...", - "trayAgendaSignInRequired": "Connectez-vous pour afficher l’agenda.", - "trayAgendaNoSources": "Aucun calendrier ni liste de tâches visible.", - "trayAgendaOpenBusyMax": "Ouvrir l’app", - "trayAgendaRefresh": "Actualiser", - "trayAgendaError": "Agenda indisponible", - "compactAgendaTitle": "Agenda", - "compactAgendaSubtitle": "À venir", - "compactAgendaOverdue": "En retard", - "compactAgendaClear": "Libre pour le moment", - "compactAgendaOpenBusyMax": "Ouvrir BusyMax", - "compactAgendaHide": "Masquer", - "compactAgendaNewTask": "Nouvelle tâche", - "compactAgendaRetry": "Réessayer", - "compactAgendaRefresh": "Actualiser", - "compactAgendaAllDay": "Toute la journée", - "compactAgendaDueToday": "Échéance aujourd’hui", - "compactAgendaDueTomorrow": "Échéance demain", - "compactAgendaDueOn": "Échéance {date}", - "compactAgendaMoreOverdue": "Charger plus de tâches en retard", + "scheduleLoading": "Chargement du planning...", + "scheduleUnavailable": "Planning indisponible", + "scheduleNoSources": "Aucun calendrier ni liste de tâches visible", + "scheduleNoSourcesDescription": "Choisissez les éléments à afficher dans les paramètres, puis actualisez.", + "scheduleSignInRequired": "Connecter un compte", + "scheduleSignInDescription": "Connectez-vous pour synchroniser vos calendriers et vos tâches.", + "scheduleNoSearchResults": "Aucun événement ni aucune tâche ne correspond", + "scheduleNoSearchResultsDescription": "Essayez une autre recherche ou effacez les filtres actuels.", + "refresh": "Actualiser", + "trayOpenBusyMax": "Ouvrir BusyMax", "agendaLoadMoreOverdue": "Charger plus de tâches en retard", "agendaLoadMoreNoDate": "Charger plus de tâches sans date", "viewDay": "Jour", @@ -71,7 +61,7 @@ "viewAgenda": "Agenda", "scheduleSettings": "Planning", "scheduleDisplaySettings": "Affichage du planning", - "scheduleDisplayHoursDescription": "Les vues Jour et Semaine s’ouvrent dans cette plage horaire. Les éléments tôt ou tardifs l’élargissent si nécessaire.", + "scheduleDisplayHoursDescription": "Les vues Jour et Semaine s’ouvrent dans cette plage horaire. Les éléments situés avant ou après cette plage l’étendent si nécessaire.", "scheduleDayStartsAt": "La journée commence à", "scheduleDayEndsAt": "La journée se termine à", "sourceCalendar": "Calendrier", @@ -97,6 +87,29 @@ "conference": "Conférence", "noConference": "Aucune conférence", "providerCalendar": "Calendrier du fournisseur", + "formatBoldShortLabel": "G", + "formatBoldTooltip": "Gras", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "Italique", + "formatUnderlineShortLabel": "S", + "formatUnderlineTooltip": "Souligné", + "reminderMinutesBefore": "{minutes, plural, =1{1 minute avant} other{{minutes} minutes avant}}", + "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "reminderAtStart": "À l’heure de début", + "reminderHoursBefore": "{hours, plural, =1{1 heure avant} other{{hours} heures avant}}", + "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, + "reminderDaysBefore": "{days, plural, =1{1 jour avant} other{{days} jours avant}}", + "@reminderDaysBefore": {"placeholders": {"days": {"type": "int"}}}, + "availabilityFree": "Disponible", + "availabilityTentative": "Provisoire", + "availabilityOutOfOffice": "Absent du bureau", + "availabilityWorkingElsewhere": "Travail ailleurs", + "visibilityDefault": "Par défaut", + "visibilityPublic": "Publique", + "visibilityPrivate": "Privée", + "visibilityConfidential": "Confidentielle", + "sensitivityNormal": "Normale", + "sensitivityPersonal": "Personnelle", "tasks": "Tâches", "allTasks": "Toutes les tâches", "tasksInList": "Tâches dans {title}", @@ -111,7 +124,7 @@ "shortcutNextPeriodDescription": "Semaine suivante en vue semaine, mois suivant en vue mois, etc.", "shortcutPreviousPeriod": "Période précédente", "shortcutPreviousPeriodDescription": "Semaine précédente en vue semaine, mois précédent en vue mois, etc.", - "shortcutJumpToToday": "Aller à aujourd'hui", + "shortcutJumpToToday": "Aller à la date du jour", "shortcutGroupView": "Affichage", "shortcutDayView": "Vue jour", "shortcutWeekView": "Vue semaine", @@ -124,12 +137,12 @@ "shortcutGroupTaskEditing": "Modification des tâches", "shortcutCancelEditing": "Annuler la modification", "shortcutCancelEditingDescription": "Fermer la modification ou les détails de la tâche", - "shortcutGroupCompactAgenda": "Agenda compact", - "shortcutRefreshCompactAgendaDescription": "Actualiser la fenêtre d'agenda compact", - "shortcutHideCompactAgendaDescription": "Masquer la fenêtre d'agenda compact", "aboutBusyMax": "À propos de BusyMax", - "aboutBusyMaxDescription": "ToDo et calendrier", + "aboutBusyMaxDescription": "Calendrier et tâches", + "license": "Licence", + "apacheLicenseName": "Apache License 2.0", "website": "Site web", + "sourceCode": "Code source", "reportAnIssue": "Signaler un problème", "sendFeedback": "Envoyer des commentaires", "feedbackSubmit": "Envoyer", @@ -156,6 +169,8 @@ "feedbackServerError": "BusyStack ne peut pas accepter vos commentaires pour le moment. Vos commentaires n’ont pas été effacés ; réessayez.", "feedbackSuccess": "Commentaires envoyés. Référence : {id}", "toggleSidebar": "Afficher/masquer la barre latérale", + "showSidebar": "Afficher le panneau latéral", + "hideSidebar": "Masquer le panneau latéral", "accounts": "Comptes", "currentAccount": "Compte actuel", "switchAccount": "Changer de compte", @@ -164,10 +179,17 @@ "googleProvider": "Google", "microsoftProvider": "Microsoft", "signedInAccount": "Connecté", - "signOutThisAccount": "Déconnecter ce compte", - "revokeThisAccount": "Révoquer ce compte", - "disconnectThisAccount": "Dissocier ce compte", - "deleteLocalDataForThisAccount": "Supprimer les données locales de ce compte", + "removeAccount": "Supprimer le compte…", + "removingAccount": "Suppression du compte…", + "removeAccountDescription": "Arrêter la synchronisation et supprimer les données de ce compte de cet appareil.", + "removeAccountTitle": "Supprimer {account} de BusyMax ?", + "@removeAccountTitle": {"placeholders": {"account": {"type": "String"}}}, + "removeAccountConfirmation": "Cette action supprime de cet appareil les tâches, calendriers, événements, rappels et modifications hors ligne en attente mis en cache. Les modifications non synchronisées seront perdues. Aucune donnée ne sera supprimée de Google ou Microsoft.", + "revokeGoogleAccess": "Révoquer également l’accès de BusyMax à ce compte Google", + "revokeGoogleAccessDescription": "Vous devrez accorder à nouveau l’accès avant de reconnecter le compte.", + "removeAccountAction": "Supprimer le compte", + "removeAccountFailed": "Impossible de terminer la suppression du compte. Réessayez.", + "accountRemovedGoogleRevokeFailed": "Le compte a été supprimé de cet appareil, mais BusyMax n’a pas pu révoquer son accès à votre compte Google. Vous pouvez révoquer cet accès depuis votre compte Google.", "newList": "Nouvelle liste", "signInToViewTaskLists": "Connectez-vous pour voir les listes de tâches.", "noTaskListsSynced": "Aucune liste de tâches synchronisée.", @@ -183,7 +205,7 @@ "title": "Titre", "create": "Créer", "newTask": "Nouvelle tâche", - "clearCompleted": "Effacer les terminées", + "clearCompleted": "Effacer les tâches terminées", "refreshList": "Actualiser la liste", "refreshAll": "Tout actualiser", "listRefreshed": "Liste actualisée.", @@ -193,7 +215,7 @@ "exportFailed": "Échec de l’export : {error}", "@exportFailed": {"placeholders": {"error": {"type": "String"}}}, "refreshFailed": "Échec de l’actualisation : {error}", - "selectOrCreateTaskList": "Sélectionnez ou créez une liste de tâches.", + "selectOrCreateTaskList": "Sélectionnez ou créez une liste de tâches pour commencer.", "signInToViewTasks": "Connectez-vous pour voir les tâches.", "noTasks": "Aucune tâche.", "noTasksYet": "Aucune tâche pour le moment", @@ -207,11 +229,6 @@ "completed": "Terminées", "duePrefix": "Échéance {date}", "dateTimeDisplay": "{date} à {time}", - "searchTasks": "Rechercher des tâches", - "advancedFilters": "Filtres avancés", - "showCompleted": "Afficher les terminées", - "showHidden": "Afficher les masquées", - "showAssigned": "Afficher les assignées", "taskDetails": "Détails de la tâche", "editTask": "Modifier la tâche", "noTaskSelected": "Aucune tâche sélectionnée.", @@ -262,7 +279,7 @@ "list": "Liste", "microsoftMoveUnsupported": "Le déplacement entre listes n’est pas pris en charge pour les comptes Microsoft To Do dans cette version.", "createSubtask": "Créer une sous-tâche", - "moveToTop": "Déplacer en haut", + "moveToTop": "Déplacer tout en haut", "deleteTask": "Supprimer la tâche", "newSubtask": "Nouvelle sous-tâche", "deleteTaskConfirmation": "Supprimer « {title} » de Google Tasks ?", @@ -270,38 +287,46 @@ "id": "ID", "etag": "ETag", "updated": "Mis à jour", - "parent": "Parent", + "parent": "Tâche parente", "position": "Position", "webLink": "Lien web", - "assignment": "Assignation", + "assignment": "Attribution", "localState": "État local", "pendingSync": "Synchronisation en attente", "synced": "Synchronisé", "account": "Compte", - "signOut": "Se déconnecter", - "revokeGoogleAuthorization": "Révoquer l’autorisation Google", - "deleteLocalData": "Supprimer les données locales", - "deleteLocalDataConfirmation": "Cela supprime de cet appareil le compte local, les tâches synchronisées et les changements hors ligne en attente.", "sync": "Synchronisation", "manualFullSync": "Synchronisation complète manuelle", + "runInBackgroundWhenClosed": "Continuer à s’exécuter après la fermeture de la fenêtre", + "showTrayIcon": "Afficher l’icône dans la zone de notification", + "startMinimizedToTray": "Démarrer réduit dans la zone de notification", + "requiresTrayIcon": "Nécessite l’icône de la zone de notification.", "syncComplete": "Synchronisation terminée.", "syncFailed": "Échec de la synchronisation : {error}", "notifySyncFailures": "Notifications d’échec de synchronisation", "notifyConflicts": "Notifications de conflits", "notifyDueToday": "Notifications des tâches dues aujourd’hui", + "eventReminders": "Rappels d’événements", + "taskReminders": "Rappels de tâches", + "notificationDetailLevel": "Niveau de détail des notifications", + "notificationDetailPrivate": "Privé", + "notificationDetailNormal": "Normal", + "quietHours": "Période de silence", + "quietHoursDescription": "Mettre les notifications en pause pendant cette période.", + "quietHoursStart": "Début de la période de silence", + "quietHoursEnd": "Fin de la période de silence", "notifications": "Notifications", "appearance": "Apparence", "theme": "Thème", "themeSystem": "Système", "themeLight": "Clair", "themeDark": "Sombre", - "themeFamily": "Famille de thème", - "themeFamilyYaru": "Ubuntu natif (Yaru)", + "themeFamily": "Famille de thèmes", + "themeFamilyYaru": "Thème natif d’Ubuntu (Yaru)", "localization": "Localisation", "currentLocale": "Paramètres régionaux actuels", "privacy": "Confidentialité", "redactTaskContentInDiagnostics": "Masquer le contenu des tâches dans les diagnostics", - "detailedNotifications": "Texte de notification détaillé", "developerDiagnostics": "Diagnostics développeur", "diagnostics": "Diagnostics", "apiInspectorDisabled": "Afficher l’inspecteur API", @@ -318,18 +343,41 @@ "pendingOpTaskId": "tâche={id}", "pendingOpAttempts": "tentatives={count}", "retry": "Réessayer", - "discard": "Ignorer", - "discardChanges": "Ignorer les modifications ?", - "discardChangesConfirmation": "Cela ignore les modifications non enregistrées de cette tâche.", + "discard": "Abandonner", + "discardChangesAction": "Abandonner", + "discardChanges": "Abandonner les modifications ?", + "discardChangesConfirmation": "Les modifications non enregistrées apportées à cette tâche seront perdues.", "retryCompleted": "Nouvelle tentative terminée.", - "discardPendingOperation": "Ignorer l’opération en attente ?", - "discardPendingOperationConfirmation": "Cela supprime l’opération locale bloquée. La prochaine synchronisation actualisera depuis Google Tasks.", - "pendingOperationDiscarded": "Opération en attente ignorée.", + "discardPendingOperation": "Abandonner l’opération en attente ?", + "discardPendingOperationConfirmation": "Cette action supprime l’opération locale bloquée. Lors de la prochaine synchronisation, les données seront rechargées depuis Google Tasks.", + "pendingOperationDiscarded": "Opération en attente abandonnée.", "syncFailureNotificationTitle": "Échec de la synchronisation BusyMax", "syncFailureNotificationBody": "La synchronisation en arrière-plan a échoué. {message}", "conflictNotificationTitle": "Conflit de synchronisation BusyMax", "conflictNotificationBody": "Une modification locale en attente a été bloquée. {summary}", "dueTodayNotificationTitle": "Tâches dues aujourd’hui", "dueTodayNotificationBody": "{count, plural, =1{Une tâche est due aujourd’hui.} other{{count} tâches sont dues aujourd’hui.}}", - "notificationDetailsHidden": "Les détails sont masqués par les paramètres de confidentialité." + "eventReminderNotificationTitle": "Rappel d’événement", + "taskReminderNotificationTitle": "Rappel de tâche", + "eventReminderNotificationBody": "L’événement commence bientôt.", + "taskReminderNotificationBody": "La tâche arrive bientôt à échéance.", + "notificationOpenAction": "Ouvrir", + "notificationDetailsHidden": "Les détails sont masqués par les paramètres de confidentialité.", + "previousMonth": "Mois précédent", + "nextMonth": "Mois suivant", + "openMonthView": "Ouvrir la vue mensuelle", + "previousYear": "Année précédente", + "nextYear": "Année suivante", + "openYearView": "Ouvrir la vue annuelle", + "weekNumberTooltip": "Semaine {number}", + "@weekNumberTooltip": {"placeholders": {"number": {"type": "int"}}}, + "resizeAllDayPanel": "Redimensionner le volet des événements sur toute la journée", + "scheduleItemCount": "{count, plural, =1{1 élément} other{{count} éléments}}", + "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, + "readOnlyCalendar": "Ce calendrier est en lecture seule.", + "selectTimeZone": "Sélectionner le fuseau horaire", + "searchLocations": "Rechercher des lieux", + "noLocationsFound": "Aucun lieu trouvé", + "deleteCalendarConfirmation": "Supprimer « {title} » ?", + "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}} } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb new file mode 100644 index 0000000..a809900 --- /dev/null +++ b/lib/l10n/app_hi.arb @@ -0,0 +1,374 @@ +{ + "@@locale": "hi", + "appTitle": "BusyMax", + "connectGoogleAccount": "कैलेंडर और कार्य सिंक करने के लिए Google और Microsoft खाते कनेक्ट करें।", + "googlePermissionsConsentNotice": "Google अनुमति स्क्रीन पर, कैलेंडर और कार्य दोनों अनुमतियाँ चुनें।", + "googlePermissionsRequiredRetry": "Google Calendar और Google Tasks की अनुमतियाँ आवश्यक हैं। फिर से कोशिश करें और दोनों चेकबॉक्स चुनें।", + "finishSetup": "सेटअप पूरा करें", + "continueSetup": "जारी रखें", + "onboardingSetupTitle": "BusyMax सेट अप करें", + "onboardingAccountsStepTitle": "खाते कनेक्ट करें", + "onboardingAccountsStepDescription": "वे सभी Google और Microsoft खाते जोड़ें जिन्हें आप उपयोग करना चाहते हैं। BusyMax प्रत्येक खाते के कैलेंडर, ईवेंट, कार्य सूचियाँ और कार्य सिंक करता है।", + "onboardingPreferencesStepTitle": "सिस्टम सेटिंग्स चुनें", + "onboardingPreferencesStepDescription": "अपना शेड्यूल खोलने से पहले डेस्कटॉप व्यवहार, रिमाइंडर, सूचनाओं के विवरण का स्तर और दिखावट सेट करें।", + "signInWithGoogle": "Google से साइन इन करें", + "signInWithMicrosoft": "Microsoft से साइन इन करें", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "यह प्रदाता कॉन्फ़िगर नहीं किया गया है।", + "waitingForGoogleSignIn": "Google साइन-इन की प्रतीक्षा हो रही है...", + "waitingForMicrosoftSignIn": "Microsoft साइन-इन की प्रतीक्षा हो रही है...", + "microsoftSignInNotConfigured": "Microsoft साइन-इन कॉन्फ़िगर नहीं है। MICROSOFT_OAUTH_CLIENT_ID सेट करें।", + "cancel": "रद्द करें", + "close": "बंद करें", + "exit": "बाहर निकलें", + "options": "विकल्प", + "hide": "छिपाएँ", + "show": "दिखाएँ", + "export": "निर्यात करें", + "save": "सहेजें", + "settings": "सेटिंग्स", + "all": "सभी", + "calendarEvents": "ईवेंट", + "calendarTasks": "कार्य", + "calendar": "कैलेंडर", + "calendars": "कैलेंडर", + "newEvent": "नया ईवेंट", + "refreshCalendar": "कैलेंडर रीफ़्रेश करें", + "openInProvider": "सेवा में खोलें", + "hideFromSchedule": "शेड्यूल से छिपाएँ", + "showInSchedule": "शेड्यूल में दिखाएँ", + "noCalendarsSynced": "अभी तक कोई कैलेंडर सिंक नहीं हुआ है।", + "allDay": "पूरे दिन", + "moreItems": "+{count} और", + "noEventsOrTasks": "कोई ईवेंट या कार्य नहीं", + "scheduleLoading": "शेड्यूल लोड हो रहा है...", + "scheduleUnavailable": "शेड्यूल उपलब्ध नहीं है", + "scheduleNoSources": "कोई दिखाई देने वाला कैलेंडर या कार्य सूची नहीं", + "scheduleNoSourcesDescription": "सेटिंग्स में चुनें कि क्या दिखाना है, फिर रीफ़्रेश करें।", + "scheduleSignInRequired": "खाता कनेक्ट करें", + "scheduleSignInDescription": "कैलेंडर और कार्य सिंक करने के लिए साइन इन करें।", + "scheduleNoSearchResults": "कोई मिलता-जुलता ईवेंट या कार्य नहीं", + "scheduleNoSearchResultsDescription": "कोई दूसरी खोज आज़माएँ या मौजूदा फ़िल्टर हटाएँ।", + "refresh": "रीफ़्रेश करें", + "trayOpenBusyMax": "BusyMax खोलें", + "agendaLoadMoreOverdue": "समय-सीमा पार कर चुके अतिरिक्त कार्य लोड करें", + "agendaLoadMoreNoDate": "बिना तारीख वाले और कार्य लोड करें", + "viewDay": "दिन", + "viewWeek": "सप्ताह", + "viewMonth": "महीना", + "viewYear": "वर्ष", + "viewAgenda": "कार्यसूची", + "scheduleSettings": "शेड्यूल", + "scheduleDisplaySettings": "शेड्यूल प्रदर्शन", + "scheduleDisplayHoursDescription": "दिन और सप्ताह दृश्य शुरू में यह समयावधि दिखाते हैं। आवश्यकता होने पर पहले या बाद के आइटम इस सीमा को बढ़ाते हैं।", + "scheduleDayStartsAt": "दिन शुरू होता है", + "scheduleDayEndsAt": "दिन समाप्त होता है", + "sourceCalendar": "कैलेंडर", + "sourceTaskList": "कार्य सूची", + "createChoiceTitle": "बनाएँ", + "createEventAtTime": "ईवेंट", + "createTaskAtDate": "कार्य", + "editEvent": "ईवेंट संपादित करें", + "eventTitle": "ईवेंट का शीर्षक", + "location": "स्थान", + "timeSlot": "समयावधि", + "startDateTime": "शुरू होने की तारीख/समय", + "endDateTime": "समाप्त होने की तारीख/समय", + "doesNotRepeat": "दोहराया नहीं जाता", + "defaultReminder": "डिफ़ॉल्ट रिमाइंडर", + "guests": "अतिथि", + "noGuests": "कोई अतिथि नहीं", + "description": "विवरण", + "availabilityShowAs": "उपलब्धता / इस रूप में दिखाएँ", + "busy": "व्यस्त", + "visibility": "दृश्यता", + "defaultVisibility": "डिफ़ॉल्ट दृश्यता", + "conference": "कॉन्फ़्रेंस", + "noConference": "कोई कॉन्फ़्रेंस नहीं", + "providerCalendar": "प्रदाता कैलेंडर", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "बोल्ड", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "इटैलिक", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "रेखांकित", + "reminderMinutesBefore": "{minutes, plural, =1{1 मिनट पहले} other{{minutes} मिनट पहले}}", + "reminderAtStart": "शुरू होने पर", + "reminderHoursBefore": "{hours, plural, =1{1 घंटा पहले} other{{hours} घंटे पहले}}", + "reminderDaysBefore": "{days, plural, =1{1 दिन पहले} other{{days} दिन पहले}}", + "availabilityFree": "खाली", + "availabilityTentative": "अस्थायी", + "availabilityOutOfOffice": "कार्यालय से बाहर", + "availabilityWorkingElsewhere": "किसी अन्य स्थान पर कार्यरत", + "visibilityDefault": "डिफ़ॉल्ट", + "visibilityPublic": "सार्वजनिक", + "visibilityPrivate": "निजी", + "visibilityConfidential": "गोपनीय", + "sensitivityNormal": "सामान्य", + "sensitivityPersonal": "व्यक्तिगत", + "tasks": "कार्य", + "allTasks": "सभी कार्य", + "tasksInList": "{title} में कार्य", + "taskLists": "कार्य सूचियाँ", + "navigation": "नेविगेशन", + "mainMenu": "मुख्य मेन्यू", + "keyboardShortcuts": "कीबोर्ड शॉर्टकट", + "shortcutGroupGeneral": "सामान्य", + "shortcutKeyboardShortcutsDescription": "यह शॉर्टकट संदर्भ दिखाएँ", + "shortcutGroupNavigation": "नेविगेशन", + "shortcutNextPeriod": "अगली अवधि", + "shortcutNextPeriodDescription": "सप्ताह दृश्य में अगला सप्ताह, महीने के दृश्य में अगला महीना, इत्यादि", + "shortcutPreviousPeriod": "पिछली अवधि", + "shortcutPreviousPeriodDescription": "सप्ताह दृश्य में पिछला सप्ताह, महीने के दृश्य में पिछला महीना, इत्यादि", + "shortcutJumpToToday": "आज की तारीख पर जाएँ", + "shortcutGroupView": "दृश्य", + "shortcutDayView": "दिन का दृश्य", + "shortcutWeekView": "सप्ताह का दृश्य", + "shortcutMonthView": "महीने का दृश्य", + "shortcutYearView": "वर्ष का दृश्य", + "shortcutAgendaView": "कार्यसूची दृश्य", + "shortcutGroupCreateAndEdit": "बनाएँ और संपादित करें", + "shortcutSaveItem": "ईवेंट या कार्य सहेजें", + "shortcutDeleteItem": "ईवेंट या कार्य मिटाएँ", + "shortcutGroupTaskEditing": "कार्य संपादन", + "shortcutCancelEditing": "संपादन रद्द करें", + "shortcutCancelEditingDescription": "कार्य संपादन या कार्य विवरण बंद करें", + "aboutBusyMax": "BusyMax के बारे में", + "aboutBusyMaxDescription": "कैलेंडर और कार्य", + "license": "लाइसेंस", + "apacheLicenseName": "Apache License 2.0", + "website": "वेबसाइट", + "sourceCode": "स्रोत कोड", + "reportAnIssue": "समस्या की रिपोर्ट करें", + "sendFeedback": "प्रतिक्रिया भेजें", + "feedbackSubmit": "सबमिट करें", + "feedbackCategory": "श्रेणी", + "feedbackSelectCategory": "श्रेणी चुनें", + "feedbackCategoryProblem": "समस्या या बग", + "feedbackCategoryFeature": "सुविधा का अनुरोध", + "feedbackCategoryPrivacySecurity": "गोपनीयता या सुरक्षा संबंधी चिंता", + "feedbackCategoryUsability": "उपयोगिता संबंधी चिंता", + "feedbackCategoryOther": "अन्य", + "feedbackSubject": "विषय", + "feedbackDetailedMessage": "विस्तृत संदेश", + "feedbackReplyEmail": "जवाब के लिए ईमेल (वैकल्पिक)", + "feedbackIncludeTechnicalDetails": "तकनीकी विवरण शामिल करें", + "feedbackTechnicalDetailsDisclosure": "केवल आपके Linux ऑपरेटिंग सिस्टम का संस्करण और ऐप की भाषा व क्षेत्रीय सेटिंग जोड़ी जाती है। लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान जानकारी शामिल नहीं की जाती।", + "feedbackCategoryRequired": "श्रेणी चुनें।", + "feedbackSubjectLengthError": "विषय 3 से 120 वर्णों के बीच होना चाहिए।", + "feedbackMessageLengthError": "संदेश 10 से 5,000 वर्णों के बीच होना चाहिए।", + "feedbackInvalidEmail": "मान्य ईमेल पता दर्ज करें।", + "feedbackConnectionError": "BusyStack से कनेक्ट नहीं हो सका। अपना कनेक्शन जाँचें और फिर कोशिश करें।", + "feedbackTimeoutError": "अनुरोध का समय समाप्त हो गया। आपकी प्रतिक्रिया हटाई नहीं गई है; फिर से कोशिश करें।", + "feedbackRateLimitedError": "इस नेटवर्क से बहुत अधिक प्रतिक्रियाएँ भेजी गई हैं। प्रतीक्षा करें और फिर कोशिश करें।", + "feedbackRejectedError": "सर्वर ने सबमिशन अस्वीकार कर दिया। फ़ील्ड की समीक्षा करें और फिर कोशिश करें।", + "feedbackServerError": "BusyStack अभी आपकी प्रतिक्रिया स्वीकार नहीं कर सका। आपकी प्रतिक्रिया हटाई नहीं गई है; फिर से कोशिश करें।", + "feedbackSuccess": "प्रतिक्रिया भेज दी गई। संदर्भ: {id}", + "toggleSidebar": "साइडबार दिखाएँ या छिपाएँ", + "showSidebar": "साइडबार पैनल दिखाएँ", + "hideSidebar": "साइडबार पैनल छिपाएँ", + "accounts": "खाते", + "currentAccount": "मौजूदा खाता", + "switchAccount": "खाता बदलें", + "addGoogleAccount": "Google खाता जोड़ें", + "addMicrosoftAccount": "Microsoft खाता जोड़ें", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "साइन इन है", + "removeAccount": "खाता हटाएँ…", + "removingAccount": "खाता हटाया जा रहा है…", + "removeAccountDescription": "सिंक करना बंद करें और इस डिवाइस से इस खाते का डेटा हटाएँ।", + "removeAccountTitle": "BusyMax से {account} हटाएँ?", + "removeAccountConfirmation": "इससे कैश किए गए कार्य, कैलेंडर, ईवेंट, रिमाइंडर और लंबित ऑफ़लाइन बदलाव इस डिवाइस से मिट जाएँगे। सिंक न किए गए बदलाव खो जाएँगे। Google या Microsoft से कुछ भी नहीं मिटेगा।", + "revokeGoogleAccess": "इस Google खाते से BusyMax की पहुँच भी रद्द करें", + "revokeGoogleAccessDescription": "दोबारा कनेक्ट करने से पहले आपको फिर से पहुँच देनी होगी।", + "removeAccountAction": "खाता हटाएँ", + "removeAccountFailed": "खाता हटाना पूरा नहीं हो सका। फिर से कोशिश करें।", + "accountRemovedGoogleRevokeFailed": "खाता इस डिवाइस से हटा दिया गया, लेकिन BusyMax की Google खाते तक पहुँच रद्द नहीं की जा सकी। आप यह पहुँच अपने Google खाते से रद्द कर सकते हैं।", + "newList": "नई सूची", + "signInToViewTaskLists": "कार्य सूचियाँ देखने के लिए साइन इन करें।", + "noTaskListsSynced": "अभी तक कोई कार्य सूची सिंक नहीं हुई है।", + "listActions": "सूची की कार्रवाइयाँ", + "rename": "नाम बदलें", + "delete": "मिटाएँ", + "renameList": "सूची का नाम बदलें", + "deleteList": "सूची मिटाएँ", + "builtInMicrosoftList": "अंतर्निहित", + "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do की अंतर्निहित सूचियों का नाम बदला या उन्हें मिटाया नहीं जा सकता।", + "deleteListConfirmation": "Google Tasks से “{title}” मिटाएँ?", + "deleteEvent": "ईवेंट मिटाएँ", + "title": "शीर्षक", + "create": "बनाएँ", + "newTask": "नया कार्य", + "clearCompleted": "पूरे हुए कार्य हटाएँ", + "refreshList": "सूची रीफ़्रेश करें", + "refreshAll": "सभी रीफ़्रेश करें", + "listRefreshed": "सूची रीफ़्रेश हो गई।", + "allTasksRefreshed": "सभी खाते रीफ़्रेश हो गए।", + "exportedFile": "{path} में निर्यात किया गया", + "exportFailed": "निर्यात विफल: {error}", + "refreshFailed": "रीफ़्रेश विफल: {error}", + "selectOrCreateTaskList": "शुरू करने के लिए कार्य सूची चुनें या बनाएँ।", + "signInToViewTasks": "कार्य देखने के लिए साइन इन करें।", + "noTasks": "कोई कार्य नहीं।", + "noTasksYet": "अभी तक कोई कार्य नहीं", + "noTasksYetMessage": "शुरू करने के लिए कार्य बनाएँ या अपने खाते रीफ़्रेश करें।", + "noTasksInList": "इस सूची में कोई कार्य नहीं है।", + "overdue": "समय सीमा बीत चुकी", + "today": "आज", + "tomorrow": "कल", + "upcoming": "आगामी", + "noDate": "कोई तारीख नहीं", + "completed": "पूर्ण", + "duePrefix": "{date} को देय", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "कार्य का विवरण", + "editTask": "कार्य संपादित करें", + "noTaskSelected": "कोई कार्य नहीं चुना गया।", + "noTaskSelectedHelper": "विवरण देखने और संपादित करने के लिए कोई कार्य चुनें।", + "taskUnavailable": "कार्य उपलब्ध नहीं है।", + "signInToEditTasks": "कार्य संपादित करने के लिए साइन इन करें।", + "refreshTask": "कार्य रीफ़्रेश करें", + "primarySection": "मुख्य", + "statusSection": "स्थिति", + "openStatus": "खुला", + "doneStatus": "पूर्ण", + "notes": "नोट्स", + "dueDate": "देय तारीख", + "clearDueDate": "देय तारीख हटाएँ", + "dueTime": "देय समय", + "startDate": "शुरू होने की तारीख", + "startTime": "शुरू होने का समय", + "endDate": "समाप्ति तारीख", + "endTime": "समाप्ति समय", + "reminderDate": "रिमाइंडर की तारीख", + "reminderTime": "रिमाइंडर का समय", + "reminder": "रिमाइंडर", + "addReminder": "रिमाइंडर जोड़ें", + "addGuest": "अतिथि जोड़ें", + "addGuestEmail": "अतिथि का ईमेल जोड़ें", + "removeReminder": "रिमाइंडर हटाएँ", + "off": "बंद", + "repeat": "दोहराएँ", + "repeatNone": "कभी नहीं", + "noneValue": "कोई नहीं", + "repeatDaily": "प्रतिदिन", + "repeatWeekly": "हर सप्ताह", + "repeatMonthly": "हर महीने", + "repeatYearly": "हर वर्ष", + "importance": "महत्त्व", + "importanceLow": "कम", + "importanceNormal": "सामान्य", + "importanceHigh": "उच्च", + "categories": "श्रेणियाँ", + "scheduleSection": "शेड्यूल", + "dueGroup": "देय", + "startGroup": "शुरुआत", + "reminderGroup": "रिमाइंडर", + "organizationSection": "व्यवस्था", + "actionsSection": "कार्रवाइयाँ", + "advancedSection": "उन्नत", + "addCategory": "श्रेणी जोड़ें", + "list": "सूची", + "microsoftMoveUnsupported": "इस संस्करण में Microsoft To Do खातों के लिए सूचियों के बीच कार्य ले जाना समर्थित नहीं है।", + "createSubtask": "उपकार्य बनाएँ", + "moveToTop": "सबसे ऊपर ले जाएँ", + "deleteTask": "कार्य मिटाएँ", + "newSubtask": "नया उपकार्य", + "deleteTaskConfirmation": "Google Tasks से “{title}” मिटाएँ?", + "metadata": "मेटाडेटा", + "id": "आईडी", + "etag": "ETag", + "updated": "अपडेट किया गया", + "parent": "मूल कार्य", + "position": "स्थान", + "webLink": "वेब लिंक", + "assignment": "असाइनमेंट", + "localState": "स्थानीय स्थिति", + "pendingSync": "सिंक लंबित", + "synced": "सिंक किया गया", + "account": "खाता", + "sync": "सिंक", + "manualFullSync": "मैन्युअल पूर्ण सिंक", + "runInBackgroundWhenClosed": "विंडो बंद होने पर भी चलते रहें", + "showTrayIcon": "ट्रे आइकन दिखाएँ", + "startMinimizedToTray": "ट्रे में मिनिमाइज़ होकर शुरू करें", + "requiresTrayIcon": "ट्रे आइकन आवश्यक है।", + "syncComplete": "सिंक पूरा हुआ।", + "syncFailed": "सिंक विफल: {error}", + "notifySyncFailures": "सिंक विफल होने की सूचनाएँ", + "notifyConflicts": "टकराव की सूचनाएँ", + "notifyDueToday": "आज देय कार्यों की सूचनाएँ", + "eventReminders": "ईवेंट रिमाइंडर", + "taskReminders": "कार्य रिमाइंडर", + "notificationDetailLevel": "सूचनाओं के विवरण का स्तर", + "notificationDetailPrivate": "निजी", + "notificationDetailNormal": "सामान्य", + "quietHours": "शांत समय", + "quietHoursDescription": "इस अवधि के दौरान सूचनाएँ रोकें।", + "quietHoursStart": "शांत समय की शुरुआत", + "quietHoursEnd": "शांत समय की समाप्ति", + "notifications": "सूचनाएँ", + "appearance": "दिखावट", + "theme": "थीम", + "themeSystem": "सिस्टम", + "themeLight": "हल्की", + "themeDark": "गहरी", + "themeFamily": "थीम परिवार", + "themeFamilyYaru": "Ubuntu की मूल थीम (Yaru)", + "localization": "स्थानीयकरण", + "currentLocale": "मौजूदा भाषा और क्षेत्रीय सेटिंग", + "privacy": "गोपनीयता", + "redactTaskContentInDiagnostics": "निदान में कार्य सामग्री छिपाएँ", + "developerDiagnostics": "डेवलपर निदान", + "diagnostics": "निदान", + "apiInspectorDisabled": "API इंस्पेक्टर दिखाएँ", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "डिस्कवरी संशोधन: {revision}", + "implementedMethods": "लागू की गई विधियाँ", + "supportsTasksScopes": "tasks और tasks.readonly स्कोप समर्थित हैं", + "requiresTasksScope": "tasks स्कोप आवश्यक है", + "blockedPendingOperations": "अवरुद्ध लंबित कार्रवाइयाँ", + "signInToInspectPendingOperations": "लंबित कार्रवाइयाँ देखने के लिए साइन इन करें।", + "noBlockedPendingOperations": "कोई अवरुद्ध लंबित कार्रवाई नहीं है।", + "operationActions": "कार्रवाई के विकल्प", + "pendingOpListId": "सूची={id}", + "pendingOpTaskId": "कार्य={id}", + "pendingOpAttempts": "प्रयास={count}", + "retry": "फिर से कोशिश करें", + "discard": "खारिज करें", + "discardChangesAction": "बदलाव छोड़ें", + "discardChanges": "बदलाव खारिज करें?", + "discardChangesConfirmation": "इससे इस कार्य में किए गए सहेजे न गए बदलाव खारिज हो जाएँगे।", + "retryCompleted": "दोबारा प्रयास पूरा हुआ।", + "discardPendingOperation": "लंबित कार्रवाई खारिज करें?", + "discardPendingOperationConfirmation": "इससे अवरुद्ध स्थानीय कार्रवाई हट जाती है। अगला सिंक Google Tasks से डेटा रीफ़्रेश करेगा।", + "pendingOperationDiscarded": "लंबित कार्रवाई खारिज कर दी गई।", + "syncFailureNotificationTitle": "BusyMax सिंक विफल", + "syncFailureNotificationBody": "बैकग्राउंड सिंक विफल हुआ। {message}", + "conflictNotificationTitle": "BusyMax सिंक में टकराव", + "conflictNotificationBody": "एक लंबित स्थानीय बदलाव अवरुद्ध हो गया। {summary}", + "dueTodayNotificationTitle": "आज देय कार्य", + "dueTodayNotificationBody": "{count, plural, =1{आज एक कार्य देय है।} other{आज {count} कार्य देय हैं।}}", + "eventReminderNotificationTitle": "ईवेंट रिमाइंडर", + "taskReminderNotificationTitle": "कार्य रिमाइंडर", + "eventReminderNotificationBody": "ईवेंट जल्द शुरू होगा।", + "taskReminderNotificationBody": "कार्य जल्द देय है।", + "notificationOpenAction": "खोलें", + "notificationDetailsHidden": "गोपनीयता सेटिंग्स के कारण विवरण छिपे हुए हैं।", + "previousMonth": "पिछला महीना", + "nextMonth": "अगला महीना", + "openMonthView": "महीने का दृश्य खोलें", + "previousYear": "पिछला वर्ष", + "nextYear": "अगला वर्ष", + "openYearView": "वर्ष का दृश्य खोलें", + "weekNumberTooltip": "सप्ताह {number}", + "resizeAllDayPanel": "पूरे दिन वाले पैनल का आकार बदलें", + "scheduleItemCount": "{count, plural, =1{1 आइटम} other{{count} आइटम}}", + "readOnlyCalendar": "यह कैलेंडर केवल पढ़ने योग्य है।", + "selectTimeZone": "समय क्षेत्र चुनें", + "searchLocations": "स्थान खोजें", + "noLocationsFound": "कोई स्थान नहीं मिला", + "deleteCalendarConfirmation": "“{title}” मिटाएँ?" +} diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb new file mode 100644 index 0000000..f486c22 --- /dev/null +++ b/lib/l10n/app_it.arb @@ -0,0 +1,374 @@ +{ + "@@locale": "it", + "appTitle": "BusyMax", + "connectGoogleAccount": "Collega gli account Google e Microsoft per sincronizzare calendari e attività.", + "googlePermissionsConsentNotice": "Nella schermata delle autorizzazioni di Google, seleziona sia l’autorizzazione per il calendario sia quella per le attività.", + "googlePermissionsRequiredRetry": "Sono necessarie le autorizzazioni per Google Calendar e Google Tasks. Riprova e seleziona entrambe le caselle.", + "finishSetup": "Completa la configurazione", + "continueSetup": "Continua", + "onboardingSetupTitle": "Configura BusyMax", + "onboardingAccountsStepTitle": "Collega gli account", + "onboardingAccountsStepDescription": "Aggiungi tutti gli account Google e Microsoft che vuoi utilizzare. BusyMax sincronizza calendari, eventi, elenchi di attività e attività di ogni account.", + "onboardingPreferencesStepTitle": "Scegli le impostazioni di sistema", + "onboardingPreferencesStepDescription": "Prima di aprire l’agenda, configura il comportamento sul desktop, i promemoria, il livello di dettaglio delle notifiche e l’aspetto.", + "signInWithGoogle": "Accedi con Google", + "signInWithMicrosoft": "Accedi con Microsoft", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "Questo servizio non è configurato.", + "waitingForGoogleSignIn": "In attesa dell’accesso a Google...", + "waitingForMicrosoftSignIn": "In attesa dell’accesso a Microsoft...", + "microsoftSignInNotConfigured": "L’accesso a Microsoft non è configurato. Imposta MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "Annulla", + "close": "Chiudi", + "exit": "Esci", + "options": "Opzioni", + "hide": "Nascondi", + "show": "Mostra", + "export": "Esporta", + "save": "Salva", + "settings": "Impostazioni", + "all": "Tutto", + "calendarEvents": "Eventi", + "calendarTasks": "Attività", + "calendar": "Calendario", + "calendars": "Calendari", + "newEvent": "Nuovo evento", + "refreshCalendar": "Aggiorna calendario", + "openInProvider": "Apri nel servizio", + "hideFromSchedule": "Nascondi dall’agenda", + "showInSchedule": "Mostra nell’agenda", + "noCalendarsSynced": "Nessun calendario ancora sincronizzato.", + "allDay": "Tutto il giorno", + "moreItems": "+{count} in più", + "noEventsOrTasks": "Nessun evento o attività", + "scheduleLoading": "Caricamento agenda...", + "scheduleUnavailable": "Agenda non disponibile", + "scheduleNoSources": "Nessun calendario o elenco di attività visibile", + "scheduleNoSourcesDescription": "Scegli cosa mostrare nelle Impostazioni, quindi aggiorna l’agenda.", + "scheduleSignInRequired": "Collega un account", + "scheduleSignInDescription": "Accedi per sincronizzare calendari e attività.", + "scheduleNoSearchResults": "Nessun evento o attività corrispondente", + "scheduleNoSearchResultsDescription": "Prova una ricerca diversa o cancella i filtri attuali.", + "refresh": "Aggiorna", + "trayOpenBusyMax": "Apri BusyMax", + "agendaLoadMoreOverdue": "Carica altre attività scadute", + "agendaLoadMoreNoDate": "Carica altre attività senza data", + "viewDay": "Giorno", + "viewWeek": "Settimana", + "viewMonth": "Mese", + "viewYear": "Anno", + "viewAgenda": "Agenda", + "scheduleSettings": "Agenda", + "scheduleDisplaySettings": "Visualizzazione agenda", + "scheduleDisplayHoursDescription": "Le viste Giorno e Settimana mostrano inizialmente questo intervallo orario. Gli elementi precedenti o successivi lo estendono quando necessario.", + "scheduleDayStartsAt": "Inizio giornata", + "scheduleDayEndsAt": "Fine giornata", + "sourceCalendar": "Calendario", + "sourceTaskList": "Elenco di attività", + "createChoiceTitle": "Crea", + "createEventAtTime": "Evento", + "createTaskAtDate": "Attività", + "editEvent": "Modifica evento", + "eventTitle": "Titolo dell’evento", + "location": "Luogo", + "timeSlot": "Fascia oraria", + "startDateTime": "Data/ora di inizio", + "endDateTime": "Data/ora di fine", + "doesNotRepeat": "Non si ripete", + "defaultReminder": "Promemoria predefinito", + "guests": "Invitati", + "noGuests": "Nessun invitato", + "description": "Descrizione", + "availabilityShowAs": "Disponibilità / Mostra come", + "busy": "Occupato", + "visibility": "Visibilità", + "defaultVisibility": "Visibilità predefinita", + "conference": "Conferenza", + "noConference": "Nessuna conferenza", + "providerCalendar": "Calendario del servizio", + "formatBoldShortLabel": "G", + "formatBoldTooltip": "Grassetto", + "formatItalicShortLabel": "C", + "formatItalicTooltip": "Corsivo", + "formatUnderlineShortLabel": "S", + "formatUnderlineTooltip": "Sottolineato", + "reminderMinutesBefore": "{minutes, plural, =1{1 minuto prima} other{{minutes} minuti prima}}", + "reminderAtStart": "All’inizio", + "reminderHoursBefore": "{hours, plural, =1{1 ora prima} other{{hours} ore prima}}", + "reminderDaysBefore": "{days, plural, =1{1 giorno prima} other{{days} giorni prima}}", + "availabilityFree": "Libero", + "availabilityTentative": "Provvisorio", + "availabilityOutOfOffice": "Fuori sede", + "availabilityWorkingElsewhere": "Lavora altrove", + "visibilityDefault": "Predefinita", + "visibilityPublic": "Pubblica", + "visibilityPrivate": "Privata", + "visibilityConfidential": "Riservata", + "sensitivityNormal": "Normale", + "sensitivityPersonal": "Personale", + "tasks": "Attività", + "allTasks": "Tutte le attività", + "tasksInList": "Attività in {title}", + "taskLists": "Elenchi di attività", + "navigation": "Navigazione", + "mainMenu": "Menu principale", + "keyboardShortcuts": "Scorciatoie da tastiera", + "shortcutGroupGeneral": "Generali", + "shortcutKeyboardShortcutsDescription": "Mostra questo elenco di scorciatoie", + "shortcutGroupNavigation": "Navigazione", + "shortcutNextPeriod": "Periodo successivo", + "shortcutNextPeriodDescription": "Settimana successiva nella vista settimanale, mese successivo nella vista mensile e così via", + "shortcutPreviousPeriod": "Periodo precedente", + "shortcutPreviousPeriodDescription": "Settimana precedente nella vista settimanale, mese precedente nella vista mensile e così via", + "shortcutJumpToToday": "Vai alla data odierna", + "shortcutGroupView": "Vista", + "shortcutDayView": "Vista giornaliera", + "shortcutWeekView": "Vista settimanale", + "shortcutMonthView": "Vista mensile", + "shortcutYearView": "Vista annuale", + "shortcutAgendaView": "Vista agenda", + "shortcutGroupCreateAndEdit": "Creazione e modifica", + "shortcutSaveItem": "Salva evento o attività", + "shortcutDeleteItem": "Elimina evento o attività", + "shortcutGroupTaskEditing": "Modifica delle attività", + "shortcutCancelEditing": "Annulla modifica", + "shortcutCancelEditingDescription": "Chiudi la modifica o i dettagli dell’attività", + "aboutBusyMax": "Informazioni su BusyMax", + "aboutBusyMaxDescription": "Calendario e attività", + "license": "Licenza", + "apacheLicenseName": "Apache License 2.0", + "website": "Sito web", + "sourceCode": "Codice sorgente", + "reportAnIssue": "Segnala un problema", + "sendFeedback": "Invia feedback", + "feedbackSubmit": "Invia", + "feedbackCategory": "Categoria", + "feedbackSelectCategory": "Seleziona una categoria", + "feedbackCategoryProblem": "Problema o errore", + "feedbackCategoryFeature": "Richiesta di funzionalità", + "feedbackCategoryPrivacySecurity": "Problema di privacy o sicurezza", + "feedbackCategoryUsability": "Problema di usabilità", + "feedbackCategoryOther": "Altro", + "feedbackSubject": "Oggetto", + "feedbackDetailedMessage": "Messaggio dettagliato", + "feedbackReplyEmail": "Indirizzo email per la risposta (facoltativo)", + "feedbackIncludeTechnicalDetails": "Includi dettagli tecnici", + "feedbackTechnicalDetailsDisclosure": "Aggiunge soltanto la versione del sistema operativo Linux e le impostazioni locali dell’applicazione. Non vengono inclusi log, dati degli account, nomi di file o altre informazioni diagnostiche.", + "feedbackCategoryRequired": "Seleziona una categoria.", + "feedbackSubjectLengthError": "L’oggetto deve contenere da 3 a 120 caratteri.", + "feedbackMessageLengthError": "Il messaggio deve contenere da 10 a 5.000 caratteri.", + "feedbackInvalidEmail": "Inserisci un indirizzo email valido.", + "feedbackConnectionError": "Impossibile connettersi a BusyStack. Controlla la connessione e riprova.", + "feedbackTimeoutError": "La richiesta ha superato il tempo limite. Il feedback non è stato cancellato; riprova.", + "feedbackRateLimitedError": "Sono stati inviati troppi feedback da questa rete. Attendi e riprova.", + "feedbackRejectedError": "Il server ha rifiutato l’invio. Controlla i campi e riprova.", + "feedbackServerError": "BusyStack non può accettare il feedback in questo momento. Il feedback non è stato cancellato; riprova.", + "feedbackSuccess": "Feedback inviato. Riferimento: {id}", + "toggleSidebar": "Mostra o nascondi la barra laterale", + "showSidebar": "Mostra il pannello laterale", + "hideSidebar": "Nascondi il pannello laterale", + "accounts": "Account", + "currentAccount": "Account attuale", + "switchAccount": "Cambia account", + "addGoogleAccount": "Aggiungi account Google", + "addMicrosoftAccount": "Aggiungi account Microsoft", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "Accesso effettuato", + "removeAccount": "Rimuovi account…", + "removingAccount": "Rimozione account…", + "removeAccountDescription": "Interrompi la sincronizzazione e rimuovi i dati di questo account dal dispositivo.", + "removeAccountTitle": "Rimuovere {account} da BusyMax?", + "removeAccountConfirmation": "Questa azione elimina dal dispositivo attività, calendari, eventi e promemoria memorizzati nella cache, oltre alle modifiche offline in sospeso. Le modifiche non sincronizzate andranno perse. Non verrà eliminato nulla da Google o Microsoft.", + "revokeGoogleAccess": "Revoca anche l’accesso di BusyMax a questo account Google", + "revokeGoogleAccessDescription": "Dovrai concedere nuovamente l’accesso prima di riconnetterti.", + "removeAccountAction": "Rimuovi account", + "removeAccountFailed": "Impossibile completare la rimozione dell’account. Riprova.", + "accountRemovedGoogleRevokeFailed": "L’account è stato rimosso da questo dispositivo, ma BusyMax non è riuscito a revocare il proprio accesso a Google. Puoi revocarlo dal tuo account Google.", + "newList": "Nuovo elenco", + "signInToViewTaskLists": "Accedi per visualizzare gli elenchi di attività.", + "noTaskListsSynced": "Nessun elenco di attività ancora sincronizzato.", + "listActions": "Azioni dell’elenco", + "rename": "Rinomina", + "delete": "Elimina", + "renameList": "Rinomina elenco", + "deleteList": "Elimina elenco", + "builtInMicrosoftList": "Integrato", + "builtInMicrosoftListCannotRenameDelete": "Gli elenchi integrati di Microsoft To Do non possono essere rinominati o eliminati.", + "deleteListConfirmation": "Eliminare «{title}» da Google Tasks?", + "deleteEvent": "Elimina evento", + "title": "Titolo", + "create": "Crea", + "newTask": "Nuova attività", + "clearCompleted": "Cancella attività completate", + "refreshList": "Aggiorna elenco", + "refreshAll": "Aggiorna tutto", + "listRefreshed": "Elenco aggiornato.", + "allTasksRefreshed": "Tutti gli account sono stati aggiornati.", + "exportedFile": "Esportato in {path}", + "exportFailed": "Esportazione non riuscita: {error}", + "refreshFailed": "Aggiornamento non riuscito: {error}", + "selectOrCreateTaskList": "Seleziona o crea un elenco di attività per iniziare.", + "signInToViewTasks": "Accedi per visualizzare le attività.", + "noTasks": "Nessuna attività.", + "noTasksYet": "Ancora nessuna attività", + "noTasksYetMessage": "Crea un’attività o aggiorna gli account per iniziare.", + "noTasksInList": "Nessuna attività in questo elenco.", + "overdue": "Scadute", + "today": "Oggi", + "tomorrow": "Domani", + "upcoming": "In arrivo", + "noDate": "Senza data", + "completed": "Completate", + "duePrefix": "Scadenza: {date}", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "Dettagli attività", + "editTask": "Modifica attività", + "noTaskSelected": "Nessuna attività selezionata.", + "noTaskSelectedHelper": "Seleziona un’attività per visualizzarne e modificarne i dettagli.", + "taskUnavailable": "Attività non disponibile.", + "signInToEditTasks": "Accedi per modificare le attività.", + "refreshTask": "Aggiorna attività", + "primarySection": "Principale", + "statusSection": "Stato", + "openStatus": "Aperta", + "doneStatus": "Completata", + "notes": "Note", + "dueDate": "Data di scadenza", + "clearDueDate": "Cancella data di scadenza", + "dueTime": "Ora di scadenza", + "startDate": "Data di inizio", + "startTime": "Ora di inizio", + "endDate": "Data di fine", + "endTime": "Ora di fine", + "reminderDate": "Data del promemoria", + "reminderTime": "Ora del promemoria", + "reminder": "Promemoria", + "addReminder": "Aggiungi promemoria", + "addGuest": "Aggiungi invitato", + "addGuestEmail": "Aggiungi email dell’invitato", + "removeReminder": "Rimuovi promemoria", + "off": "Disattivato", + "repeat": "Ripeti", + "repeatNone": "Nessuna ripetizione", + "noneValue": "Nessuno", + "repeatDaily": "Ogni giorno", + "repeatWeekly": "Ogni settimana", + "repeatMonthly": "Ogni mese", + "repeatYearly": "Ogni anno", + "importance": "Importanza", + "importanceLow": "Bassa", + "importanceNormal": "Normale", + "importanceHigh": "Alta", + "categories": "Categorie", + "scheduleSection": "Programmazione", + "dueGroup": "Scadenza", + "startGroup": "Inizio", + "reminderGroup": "Promemoria", + "organizationSection": "Organizzazione", + "actionsSection": "Azioni", + "advancedSection": "Avanzate", + "addCategory": "Aggiungi categoria", + "list": "Elenco", + "microsoftMoveUnsupported": "In questa versione non è possibile spostare attività tra elenchi negli account Microsoft To Do.", + "createSubtask": "Crea sottoattività", + "moveToTop": "Sposta in cima", + "deleteTask": "Elimina attività", + "newSubtask": "Nuova sottoattività", + "deleteTaskConfirmation": "Eliminare «{title}» da Google Tasks?", + "metadata": "Metadati", + "id": "ID", + "etag": "ETag", + "updated": "Aggiornato", + "parent": "Attività principale", + "position": "Posizione", + "webLink": "Collegamento web", + "assignment": "Assegnazione", + "localState": "Stato locale", + "pendingSync": "Sincronizzazione in sospeso", + "synced": "Sincronizzato", + "account": "Account", + "sync": "Sincronizzazione", + "manualFullSync": "Sincronizzazione completa manuale", + "runInBackgroundWhenClosed": "Continua a funzionare quando la finestra è chiusa", + "showTrayIcon": "Mostra icona nell’area di notifica", + "startMinimizedToTray": "Avvia ridotto a icona nell’area di notifica", + "requiresTrayIcon": "Richiede l’icona nell’area di notifica.", + "syncComplete": "Sincronizzazione completata.", + "syncFailed": "Sincronizzazione non riuscita: {error}", + "notifySyncFailures": "Notifiche in caso di errore di sincronizzazione", + "notifyConflicts": "Notifiche in caso di conflitto", + "notifyDueToday": "Notifiche per attività in scadenza oggi", + "eventReminders": "Promemoria degli eventi", + "taskReminders": "Promemoria delle attività", + "notificationDetailLevel": "Livello di dettaglio delle notifiche", + "notificationDetailPrivate": "Privato", + "notificationDetailNormal": "Normale", + "quietHours": "Ore di silenzio", + "quietHoursDescription": "Sospendi le notifiche durante questo periodo.", + "quietHoursStart": "Inizio delle ore di silenzio", + "quietHoursEnd": "Fine delle ore di silenzio", + "notifications": "Notifiche", + "appearance": "Aspetto", + "theme": "Tema", + "themeSystem": "Sistema", + "themeLight": "Chiaro", + "themeDark": "Scuro", + "themeFamily": "Famiglia di temi", + "themeFamilyYaru": "Tema nativo di Ubuntu (Yaru)", + "localization": "Lingua e area geografica", + "currentLocale": "Impostazioni locali correnti", + "privacy": "Privacy", + "redactTaskContentInDiagnostics": "Nascondi il contenuto delle attività nelle informazioni diagnostiche", + "developerDiagnostics": "Diagnostica per sviluppatori", + "diagnostics": "Diagnostica", + "apiInspectorDisabled": "Mostra l’ispettore API", + "googleTasksApi": "API Google Tasks", + "discoveryRevision": "Revisione Discovery: {revision}", + "implementedMethods": "Metodi implementati", + "supportsTasksScopes": "Supporta gli ambiti tasks e tasks.readonly", + "requiresTasksScope": "Richiede l’ambito tasks", + "blockedPendingOperations": "Operazioni in sospeso bloccate", + "signInToInspectPendingOperations": "Accedi per esaminare le operazioni in sospeso.", + "noBlockedPendingOperations": "Nessuna operazione in sospeso bloccata.", + "operationActions": "Azioni dell’operazione", + "pendingOpListId": "elenco={id}", + "pendingOpTaskId": "attività={id}", + "pendingOpAttempts": "tentativi={count}", + "retry": "Riprova", + "discard": "Scarta", + "discardChangesAction": "Scarta", + "discardChanges": "Scartare le modifiche?", + "discardChangesConfirmation": "Questa azione scarta le modifiche non salvate dell’attività.", + "retryCompleted": "Nuovo tentativo completato.", + "discardPendingOperation": "Scartare l’operazione in sospeso?", + "discardPendingOperationConfirmation": "Questa azione rimuove l’operazione locale bloccata. Alla prossima sincronizzazione, i dati verranno ricaricati da Google Tasks.", + "pendingOperationDiscarded": "Operazione in sospeso scartata.", + "syncFailureNotificationTitle": "Sincronizzazione di BusyMax non riuscita", + "syncFailureNotificationBody": "Sincronizzazione in background non riuscita. {message}", + "conflictNotificationTitle": "Conflitto di sincronizzazione di BusyMax", + "conflictNotificationBody": "Una modifica locale in sospeso è stata bloccata. {summary}", + "dueTodayNotificationTitle": "Attività in scadenza oggi", + "dueTodayNotificationBody": "{count, plural, =1{Un’attività scade oggi.} other{{count} attività scadono oggi.}}", + "eventReminderNotificationTitle": "Promemoria evento", + "taskReminderNotificationTitle": "Promemoria attività", + "eventReminderNotificationBody": "L’evento inizierà a breve.", + "taskReminderNotificationBody": "L’attività scadrà a breve.", + "notificationOpenAction": "Apri", + "notificationDetailsHidden": "I dettagli sono nascosti dalle impostazioni sulla privacy.", + "previousMonth": "Mese precedente", + "nextMonth": "Mese successivo", + "openMonthView": "Apri vista mensile", + "previousYear": "Anno precedente", + "nextYear": "Anno successivo", + "openYearView": "Apri vista annuale", + "weekNumberTooltip": "Settimana {number}", + "resizeAllDayPanel": "Ridimensiona il pannello per l’intera giornata", + "scheduleItemCount": "{count, plural, =1{1 elemento} other{{count} elementi}}", + "readOnlyCalendar": "Questo calendario è di sola lettura.", + "selectTimeZone": "Seleziona fuso orario", + "searchLocations": "Cerca luoghi", + "noLocationsFound": "Nessun luogo trovato", + "deleteCalendarConfirmation": "Eliminare «{title}»?" +} diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb new file mode 100644 index 0000000..06b002e --- /dev/null +++ b/lib/l10n/app_ja.arb @@ -0,0 +1,374 @@ +{ + "@@locale": "ja", + "appTitle": "BusyMax", + "connectGoogleAccount": "Google と Microsoft のアカウントを接続して、カレンダーとタスクを同期します。", + "googlePermissionsConsentNotice": "Google の権限画面で、カレンダーとタスクの両方の権限を選択してください。", + "googlePermissionsRequiredRetry": "Google カレンダーと Google Tasks の権限が必要です。もう一度試して、両方のチェックボックスを選択してください。", + "finishSetup": "セットアップを完了", + "continueSetup": "続行", + "onboardingSetupTitle": "BusyMax をセットアップ", + "onboardingAccountsStepTitle": "アカウントを接続", + "onboardingAccountsStepDescription": "使用するすべての Google アカウントと Microsoft アカウントを追加してください。BusyMax は各アカウントのカレンダー、予定、タスクリスト、タスクを同期します。", + "onboardingPreferencesStepTitle": "システム設定を選択", + "onboardingPreferencesStepDescription": "スケジュールを開く前に、デスクトップでの動作、リマインダー、通知の詳細度、外観を設定します。", + "signInWithGoogle": "Google でサインイン", + "signInWithMicrosoft": "Microsoft でサインイン", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "このプロバイダーは設定されていません。", + "waitingForGoogleSignIn": "Google のサインインを待機しています...", + "waitingForMicrosoftSignIn": "Microsoft のサインインを待機しています...", + "microsoftSignInNotConfigured": "Microsoft のサインインが設定されていません。MICROSOFT_OAUTH_CLIENT_ID を設定してください。", + "cancel": "キャンセル", + "close": "閉じる", + "exit": "終了", + "options": "オプション", + "hide": "非表示", + "show": "表示", + "export": "エクスポート", + "save": "保存", + "settings": "設定", + "all": "すべて", + "calendarEvents": "予定", + "calendarTasks": "タスク", + "calendar": "カレンダー", + "calendars": "カレンダー", + "newEvent": "新しい予定", + "refreshCalendar": "カレンダーを更新", + "openInProvider": "サービスで開く", + "hideFromSchedule": "スケジュールから非表示", + "showInSchedule": "スケジュールに表示", + "noCalendarsSynced": "同期済みのカレンダーはまだありません。", + "allDay": "終日", + "moreItems": "他 {count} 件", + "noEventsOrTasks": "予定またはタスクはありません", + "scheduleLoading": "スケジュールを読み込んでいます...", + "scheduleUnavailable": "スケジュールを利用できません", + "scheduleNoSources": "表示できるカレンダーまたはタスクリストがありません", + "scheduleNoSourcesDescription": "設定で表示する項目を選択してから、更新してください。", + "scheduleSignInRequired": "アカウントを接続", + "scheduleSignInDescription": "カレンダーとタスクを同期するにはサインインしてください。", + "scheduleNoSearchResults": "一致する予定またはタスクはありません", + "scheduleNoSearchResultsDescription": "別の条件で検索するか、現在のフィルターを解除してください。", + "refresh": "更新", + "trayOpenBusyMax": "BusyMax を開く", + "agendaLoadMoreOverdue": "期限切れのタスクをさらに読み込む", + "agendaLoadMoreNoDate": "日付のないタスクをさらに読み込む", + "viewDay": "日", + "viewWeek": "週", + "viewMonth": "月", + "viewYear": "年", + "viewAgenda": "予定一覧", + "scheduleSettings": "スケジュール", + "scheduleDisplaySettings": "スケジュール表示", + "scheduleDisplayHoursDescription": "日表示と週表示では、最初にこの時間範囲が表示されます。必要に応じて、この範囲より前または後の項目も表示されるように範囲が広がります。", + "scheduleDayStartsAt": "一日の開始時刻", + "scheduleDayEndsAt": "一日の終了時刻", + "sourceCalendar": "カレンダー", + "sourceTaskList": "タスクリスト", + "createChoiceTitle": "作成", + "createEventAtTime": "予定", + "createTaskAtDate": "タスク", + "editEvent": "予定を編集", + "eventTitle": "予定のタイトル", + "location": "場所", + "timeSlot": "時間帯", + "startDateTime": "開始日時", + "endDateTime": "終了日時", + "doesNotRepeat": "繰り返さない", + "defaultReminder": "デフォルトのリマインダー", + "guests": "ゲスト", + "noGuests": "ゲストなし", + "description": "説明", + "availabilityShowAs": "空き時間情報 / 表示方法", + "busy": "予定あり", + "visibility": "公開設定", + "defaultVisibility": "デフォルトの公開設定", + "conference": "会議", + "noConference": "会議なし", + "providerCalendar": "プロバイダーのカレンダー", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "太字", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "斜体", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "下線", + "reminderMinutesBefore": "{minutes, plural, =1{1分前} other{{minutes}分前}}", + "reminderAtStart": "開始時", + "reminderHoursBefore": "{hours, plural, =1{1時間前} other{{hours}時間前}}", + "reminderDaysBefore": "{days, plural, =1{1日前} other{{days}日前}}", + "availabilityFree": "空き時間", + "availabilityTentative": "仮の予定", + "availabilityOutOfOffice": "外出中", + "availabilityWorkingElsewhere": "別の場所で勤務", + "visibilityDefault": "デフォルト", + "visibilityPublic": "一般公開", + "visibilityPrivate": "非公開", + "visibilityConfidential": "機密", + "sensitivityNormal": "標準", + "sensitivityPersonal": "個人用", + "tasks": "タスク", + "allTasks": "すべてのタスク", + "tasksInList": "{title} のタスク", + "taskLists": "タスクリスト", + "navigation": "ナビゲーション", + "mainMenu": "メインメニュー", + "keyboardShortcuts": "キーボードショートカット", + "shortcutGroupGeneral": "全般", + "shortcutKeyboardShortcutsDescription": "このショートカット一覧を表示", + "shortcutGroupNavigation": "ナビゲーション", + "shortcutNextPeriod": "次の期間", + "shortcutNextPeriodDescription": "週表示では次の週、月表示では次の月というように移動します", + "shortcutPreviousPeriod": "前の期間", + "shortcutPreviousPeriodDescription": "週表示では前の週、月表示では前の月というように移動します", + "shortcutJumpToToday": "今日に移動", + "shortcutGroupView": "表示", + "shortcutDayView": "日表示", + "shortcutWeekView": "週表示", + "shortcutMonthView": "月表示", + "shortcutYearView": "年表示", + "shortcutAgendaView": "予定一覧表示", + "shortcutGroupCreateAndEdit": "作成と編集", + "shortcutSaveItem": "予定またはタスクを保存", + "shortcutDeleteItem": "予定またはタスクを削除", + "shortcutGroupTaskEditing": "タスクの編集", + "shortcutCancelEditing": "編集をキャンセル", + "shortcutCancelEditingDescription": "タスクの編集または詳細を閉じる", + "aboutBusyMax": "BusyMax について", + "aboutBusyMaxDescription": "カレンダーとタスク", + "license": "ライセンス", + "apacheLicenseName": "Apache License 2.0", + "website": "ウェブサイト", + "sourceCode": "ソースコード", + "reportAnIssue": "問題を報告", + "sendFeedback": "フィードバックを送信", + "feedbackSubmit": "送信", + "feedbackCategory": "カテゴリー", + "feedbackSelectCategory": "カテゴリーを選択", + "feedbackCategoryProblem": "問題またはバグ", + "feedbackCategoryFeature": "機能のリクエスト", + "feedbackCategoryPrivacySecurity": "プライバシーまたはセキュリティに関する懸念", + "feedbackCategoryUsability": "使いやすさに関する懸念", + "feedbackCategoryOther": "その他", + "feedbackSubject": "件名", + "feedbackDetailedMessage": "詳しい内容", + "feedbackReplyEmail": "返信先メールアドレス(任意)", + "feedbackIncludeTechnicalDetails": "技術情報を含める", + "feedbackTechnicalDetailsDisclosure": "Linux オペレーティングシステムのバージョンとアプリのロケールのみが追加されます。ログ、アカウントデータ、ファイル名、その他の診断情報は含まれません。", + "feedbackCategoryRequired": "カテゴリーを選択してください。", + "feedbackSubjectLengthError": "件名は3文字以上120文字以下にしてください。", + "feedbackMessageLengthError": "メッセージは10文字以上5,000文字以下にしてください。", + "feedbackInvalidEmail": "有効なメールアドレスを入力してください。", + "feedbackConnectionError": "BusyStack に接続できませんでした。接続を確認して、もう一度お試しください。", + "feedbackTimeoutError": "リクエストがタイムアウトしました。フィードバックは消去されていません。もう一度お試しください。", + "feedbackRateLimitedError": "このネットワークから送信されたフィードバックが多すぎます。しばらく待ってから、もう一度お試しください。", + "feedbackRejectedError": "サーバーが送信を拒否しました。入力内容を確認して、もう一度お試しください。", + "feedbackServerError": "現在、BusyStack はフィードバックを受け付けられません。フィードバックは消去されていません。もう一度お試しください。", + "feedbackSuccess": "フィードバックを送信しました。参照番号: {id}", + "toggleSidebar": "サイドバーの表示を切り替え", + "showSidebar": "サイドバーパネルを表示", + "hideSidebar": "サイドバーパネルを非表示", + "accounts": "アカウント", + "currentAccount": "現在のアカウント", + "switchAccount": "アカウントを切り替え", + "addGoogleAccount": "Google アカウントを追加", + "addMicrosoftAccount": "Microsoft アカウントを追加", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "サインイン済み", + "removeAccount": "アカウントを削除…", + "removingAccount": "アカウントを削除しています…", + "removeAccountDescription": "同期を停止し、このアカウントのデータをこのデバイスから削除します。", + "removeAccountTitle": "BusyMax から {account} を削除しますか?", + "removeAccountConfirmation": "このデバイスにキャッシュされたタスク、カレンダー、予定、リマインダー、保留中のオフライン変更が削除されます。同期されていない変更は失われます。Google または Microsoft から削除されるデータはありません。", + "revokeGoogleAccess": "この Google アカウントへの BusyMax のアクセス権も取り消す", + "revokeGoogleAccessDescription": "再接続する前に、もう一度アクセスを許可する必要があります。", + "removeAccountAction": "アカウントを削除", + "removeAccountFailed": "アカウントの削除を完了できませんでした。もう一度お試しください。", + "accountRemovedGoogleRevokeFailed": "アカウントはこのデバイスから削除されましたが、BusyMax は Google アカウントへのアクセス権を取り消せませんでした。Google アカウントの設定から取り消すことができます。", + "newList": "新しいリスト", + "signInToViewTaskLists": "タスクリストを表示するにはサインインしてください。", + "noTaskListsSynced": "同期済みのタスクリストはまだありません。", + "listActions": "リストの操作", + "rename": "名前を変更", + "delete": "削除", + "renameList": "リスト名を変更", + "deleteList": "リストを削除", + "builtInMicrosoftList": "組み込み", + "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do の組み込みリストは、名前の変更や削除ができません。", + "deleteListConfirmation": "Google Tasks から「{title}」を削除しますか?", + "deleteEvent": "予定を削除", + "title": "タイトル", + "create": "作成", + "newTask": "新しいタスク", + "clearCompleted": "完了済みを消去", + "refreshList": "リストを更新", + "refreshAll": "すべて更新", + "listRefreshed": "リストを更新しました。", + "allTasksRefreshed": "すべてのアカウントを更新しました。", + "exportedFile": "{path} にエクスポートしました", + "exportFailed": "エクスポートに失敗しました: {error}", + "refreshFailed": "更新に失敗しました: {error}", + "selectOrCreateTaskList": "開始するには、タスクリストを選択または作成してください。", + "signInToViewTasks": "タスクを表示するにはサインインしてください。", + "noTasks": "タスクはありません。", + "noTasksYet": "タスクはまだありません", + "noTasksYetMessage": "タスクを作成するか、アカウントを更新して始めましょう。", + "noTasksInList": "このリストにタスクはありません。", + "overdue": "期限超過", + "today": "今日", + "tomorrow": "明日", + "upcoming": "今後", + "noDate": "日付なし", + "completed": "完了", + "duePrefix": "期限: {date}", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "タスクの詳細", + "editTask": "タスクを編集", + "noTaskSelected": "タスクが選択されていません。", + "noTaskSelectedHelper": "詳細を表示して編集するタスクを選択してください。", + "taskUnavailable": "タスクを利用できません。", + "signInToEditTasks": "タスクを編集するにはサインインしてください。", + "refreshTask": "タスクを更新", + "primarySection": "基本情報", + "statusSection": "ステータス", + "openStatus": "未完了", + "doneStatus": "完了", + "notes": "メモ", + "dueDate": "期限日", + "clearDueDate": "期限日を消去", + "dueTime": "期限時刻", + "startDate": "開始日", + "startTime": "開始時刻", + "endDate": "終了日", + "endTime": "終了時刻", + "reminderDate": "リマインダーの日付", + "reminderTime": "リマインダーの時刻", + "reminder": "リマインダー", + "addReminder": "リマインダーを追加", + "addGuest": "ゲストを追加", + "addGuestEmail": "ゲストのメールアドレスを追加", + "removeReminder": "リマインダーを削除", + "off": "オフ", + "repeat": "繰り返し", + "repeatNone": "なし", + "noneValue": "なし", + "repeatDaily": "毎日", + "repeatWeekly": "毎週", + "repeatMonthly": "毎月", + "repeatYearly": "毎年", + "importance": "重要度", + "importanceLow": "低", + "importanceNormal": "標準", + "importanceHigh": "高", + "categories": "カテゴリー", + "scheduleSection": "スケジュール", + "dueGroup": "期限", + "startGroup": "開始", + "reminderGroup": "リマインダー", + "organizationSection": "整理", + "actionsSection": "操作", + "advancedSection": "詳細設定", + "addCategory": "カテゴリーを追加", + "list": "リスト", + "microsoftMoveUnsupported": "このバージョンでは、Microsoft To Do アカウントのリスト間でタスクを移動できません。", + "createSubtask": "サブタスクを作成", + "moveToTop": "一番上に移動", + "deleteTask": "タスクを削除", + "newSubtask": "新しいサブタスク", + "deleteTaskConfirmation": "Google Tasks から「{title}」を削除しますか?", + "metadata": "メタデータ", + "id": "ID", + "etag": "ETag", + "updated": "更新日時", + "parent": "親タスク", + "position": "位置", + "webLink": "ウェブリンク", + "assignment": "割り当て", + "localState": "ローカル状態", + "pendingSync": "同期待ち", + "synced": "同期済み", + "account": "アカウント", + "sync": "同期", + "manualFullSync": "手動ですべて同期", + "runInBackgroundWhenClosed": "ウィンドウを閉じてもバックグラウンドで実行を続ける", + "showTrayIcon": "トレイアイコンを表示", + "startMinimizedToTray": "トレイに最小化して起動", + "requiresTrayIcon": "トレイアイコンが必要です。", + "syncComplete": "同期が完了しました。", + "syncFailed": "同期に失敗しました: {error}", + "notifySyncFailures": "同期失敗時に通知", + "notifyConflicts": "競合時に通知", + "notifyDueToday": "今日が期限のタスクを通知", + "eventReminders": "予定のリマインダー", + "taskReminders": "タスクのリマインダー", + "notificationDetailLevel": "通知の詳細度", + "notificationDetailPrivate": "非公開", + "notificationDetailNormal": "標準", + "quietHours": "通知を停止する時間", + "quietHoursDescription": "この時間帯は通知を一時停止します。", + "quietHoursStart": "通知停止の開始時刻", + "quietHoursEnd": "通知停止の終了時刻", + "notifications": "通知", + "appearance": "外観", + "theme": "テーマ", + "themeSystem": "システム", + "themeLight": "ライト", + "themeDark": "ダーク", + "themeFamily": "テーマファミリー", + "themeFamilyYaru": "Ubuntu ネイティブ(Yaru)", + "localization": "言語と地域", + "currentLocale": "現在のロケール", + "privacy": "プライバシー", + "redactTaskContentInDiagnostics": "診断情報でタスクの内容を伏せる", + "developerDiagnostics": "開発者向け診断", + "diagnostics": "診断", + "apiInspectorDisabled": "API インスペクターを表示", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "Discovery リビジョン: {revision}", + "implementedMethods": "実装済みメソッド", + "supportsTasksScopes": "tasks および tasks.readonly スコープをサポート", + "requiresTasksScope": "tasks スコープが必要", + "blockedPendingOperations": "ブロックされた保留中の操作", + "signInToInspectPendingOperations": "保留中の操作を確認するにはサインインしてください。", + "noBlockedPendingOperations": "ブロックされた保留中の操作はありません。", + "operationActions": "操作への対応", + "pendingOpListId": "リスト={id}", + "pendingOpTaskId": "タスク={id}", + "pendingOpAttempts": "試行回数={count}", + "retry": "再試行", + "discard": "破棄", + "discardChangesAction": "破棄", + "discardChanges": "変更を破棄しますか?", + "discardChangesConfirmation": "このタスクの未保存の編集内容を破棄します。", + "retryCompleted": "再試行が完了しました。", + "discardPendingOperation": "保留中の操作を破棄しますか?", + "discardPendingOperationConfirmation": "ブロックされたローカル操作を削除します。次回の同期時に Google Tasks からデータが再取得されます。", + "pendingOperationDiscarded": "保留中の操作を破棄しました。", + "syncFailureNotificationTitle": "BusyMax の同期に失敗", + "syncFailureNotificationBody": "バックグラウンド同期に失敗しました。{message}", + "conflictNotificationTitle": "BusyMax の同期競合", + "conflictNotificationBody": "保留中のローカル変更がブロックされました。{summary}", + "dueTodayNotificationTitle": "今日が期限のタスク", + "dueTodayNotificationBody": "{count, plural, =1{今日が期限のタスクが1件あります。} other{今日が期限のタスクが{count}件あります。}}", + "eventReminderNotificationTitle": "予定のリマインダー", + "taskReminderNotificationTitle": "タスクのリマインダー", + "eventReminderNotificationBody": "予定がまもなく始まります。", + "taskReminderNotificationBody": "タスクの期限が近づいています。", + "notificationOpenAction": "開く", + "notificationDetailsHidden": "プライバシー設定により詳細は非表示です。", + "previousMonth": "前の月", + "nextMonth": "次の月", + "openMonthView": "月表示を開く", + "previousYear": "前の年", + "nextYear": "次の年", + "openYearView": "年表示を開く", + "weekNumberTooltip": "第{number}週", + "resizeAllDayPanel": "終日パネルのサイズを変更", + "scheduleItemCount": "{count, plural, =1{1件} other{{count}件}}", + "readOnlyCalendar": "このカレンダーは読み取り専用です。", + "selectTimeZone": "タイムゾーンを選択", + "searchLocations": "場所を検索", + "noLocationsFound": "場所が見つかりません", + "deleteCalendarConfirmation": "「{title}」を削除しますか?" +} diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb new file mode 100644 index 0000000..183daaf --- /dev/null +++ b/lib/l10n/app_ko.arb @@ -0,0 +1,374 @@ +{ + "@@locale": "ko", + "appTitle": "BusyMax", + "connectGoogleAccount": "Google 및 Microsoft 계정을 연결하여 캘린더와 할 일을 동기화하세요.", + "googlePermissionsConsentNotice": "Google 권한 화면에서 캘린더와 할 일 권한을 모두 선택하세요.", + "googlePermissionsRequiredRetry": "Google Calendar 및 Google Tasks 권한이 필요합니다. 다시 시도하여 두 체크박스를 모두 선택하세요.", + "finishSetup": "설정 완료", + "continueSetup": "계속", + "onboardingSetupTitle": "BusyMax 설정", + "onboardingAccountsStepTitle": "계정 연결", + "onboardingAccountsStepDescription": "사용할 Google 및 Microsoft 계정을 모두 추가하세요. BusyMax는 각 계정의 캘린더, 일정, 할 일 목록 및 할 일을 동기화합니다.", + "onboardingPreferencesStepTitle": "시스템 설정 선택", + "onboardingPreferencesStepDescription": "일정을 열기 전에 데스크톱 동작, 미리 알림, 알림 세부 수준 및 화면 모양을 설정하세요.", + "signInWithGoogle": "Google로 로그인", + "signInWithMicrosoft": "Microsoft로 로그인", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "이 서비스는 설정되어 있지 않습니다.", + "waitingForGoogleSignIn": "Google 로그인을 기다리는 중...", + "waitingForMicrosoftSignIn": "Microsoft 로그인을 기다리는 중...", + "microsoftSignInNotConfigured": "Microsoft 로그인이 구성되지 않았습니다. MICROSOFT_OAUTH_CLIENT_ID를 설정하세요.", + "cancel": "취소", + "close": "닫기", + "exit": "종료", + "options": "옵션", + "hide": "숨기기", + "show": "표시", + "export": "내보내기", + "save": "저장", + "settings": "설정", + "all": "모두", + "calendarEvents": "일정", + "calendarTasks": "할 일", + "calendar": "캘린더", + "calendars": "캘린더", + "newEvent": "새 일정", + "refreshCalendar": "캘린더 새로 고침", + "openInProvider": "서비스에서 열기", + "hideFromSchedule": "일정에서 숨기기", + "showInSchedule": "일정에 표시", + "noCalendarsSynced": "아직 동기화된 캘린더가 없습니다.", + "allDay": "하루 종일", + "moreItems": "+{count}개 더 보기", + "noEventsOrTasks": "일정 또는 할 일이 없습니다", + "scheduleLoading": "일정을 불러오는 중...", + "scheduleUnavailable": "일정을 사용할 수 없습니다", + "scheduleNoSources": "표시할 캘린더 또는 할 일 목록이 없습니다", + "scheduleNoSourcesDescription": "설정에서 표시할 항목을 선택한 다음 새로 고침하세요.", + "scheduleSignInRequired": "계정 연결", + "scheduleSignInDescription": "캘린더와 할 일을 동기화하려면 로그인하세요.", + "scheduleNoSearchResults": "일치하는 일정 또는 할 일이 없습니다", + "scheduleNoSearchResultsDescription": "다른 검색어를 사용하거나 현재 필터를 지우세요.", + "refresh": "새로 고침", + "trayOpenBusyMax": "BusyMax 열기", + "agendaLoadMoreOverdue": "기한이 지난 할 일 더 불러오기", + "agendaLoadMoreNoDate": "날짜 없는 할 일 더 불러오기", + "viewDay": "일", + "viewWeek": "주", + "viewMonth": "월", + "viewYear": "년", + "viewAgenda": "일정 목록", + "scheduleSettings": "일정", + "scheduleDisplaySettings": "일정 표시", + "scheduleDisplayHoursDescription": "일간 및 주간 보기는 처음에 이 시간 범위를 표시합니다. 필요한 경우 더 이르거나 늦은 항목에 맞춰 범위가 확장됩니다.", + "scheduleDayStartsAt": "하루 시작 시간", + "scheduleDayEndsAt": "하루 종료 시간", + "sourceCalendar": "캘린더", + "sourceTaskList": "할 일 목록", + "createChoiceTitle": "만들기", + "createEventAtTime": "일정", + "createTaskAtDate": "할 일", + "editEvent": "일정 편집", + "eventTitle": "일정 제목", + "location": "위치", + "timeSlot": "시간대", + "startDateTime": "시작 날짜/시간", + "endDateTime": "종료 날짜/시간", + "doesNotRepeat": "반복 안 함", + "defaultReminder": "기본 미리 알림", + "guests": "참석자", + "noGuests": "참석자 없음", + "description": "설명", + "availabilityShowAs": "일정 상태 / 표시 방식", + "busy": "바쁨", + "visibility": "공개 범위", + "defaultVisibility": "기본 공개 범위", + "conference": "회의", + "noConference": "회의 없음", + "providerCalendar": "서비스 캘린더", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "굵게", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "기울임꼴", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "밑줄", + "reminderMinutesBefore": "{minutes, plural, =1{1분 전} other{{minutes}분 전}}", + "reminderAtStart": "시작 시", + "reminderHoursBefore": "{hours, plural, =1{1시간 전} other{{hours}시간 전}}", + "reminderDaysBefore": "{days, plural, =1{1일 전} other{{days}일 전}}", + "availabilityFree": "한가함", + "availabilityTentative": "미정", + "availabilityOutOfOffice": "부재중", + "availabilityWorkingElsewhere": "다른 장소에서 근무", + "visibilityDefault": "기본값", + "visibilityPublic": "공개", + "visibilityPrivate": "비공개", + "visibilityConfidential": "기밀", + "sensitivityNormal": "일반", + "sensitivityPersonal": "개인", + "tasks": "할 일", + "allTasks": "모든 할 일", + "tasksInList": "{title}의 할 일", + "taskLists": "할 일 목록", + "navigation": "탐색", + "mainMenu": "주 메뉴", + "keyboardShortcuts": "키보드 단축키", + "shortcutGroupGeneral": "일반", + "shortcutKeyboardShortcutsDescription": "이 단축키 도움말 표시", + "shortcutGroupNavigation": "탐색", + "shortcutNextPeriod": "다음 기간", + "shortcutNextPeriodDescription": "주간 보기에서는 다음 주, 월간 보기에서는 다음 달로 이동하는 식입니다", + "shortcutPreviousPeriod": "이전 기간", + "shortcutPreviousPeriodDescription": "주간 보기에서는 이전 주, 월간 보기에서는 이전 달로 이동하는 식입니다", + "shortcutJumpToToday": "오늘로 이동", + "shortcutGroupView": "보기", + "shortcutDayView": "일간 보기", + "shortcutWeekView": "주간 보기", + "shortcutMonthView": "월간 보기", + "shortcutYearView": "연간 보기", + "shortcutAgendaView": "일정 목록 보기", + "shortcutGroupCreateAndEdit": "만들기 및 편집", + "shortcutSaveItem": "일정 또는 할 일 저장", + "shortcutDeleteItem": "일정 또는 할 일 삭제", + "shortcutGroupTaskEditing": "할 일 편집", + "shortcutCancelEditing": "편집 취소", + "shortcutCancelEditingDescription": "할 일 편집 또는 할 일 세부 정보 닫기", + "aboutBusyMax": "BusyMax 정보", + "aboutBusyMaxDescription": "캘린더와 할 일", + "license": "라이선스", + "apacheLicenseName": "Apache License 2.0", + "website": "웹사이트", + "sourceCode": "소스 코드", + "reportAnIssue": "문제 신고", + "sendFeedback": "의견 보내기", + "feedbackSubmit": "제출", + "feedbackCategory": "범주", + "feedbackSelectCategory": "범주 선택", + "feedbackCategoryProblem": "문제 또는 버그", + "feedbackCategoryFeature": "기능 요청", + "feedbackCategoryPrivacySecurity": "개인정보 보호 또는 보안 우려", + "feedbackCategoryUsability": "사용 편의성 문제", + "feedbackCategoryOther": "기타", + "feedbackSubject": "제목", + "feedbackDetailedMessage": "자세한 내용", + "feedbackReplyEmail": "답변 받을 이메일 주소(선택 사항)", + "feedbackIncludeTechnicalDetails": "기술 세부 정보 포함", + "feedbackTechnicalDetailsDisclosure": "Linux 운영 체제 버전과 앱 로캘만 추가됩니다. 로그, 계정 데이터, 파일 이름 또는 기타 진단 정보는 포함되지 않습니다.", + "feedbackCategoryRequired": "범주를 선택하세요.", + "feedbackSubjectLengthError": "제목은 3~120자여야 합니다.", + "feedbackMessageLengthError": "메시지는 10~5,000자여야 합니다.", + "feedbackInvalidEmail": "올바른 이메일 주소를 입력하세요.", + "feedbackConnectionError": "BusyStack에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요.", + "feedbackTimeoutError": "요청 시간이 초과되었습니다. 의견은 지워지지 않았습니다. 다시 시도하세요.", + "feedbackRateLimitedError": "이 네트워크에서 너무 많은 의견이 제출되었습니다. 잠시 기다린 후 다시 시도하세요.", + "feedbackRejectedError": "서버가 제출을 거부했습니다. 입력란을 검토하고 다시 시도하세요.", + "feedbackServerError": "현재 BusyStack에서 의견을 받을 수 없습니다. 의견은 지워지지 않았습니다. 다시 시도하세요.", + "feedbackSuccess": "의견을 보냈습니다. 참조: {id}", + "toggleSidebar": "사이드바 표시 전환", + "showSidebar": "사이드바 패널 표시", + "hideSidebar": "사이드바 패널 숨기기", + "accounts": "계정", + "currentAccount": "현재 계정", + "switchAccount": "계정 전환", + "addGoogleAccount": "Google 계정 추가", + "addMicrosoftAccount": "Microsoft 계정 추가", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "로그인됨", + "removeAccount": "계정 삭제…", + "removingAccount": "계정 삭제 중…", + "removeAccountDescription": "동기화를 중지하고 이 기기에서 이 계정의 데이터를 삭제합니다.", + "removeAccountTitle": "BusyMax에서 {account} 계정을 삭제할까요?", + "removeAccountConfirmation": "이 기기에서 캐시된 할 일, 캘린더, 일정, 미리 알림 및 보류 중인 오프라인 변경 사항이 삭제됩니다. 동기화되지 않은 변경 사항은 사라집니다. Google 또는 Microsoft에서는 아무것도 삭제되지 않습니다.", + "revokeGoogleAccess": "이 Google 계정에 대한 BusyMax의 액세스 권한도 취소", + "revokeGoogleAccessDescription": "다시 연결하기 전에 액세스 권한을 다시 부여해야 합니다.", + "removeAccountAction": "계정 삭제", + "removeAccountFailed": "계정 삭제를 완료할 수 없습니다. 다시 시도하세요.", + "accountRemovedGoogleRevokeFailed": "계정은 이 기기에서 삭제되었지만 BusyMax가 Google 계정 액세스 권한을 취소하지 못했습니다. Google 계정에서 직접 취소할 수 있습니다.", + "newList": "새 목록", + "signInToViewTaskLists": "할 일 목록을 보려면 로그인하세요.", + "noTaskListsSynced": "아직 동기화된 할 일 목록이 없습니다.", + "listActions": "목록 작업", + "rename": "이름 바꾸기", + "delete": "삭제", + "renameList": "목록 이름 바꾸기", + "deleteList": "목록 삭제", + "builtInMicrosoftList": "기본 제공", + "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do의 기본 제공 목록은 이름을 바꾸거나 삭제할 수 없습니다.", + "deleteListConfirmation": "Google Tasks에서 “{title}” 목록을 삭제할까요?", + "deleteEvent": "일정 삭제", + "title": "제목", + "create": "만들기", + "newTask": "새 할 일", + "clearCompleted": "완료된 항목 지우기", + "refreshList": "목록 새로 고침", + "refreshAll": "모두 새로 고침", + "listRefreshed": "목록을 새로 고쳤습니다.", + "allTasksRefreshed": "모든 계정을 새로 고쳤습니다.", + "exportedFile": "{path}에 내보냈습니다", + "exportFailed": "내보내기 실패: {error}", + "refreshFailed": "새로 고침 실패: {error}", + "selectOrCreateTaskList": "시작하려면 할 일 목록을 선택하거나 만드세요.", + "signInToViewTasks": "할 일을 보려면 로그인하세요.", + "noTasks": "할 일이 없습니다.", + "noTasksYet": "아직 할 일이 없습니다", + "noTasksYetMessage": "할 일을 만들거나 계정을 새로 고쳐 시작하세요.", + "noTasksInList": "이 목록에 할 일이 없습니다.", + "overdue": "기한 지남", + "today": "오늘", + "tomorrow": "내일", + "upcoming": "예정", + "noDate": "날짜 없음", + "completed": "완료", + "duePrefix": "{date} 마감", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "할 일 세부 정보", + "editTask": "할 일 편집", + "noTaskSelected": "선택된 할 일이 없습니다.", + "noTaskSelectedHelper": "세부 정보를 보고 편집할 할 일을 선택하세요.", + "taskUnavailable": "할 일을 사용할 수 없습니다.", + "signInToEditTasks": "할 일을 편집하려면 로그인하세요.", + "refreshTask": "할 일 새로 고침", + "primarySection": "기본", + "statusSection": "상태", + "openStatus": "미완료", + "doneStatus": "완료", + "notes": "메모", + "dueDate": "마감일", + "clearDueDate": "마감일 지우기", + "dueTime": "마감 시간", + "startDate": "시작일", + "startTime": "시작 시간", + "endDate": "종료일", + "endTime": "종료 시간", + "reminderDate": "미리 알림 날짜", + "reminderTime": "미리 알림 시간", + "reminder": "미리 알림", + "addReminder": "미리 알림 추가", + "addGuest": "참석자 추가", + "addGuestEmail": "참석자 이메일 추가", + "removeReminder": "미리 알림 삭제", + "off": "끔", + "repeat": "반복", + "repeatNone": "없음", + "noneValue": "없음", + "repeatDaily": "매일", + "repeatWeekly": "매주", + "repeatMonthly": "매월", + "repeatYearly": "매년", + "importance": "중요도", + "importanceLow": "낮음", + "importanceNormal": "보통", + "importanceHigh": "높음", + "categories": "범주", + "scheduleSection": "일정", + "dueGroup": "마감", + "startGroup": "시작", + "reminderGroup": "미리 알림", + "organizationSection": "구성", + "actionsSection": "작업", + "advancedSection": "고급", + "addCategory": "범주 추가", + "list": "목록", + "microsoftMoveUnsupported": "이 버전에서는 Microsoft To Do 계정의 목록 간에 할 일을 이동할 수 없습니다.", + "createSubtask": "하위 할 일 만들기", + "moveToTop": "맨 위로 이동", + "deleteTask": "할 일 삭제", + "newSubtask": "새 하위 할 일", + "deleteTaskConfirmation": "Google Tasks에서 “{title}” 항목을 삭제할까요?", + "metadata": "메타데이터", + "id": "ID", + "etag": "ETag", + "updated": "업데이트됨", + "parent": "상위 할 일", + "position": "위치", + "webLink": "웹 링크", + "assignment": "할당", + "localState": "로컬 상태", + "pendingSync": "동기화 보류 중", + "synced": "동기화됨", + "account": "계정", + "sync": "동기화", + "manualFullSync": "수동 전체 동기화", + "runInBackgroundWhenClosed": "창을 닫아도 계속 실행", + "showTrayIcon": "트레이 아이콘 표시", + "startMinimizedToTray": "트레이에 최소화하여 시작", + "requiresTrayIcon": "트레이 아이콘이 필요합니다.", + "syncComplete": "동기화가 완료되었습니다.", + "syncFailed": "동기화 실패: {error}", + "notifySyncFailures": "동기화 실패 알림", + "notifyConflicts": "충돌 알림", + "notifyDueToday": "오늘 마감인 할 일 알림", + "eventReminders": "일정 미리 알림", + "taskReminders": "할 일 미리 알림", + "notificationDetailLevel": "알림 세부 수준", + "notificationDetailPrivate": "비공개", + "notificationDetailNormal": "일반", + "quietHours": "방해 금지 시간", + "quietHoursDescription": "이 시간 동안 알림을 일시 중지합니다.", + "quietHoursStart": "방해 금지 시작 시간", + "quietHoursEnd": "방해 금지 종료 시간", + "notifications": "알림", + "appearance": "화면 모양", + "theme": "테마", + "themeSystem": "시스템", + "themeLight": "라이트", + "themeDark": "다크", + "themeFamily": "테마 계열", + "themeFamilyYaru": "Ubuntu 기본 테마(Yaru)", + "localization": "언어 및 지역", + "currentLocale": "현재 로캘", + "privacy": "개인정보 보호", + "redactTaskContentInDiagnostics": "진단 정보에서 할 일 내용 숨기기", + "developerDiagnostics": "개발자 진단", + "diagnostics": "진단", + "apiInspectorDisabled": "API 검사기 표시", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "Discovery 리비전: {revision}", + "implementedMethods": "구현된 메서드", + "supportsTasksScopes": "tasks 및 tasks.readonly 범위 지원", + "requiresTasksScope": "tasks 범위 필요", + "blockedPendingOperations": "차단된 보류 작업", + "signInToInspectPendingOperations": "보류 작업을 확인하려면 로그인하세요.", + "noBlockedPendingOperations": "차단된 보류 작업이 없습니다.", + "operationActions": "작업별 조치", + "pendingOpListId": "목록={id}", + "pendingOpTaskId": "할 일={id}", + "pendingOpAttempts": "시도={count}", + "retry": "다시 시도", + "discard": "버리기", + "discardChangesAction": "변경 사항 버리기", + "discardChanges": "변경 사항을 버릴까요?", + "discardChangesConfirmation": "이 할 일에서 저장하지 않은 편집 내용을 버립니다.", + "retryCompleted": "다시 시도했습니다.", + "discardPendingOperation": "보류 작업을 버릴까요?", + "discardPendingOperationConfirmation": "차단된 로컬 작업을 삭제합니다. 다음 동기화에서 Google Tasks의 데이터를 새로 불러옵니다.", + "pendingOperationDiscarded": "보류 작업을 버렸습니다.", + "syncFailureNotificationTitle": "BusyMax 동기화 실패", + "syncFailureNotificationBody": "백그라운드 동기화에 실패했습니다. {message}", + "conflictNotificationTitle": "BusyMax 동기화 충돌", + "conflictNotificationBody": "보류 중인 로컬 변경 사항이 차단되었습니다. {summary}", + "dueTodayNotificationTitle": "오늘 마감인 할 일", + "dueTodayNotificationBody": "{count, plural, =1{오늘 마감인 할 일이 1개 있습니다.} other{오늘 마감인 할 일이 {count}개 있습니다.}}", + "eventReminderNotificationTitle": "일정 미리 알림", + "taskReminderNotificationTitle": "할 일 미리 알림", + "eventReminderNotificationBody": "일정이 곧 시작됩니다.", + "taskReminderNotificationBody": "할 일 마감이 얼마 남지 않았습니다.", + "notificationOpenAction": "열기", + "notificationDetailsHidden": "개인정보 보호 설정에 따라 세부 정보가 숨겨졌습니다.", + "previousMonth": "이전 달", + "nextMonth": "다음 달", + "openMonthView": "월간 보기 열기", + "previousYear": "이전 해", + "nextYear": "다음 해", + "openYearView": "연간 보기 열기", + "weekNumberTooltip": "{number}주차", + "resizeAllDayPanel": "종일 패널 크기 조절", + "scheduleItemCount": "{count, plural, =1{항목 1개} other{항목 {count}개}}", + "readOnlyCalendar": "이 캘린더는 읽기 전용입니다.", + "selectTimeZone": "시간대 선택", + "searchLocations": "위치 검색", + "noLocationsFound": "위치를 찾을 수 없습니다", + "deleteCalendarConfirmation": "“{title}” 캘린더를 삭제할까요?" +} diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb new file mode 100644 index 0000000..d330459 --- /dev/null +++ b/lib/l10n/app_pt.arb @@ -0,0 +1,374 @@ +{ + "@@locale": "pt", + "appTitle": "BusyMax", + "connectGoogleAccount": "Ligue contas Google e Microsoft para sincronizar calendários e tarefas.", + "googlePermissionsConsentNotice": "No ecrã de autorizações da Google, selecione as autorizações do Calendário e das Tarefas.", + "googlePermissionsRequiredRetry": "As autorizações do Calendário Google e do Google Tasks são necessárias. Tente novamente e selecione ambas as caixas.", + "finishSetup": "Concluir configuração", + "continueSetup": "Continuar", + "onboardingSetupTitle": "Configurar o BusyMax", + "onboardingAccountsStepTitle": "Ligar contas", + "onboardingAccountsStepDescription": "Adicione todas as contas Google e Microsoft que pretende utilizar. O BusyMax sincroniza calendários, eventos, listas de tarefas e tarefas de cada conta.", + "onboardingPreferencesStepTitle": "Escolher definições do sistema", + "onboardingPreferencesStepDescription": "Configure o comportamento da aplicação no ambiente de trabalho, os lembretes, o nível de detalhe das notificações e o aspeto antes de abrir a agenda.", + "signInWithGoogle": "Iniciar sessão com a Google", + "signInWithMicrosoft": "Iniciar sessão com a Microsoft", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "Este fornecedor não está configurado.", + "waitingForGoogleSignIn": "A aguardar o início de sessão da Google...", + "waitingForMicrosoftSignIn": "A aguardar o início de sessão da Microsoft...", + "microsoftSignInNotConfigured": "O início de sessão da Microsoft não está configurado. Defina MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "Cancelar", + "close": "Fechar", + "exit": "Sair", + "options": "Opções", + "hide": "Ocultar", + "show": "Mostrar", + "export": "Exportar", + "save": "Guardar", + "settings": "Definições", + "all": "Tudo", + "calendarEvents": "Eventos", + "calendarTasks": "Tarefas", + "calendar": "Calendário", + "calendars": "Calendários", + "newEvent": "Novo evento", + "refreshCalendar": "Atualizar calendário", + "openInProvider": "Abrir no serviço", + "hideFromSchedule": "Ocultar da agenda", + "showInSchedule": "Mostrar na agenda", + "noCalendarsSynced": "Ainda não há calendários sincronizados.", + "allDay": "Todo o dia", + "moreItems": "+{count} mais", + "noEventsOrTasks": "Sem eventos ou tarefas", + "scheduleLoading": "A carregar agenda...", + "scheduleUnavailable": "Agenda indisponível", + "scheduleNoSources": "Sem calendários ou listas de tarefas visíveis", + "scheduleNoSourcesDescription": "Escolha o que pretende mostrar nas Definições e atualize a agenda.", + "scheduleSignInRequired": "Ligar uma conta", + "scheduleSignInDescription": "Inicie sessão para sincronizar calendários e tarefas.", + "scheduleNoSearchResults": "Nenhum evento ou tarefa correspondente", + "scheduleNoSearchResultsDescription": "Experimente outra pesquisa ou limpe os filtros atuais.", + "refresh": "Atualizar", + "trayOpenBusyMax": "Abrir o BusyMax", + "agendaLoadMoreOverdue": "Carregar mais tarefas em atraso", + "agendaLoadMoreNoDate": "Carregar mais tarefas sem data", + "viewDay": "Dia", + "viewWeek": "Semana", + "viewMonth": "Mês", + "viewYear": "Ano", + "viewAgenda": "Agenda", + "scheduleSettings": "Agenda", + "scheduleDisplaySettings": "Apresentação da agenda", + "scheduleDisplayHoursDescription": "As vistas de dia e semana mostram inicialmente este intervalo horário. Os itens anteriores ou posteriores alargam-no quando necessário.", + "scheduleDayStartsAt": "O dia começa às", + "scheduleDayEndsAt": "O dia termina às", + "sourceCalendar": "Calendário", + "sourceTaskList": "Lista de tarefas", + "createChoiceTitle": "Criar", + "createEventAtTime": "Evento", + "createTaskAtDate": "Tarefa", + "editEvent": "Editar evento", + "eventTitle": "Título do evento", + "location": "Local", + "timeSlot": "Intervalo de tempo", + "startDateTime": "Data e hora de início", + "endDateTime": "Data e hora de fim", + "doesNotRepeat": "Não se repete", + "defaultReminder": "Lembrete predefinido", + "guests": "Convidados", + "noGuests": "Sem convidados", + "description": "Descrição", + "availabilityShowAs": "Disponibilidade / Mostrar como", + "busy": "Ocupado", + "visibility": "Visibilidade", + "defaultVisibility": "Visibilidade predefinida", + "conference": "Conferência", + "noConference": "Sem conferência", + "providerCalendar": "Calendário do fornecedor", + "formatBoldShortLabel": "N", + "formatBoldTooltip": "Negrito", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "Itálico", + "formatUnderlineShortLabel": "S", + "formatUnderlineTooltip": "Sublinhado", + "reminderMinutesBefore": "{minutes, plural, =1{1 minuto antes} other{{minutes} minutos antes}}", + "reminderAtStart": "À hora de início", + "reminderHoursBefore": "{hours, plural, =1{1 hora antes} other{{hours} horas antes}}", + "reminderDaysBefore": "{days, plural, =1{1 dia antes} other{{days} dias antes}}", + "availabilityFree": "Livre", + "availabilityTentative": "Provisório", + "availabilityOutOfOffice": "Fora do escritório", + "availabilityWorkingElsewhere": "A trabalhar noutro local", + "visibilityDefault": "Predefinida", + "visibilityPublic": "Pública", + "visibilityPrivate": "Privada", + "visibilityConfidential": "Confidencial", + "sensitivityNormal": "Normal", + "sensitivityPersonal": "Pessoal", + "tasks": "Tarefas", + "allTasks": "Todas as tarefas", + "tasksInList": "Tarefas em {title}", + "taskLists": "Listas de tarefas", + "navigation": "Navegação", + "mainMenu": "Menu principal", + "keyboardShortcuts": "Atalhos de teclado", + "shortcutGroupGeneral": "Geral", + "shortcutKeyboardShortcutsDescription": "Mostrar esta referência de atalhos", + "shortcutGroupNavigation": "Navegação", + "shortcutNextPeriod": "Período seguinte", + "shortcutNextPeriodDescription": "Semana seguinte na vista semanal, mês seguinte na vista mensal e assim por diante", + "shortcutPreviousPeriod": "Período anterior", + "shortcutPreviousPeriodDescription": "Semana anterior na vista semanal, mês anterior na vista mensal e assim por diante", + "shortcutJumpToToday": "Ir para hoje", + "shortcutGroupView": "Vista", + "shortcutDayView": "Vista diária", + "shortcutWeekView": "Vista semanal", + "shortcutMonthView": "Vista mensal", + "shortcutYearView": "Vista anual", + "shortcutAgendaView": "Vista de agenda", + "shortcutGroupCreateAndEdit": "Criar e editar", + "shortcutSaveItem": "Guardar evento ou tarefa", + "shortcutDeleteItem": "Eliminar evento ou tarefa", + "shortcutGroupTaskEditing": "Edição de tarefas", + "shortcutCancelEditing": "Cancelar edição", + "shortcutCancelEditingDescription": "Fechar a edição ou os detalhes da tarefa", + "aboutBusyMax": "Acerca do BusyMax", + "aboutBusyMaxDescription": "Calendário e tarefas", + "license": "Licença", + "apacheLicenseName": "Apache License 2.0", + "website": "Site", + "sourceCode": "Código-fonte", + "reportAnIssue": "Comunicar um problema", + "sendFeedback": "Enviar comentários", + "feedbackSubmit": "Enviar", + "feedbackCategory": "Categoria", + "feedbackSelectCategory": "Selecione uma categoria", + "feedbackCategoryProblem": "Problema ou erro", + "feedbackCategoryFeature": "Pedido de funcionalidade", + "feedbackCategoryPrivacySecurity": "Questão de privacidade ou segurança", + "feedbackCategoryUsability": "Problema de utilização", + "feedbackCategoryOther": "Outro", + "feedbackSubject": "Assunto", + "feedbackDetailedMessage": "Mensagem detalhada", + "feedbackReplyEmail": "Endereço de e-mail para resposta (opcional)", + "feedbackIncludeTechnicalDetails": "Incluir detalhes técnicos", + "feedbackTechnicalDetailsDisclosure": "Adiciona apenas a versão do sistema operativo Linux e a configuração regional da aplicação. Não são incluídos registos, dados de contas, nomes de ficheiros nem outros diagnósticos.", + "feedbackCategoryRequired": "Selecione uma categoria.", + "feedbackSubjectLengthError": "O assunto deve ter entre 3 e 120 carateres.", + "feedbackMessageLengthError": "A mensagem deve ter entre 10 e 5 000 carateres.", + "feedbackInvalidEmail": "Introduza um endereço de e-mail válido.", + "feedbackConnectionError": "Não foi possível ligar ao BusyStack. Verifique a ligação e tente novamente.", + "feedbackTimeoutError": "O pedido excedeu o tempo limite. Os seus comentários não foram apagados; tente novamente.", + "feedbackRateLimitedError": "Foram enviados demasiados comentários a partir desta rede. Aguarde e tente novamente.", + "feedbackRejectedError": "O servidor rejeitou o envio. Reveja os campos e tente novamente.", + "feedbackServerError": "O BusyStack não pode aceitar os seus comentários neste momento. Os seus comentários não foram apagados; tente novamente.", + "feedbackSuccess": "Comentários enviados. Referência: {id}", + "toggleSidebar": "Mostrar ou ocultar a barra lateral", + "showSidebar": "Mostrar painel lateral", + "hideSidebar": "Ocultar painel lateral", + "accounts": "Contas", + "currentAccount": "Conta atual", + "switchAccount": "Mudar de conta", + "addGoogleAccount": "Adicionar conta Google", + "addMicrosoftAccount": "Adicionar conta Microsoft", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "Sessão iniciada", + "removeAccount": "Remover conta…", + "removingAccount": "A remover conta…", + "removeAccountDescription": "Parar a sincronização e remover os dados desta conta deste dispositivo.", + "removeAccountTitle": "Remover {account} do BusyMax?", + "removeAccountConfirmation": "Esta ação elimina deste dispositivo as tarefas, os calendários, os eventos, os lembretes e as alterações offline pendentes em cache. As alterações não sincronizadas serão perdidas. Nada será eliminado da Google ou da Microsoft.", + "revokeGoogleAccess": "Revogar também o acesso do BusyMax a esta conta Google", + "revokeGoogleAccessDescription": "Terá de conceder acesso novamente antes de voltar a ligar a conta.", + "removeAccountAction": "Remover conta", + "removeAccountFailed": "Não foi possível concluir a remoção da conta. Tente novamente.", + "accountRemovedGoogleRevokeFailed": "A conta foi removida deste dispositivo, mas não foi possível revogar o acesso do BusyMax à sua conta Google. Pode revogar esse acesso na sua conta Google.", + "newList": "Nova lista", + "signInToViewTaskLists": "Inicie sessão para ver as listas de tarefas.", + "noTaskListsSynced": "Ainda não há listas de tarefas sincronizadas.", + "listActions": "Ações da lista", + "rename": "Mudar o nome", + "delete": "Eliminar", + "renameList": "Mudar o nome da lista", + "deleteList": "Eliminar lista", + "builtInMicrosoftList": "Incorporada", + "builtInMicrosoftListCannotRenameDelete": "As listas incorporadas do Microsoft To Do não podem ser renomeadas nem eliminadas.", + "deleteListConfirmation": "Eliminar «{title}» do Google Tasks?", + "deleteEvent": "Eliminar evento", + "title": "Título", + "create": "Criar", + "newTask": "Nova tarefa", + "clearCompleted": "Limpar tarefas concluídas", + "refreshList": "Atualizar lista", + "refreshAll": "Atualizar tudo", + "listRefreshed": "Lista atualizada.", + "allTasksRefreshed": "Todas as contas foram atualizadas.", + "exportedFile": "Exportado para {path}", + "exportFailed": "Falha ao exportar: {error}", + "refreshFailed": "Falha ao atualizar: {error}", + "selectOrCreateTaskList": "Selecione ou crie uma lista de tarefas para começar.", + "signInToViewTasks": "Inicie sessão para ver as tarefas.", + "noTasks": "Sem tarefas.", + "noTasksYet": "Ainda não há tarefas", + "noTasksYetMessage": "Crie uma tarefa ou atualize as suas contas para começar.", + "noTasksInList": "Não há tarefas nesta lista.", + "overdue": "Em atraso", + "today": "Hoje", + "tomorrow": "Amanhã", + "upcoming": "Próximas", + "noDate": "Sem data", + "completed": "Concluídas", + "duePrefix": "Prazo: {date}", + "dateTimeDisplay": "{date}, {time}", + "taskDetails": "Detalhes da tarefa", + "editTask": "Editar tarefa", + "noTaskSelected": "Nenhuma tarefa selecionada.", + "noTaskSelectedHelper": "Selecione uma tarefa para ver e editar os detalhes.", + "taskUnavailable": "Tarefa indisponível.", + "signInToEditTasks": "Inicie sessão para editar tarefas.", + "refreshTask": "Atualizar tarefa", + "primarySection": "Principal", + "statusSection": "Estado", + "openStatus": "Aberta", + "doneStatus": "Concluída", + "notes": "Notas", + "dueDate": "Data limite", + "clearDueDate": "Limpar data limite", + "dueTime": "Hora limite", + "startDate": "Data de início", + "startTime": "Hora de início", + "endDate": "Data de fim", + "endTime": "Hora de fim", + "reminderDate": "Data do lembrete", + "reminderTime": "Hora do lembrete", + "reminder": "Lembrete", + "addReminder": "Adicionar lembrete", + "addGuest": "Adicionar convidado", + "addGuestEmail": "Adicionar e-mail do convidado", + "removeReminder": "Remover lembrete", + "off": "Desativado", + "repeat": "Repetição", + "repeatNone": "Não repetir", + "noneValue": "Nenhum", + "repeatDaily": "Diariamente", + "repeatWeekly": "Semanalmente", + "repeatMonthly": "Mensalmente", + "repeatYearly": "Anualmente", + "importance": "Importância", + "importanceLow": "Baixa", + "importanceNormal": "Normal", + "importanceHigh": "Alta", + "categories": "Categorias", + "scheduleSection": "Agenda", + "dueGroup": "Prazo", + "startGroup": "Início", + "reminderGroup": "Lembrete", + "organizationSection": "Organização", + "actionsSection": "Ações", + "advancedSection": "Avançado", + "addCategory": "Adicionar categoria", + "list": "Lista", + "microsoftMoveUnsupported": "Nesta versão, não é possível mover tarefas entre listas em contas Microsoft To Do.", + "createSubtask": "Criar subtarefa", + "moveToTop": "Mover para o início", + "deleteTask": "Eliminar tarefa", + "newSubtask": "Nova subtarefa", + "deleteTaskConfirmation": "Eliminar «{title}» do Google Tasks?", + "metadata": "Metadados", + "id": "ID", + "etag": "ETag", + "updated": "Atualizado", + "parent": "Tarefa principal", + "position": "Posição", + "webLink": "Ligação Web", + "assignment": "Atribuição", + "localState": "Estado local", + "pendingSync": "Sincronização pendente", + "synced": "Sincronizado", + "account": "Conta", + "sync": "Sincronização", + "manualFullSync": "Sincronização completa manual", + "runInBackgroundWhenClosed": "Continuar em execução quando a janela for fechada", + "showTrayIcon": "Mostrar ícone na área de notificação", + "startMinimizedToTray": "Iniciar minimizado na área de notificação", + "requiresTrayIcon": "Requer o ícone da área de notificação.", + "syncComplete": "Sincronização concluída.", + "syncFailed": "Falha na sincronização: {error}", + "notifySyncFailures": "Notificações de falhas de sincronização", + "notifyConflicts": "Notificações de conflitos", + "notifyDueToday": "Notificações de tarefas com prazo para hoje", + "eventReminders": "Lembretes de eventos", + "taskReminders": "Lembretes de tarefas", + "notificationDetailLevel": "Nível de detalhe das notificações", + "notificationDetailPrivate": "Privado", + "notificationDetailNormal": "Normal", + "quietHours": "Período de silêncio", + "quietHoursDescription": "Pausar as notificações durante este período.", + "quietHoursStart": "Início do período de silêncio", + "quietHoursEnd": "Fim do período de silêncio", + "notifications": "Notificações", + "appearance": "Aspeto", + "theme": "Tema", + "themeSystem": "Sistema", + "themeLight": "Claro", + "themeDark": "Escuro", + "themeFamily": "Família de temas", + "themeFamilyYaru": "Tema nativo do Ubuntu (Yaru)", + "localization": "Localização", + "currentLocale": "Configuração regional atual", + "privacy": "Privacidade", + "redactTaskContentInDiagnostics": "Ocultar o conteúdo das tarefas nos diagnósticos", + "developerDiagnostics": "Diagnósticos de programador", + "diagnostics": "Diagnósticos", + "apiInspectorDisabled": "Mostrar inspetor da API", + "googleTasksApi": "API do Google Tasks", + "discoveryRevision": "Revisão de descoberta: {revision}", + "implementedMethods": "Métodos implementados", + "supportsTasksScopes": "Suporta os âmbitos de autorização tasks e tasks.readonly", + "requiresTasksScope": "Requer o âmbito de autorização tasks", + "blockedPendingOperations": "Operações pendentes bloqueadas", + "signInToInspectPendingOperations": "Inicie sessão para inspecionar as operações pendentes.", + "noBlockedPendingOperations": "Não há operações pendentes bloqueadas.", + "operationActions": "Ações da operação", + "pendingOpListId": "lista={id}", + "pendingOpTaskId": "tarefa={id}", + "pendingOpAttempts": "tentativas={count}", + "retry": "Tentar novamente", + "discard": "Descartar", + "discardChangesAction": "Descartar", + "discardChanges": "Descartar alterações?", + "discardChangesConfirmation": "Esta ação descarta as alterações não guardadas nesta tarefa.", + "retryCompleted": "Nova tentativa concluída.", + "discardPendingOperation": "Descartar operação pendente?", + "discardPendingOperationConfirmation": "Esta ação remove a operação local bloqueada. Na próxima sincronização, os dados serão novamente carregados do Google Tasks.", + "pendingOperationDiscarded": "Operação pendente descartada.", + "syncFailureNotificationTitle": "Falha na sincronização do BusyMax", + "syncFailureNotificationBody": "A sincronização em segundo plano falhou. {message}", + "conflictNotificationTitle": "Conflito de sincronização do BusyMax", + "conflictNotificationBody": "Uma alteração local pendente foi bloqueada. {summary}", + "dueTodayNotificationTitle": "Tarefas com prazo para hoje", + "dueTodayNotificationBody": "{count, plural, =1{Há uma tarefa com prazo para hoje.} other{Há {count} tarefas com prazo para hoje.}}", + "eventReminderNotificationTitle": "Lembrete de evento", + "taskReminderNotificationTitle": "Lembrete de tarefa", + "eventReminderNotificationBody": "O evento começa em breve.", + "taskReminderNotificationBody": "O prazo da tarefa aproxima-se.", + "notificationOpenAction": "Abrir", + "notificationDetailsHidden": "Os detalhes estão ocultos pelas definições de privacidade.", + "previousMonth": "Mês anterior", + "nextMonth": "Mês seguinte", + "openMonthView": "Abrir vista mensal", + "previousYear": "Ano anterior", + "nextYear": "Ano seguinte", + "openYearView": "Abrir vista anual", + "weekNumberTooltip": "Semana {number}", + "resizeAllDayPanel": "Redimensionar o painel de dia inteiro", + "scheduleItemCount": "{count, plural, =1{1 item} other{{count} itens}}", + "readOnlyCalendar": "Este calendário é só de leitura.", + "selectTimeZone": "Selecionar fuso horário", + "searchLocations": "Pesquisar locais", + "noLocationsFound": "Nenhum local encontrado", + "deleteCalendarConfirmation": "Eliminar «{title}»?" +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb new file mode 100644 index 0000000..6184aa3 --- /dev/null +++ b/lib/l10n/app_ru.arb @@ -0,0 +1,374 @@ +{ + "@@locale": "ru", + "appTitle": "BusyMax", + "connectGoogleAccount": "Подключите аккаунты Google и Microsoft, чтобы синхронизировать календари и задачи.", + "googlePermissionsConsentNotice": "На экране запроса доступа Google установите флажки «Google Календарь» и «Google Задачи».", + "googlePermissionsRequiredRetry": "BusyMax требуется доступ к Google Календарю и Google Задачам. Повторите попытку и установите оба флажка.", + "finishSetup": "Завершить настройку", + "continueSetup": "Продолжить", + "onboardingSetupTitle": "Настройка BusyMax", + "onboardingAccountsStepTitle": "Подключите аккаунты", + "onboardingAccountsStepDescription": "Добавьте все аккаунты Google и Microsoft, которые хотите использовать. BusyMax синхронизирует календари, события, списки задач и задачи из каждого аккаунта.", + "onboardingPreferencesStepTitle": "Выберите системные параметры", + "onboardingPreferencesStepDescription": "Настройте поведение приложения на рабочем столе, напоминания, уровень детализации уведомлений и внешний вид, прежде чем открыть расписание.", + "signInWithGoogle": "Войти через Google", + "signInWithMicrosoft": "Войти через Microsoft", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "Этот сервис не настроен.", + "waitingForGoogleSignIn": "Ожидание входа через Google...", + "waitingForMicrosoftSignIn": "Ожидание входа через Microsoft...", + "microsoftSignInNotConfigured": "Вход через Microsoft не настроен. Задайте MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "Отмена", + "close": "Закрыть", + "exit": "Выйти", + "options": "Параметры", + "hide": "Скрыть", + "show": "Показать", + "export": "Экспортировать", + "save": "Сохранить", + "settings": "Настройки", + "all": "Все", + "calendarEvents": "События", + "calendarTasks": "Задачи", + "calendar": "Календарь", + "calendars": "Календари", + "newEvent": "Новое событие", + "refreshCalendar": "Обновить календарь", + "openInProvider": "Открыть в сервисе", + "hideFromSchedule": "Скрыть из расписания", + "showInSchedule": "Показывать в расписании", + "noCalendarsSynced": "Синхронизированных календарей пока нет.", + "allDay": "Весь день", + "moreItems": "+ ещё {count}", + "noEventsOrTasks": "Нет событий или задач", + "scheduleLoading": "Загрузка расписания...", + "scheduleUnavailable": "Расписание недоступно", + "scheduleNoSources": "Нет видимых календарей или списков задач", + "scheduleNoSourcesDescription": "Выберите в настройках, что нужно показывать, а затем обновите расписание.", + "scheduleSignInRequired": "Подключите аккаунт", + "scheduleSignInDescription": "Войдите, чтобы синхронизировать календари и задачи.", + "scheduleNoSearchResults": "Подходящих событий или задач нет", + "scheduleNoSearchResultsDescription": "Попробуйте изменить запрос или сбросить текущие фильтры.", + "refresh": "Обновить", + "trayOpenBusyMax": "Открыть BusyMax", + "agendaLoadMoreOverdue": "Загрузить ещё просроченные задачи", + "agendaLoadMoreNoDate": "Загрузить ещё задачи без даты", + "viewDay": "День", + "viewWeek": "Неделя", + "viewMonth": "Месяц", + "viewYear": "Год", + "viewAgenda": "Расписание", + "scheduleSettings": "Расписание", + "scheduleDisplaySettings": "Отображение расписания", + "scheduleDisplayHoursDescription": "В представлениях дня и недели изначально отображается этот период. Более ранние или поздние записи при необходимости расширяют его.", + "scheduleDayStartsAt": "Начало дня", + "scheduleDayEndsAt": "Конец дня", + "sourceCalendar": "Календарь", + "sourceTaskList": "Список задач", + "createChoiceTitle": "Создать", + "createEventAtTime": "Событие", + "createTaskAtDate": "Задача", + "editEvent": "Изменить событие", + "eventTitle": "Название события", + "location": "Место", + "timeSlot": "Интервал времени", + "startDateTime": "Дата и время начала", + "endDateTime": "Дата и время окончания", + "doesNotRepeat": "Не повторяется", + "defaultReminder": "Напоминание по умолчанию", + "guests": "Гости", + "noGuests": "Нет гостей", + "description": "Описание", + "availabilityShowAs": "Показывать как", + "busy": "Занят", + "visibility": "Видимость", + "defaultVisibility": "Видимость по умолчанию", + "conference": "Конференция", + "noConference": "Без конференции", + "providerCalendar": "Календарь сервиса", + "formatBoldShortLabel": "Ж", + "formatBoldTooltip": "Полужирный", + "formatItalicShortLabel": "К", + "formatItalicTooltip": "Курсив", + "formatUnderlineShortLabel": "Ч", + "formatUnderlineTooltip": "Подчёркнутый", + "reminderMinutesBefore": "{minutes, plural, one{За {minutes} минуту} few{За {minutes} минуты} many{За {minutes} минут} other{За {minutes} минуты}}", + "reminderAtStart": "В момент начала", + "reminderHoursBefore": "{hours, plural, one{За {hours} час} few{За {hours} часа} many{За {hours} часов} other{За {hours} часа}}", + "reminderDaysBefore": "{days, plural, one{За {days} день} few{За {days} дня} many{За {days} дней} other{За {days} дня}}", + "availabilityFree": "Свободен", + "availabilityTentative": "Под вопросом", + "availabilityOutOfOffice": "Нет на рабочем месте", + "availabilityWorkingElsewhere": "Работа в другом месте", + "visibilityDefault": "По умолчанию", + "visibilityPublic": "Общедоступное", + "visibilityPrivate": "Личное", + "visibilityConfidential": "Конфиденциальное", + "sensitivityNormal": "Обычная", + "sensitivityPersonal": "Личная", + "tasks": "Задачи", + "allTasks": "Все задачи", + "tasksInList": "Задачи в списке «{title}»", + "taskLists": "Списки задач", + "navigation": "Навигация", + "mainMenu": "Главное меню", + "keyboardShortcuts": "Сочетания клавиш", + "shortcutGroupGeneral": "Общие", + "shortcutKeyboardShortcutsDescription": "Показать эту справку по сочетаниям клавиш", + "shortcutGroupNavigation": "Навигация", + "shortcutNextPeriod": "Следующий период", + "shortcutNextPeriodDescription": "Следующая неделя в представлении недели, следующий месяц в представлении месяца и так далее", + "shortcutPreviousPeriod": "Предыдущий период", + "shortcutPreviousPeriodDescription": "Предыдущая неделя в представлении недели, предыдущий месяц в представлении месяца и так далее", + "shortcutJumpToToday": "Перейти к сегодняшнему дню", + "shortcutGroupView": "Представление", + "shortcutDayView": "Представление дня", + "shortcutWeekView": "Представление недели", + "shortcutMonthView": "Представление месяца", + "shortcutYearView": "Представление года", + "shortcutAgendaView": "Расписание", + "shortcutGroupCreateAndEdit": "Создание и редактирование", + "shortcutSaveItem": "Сохранить событие или задачу", + "shortcutDeleteItem": "Удалить событие или задачу", + "shortcutGroupTaskEditing": "Редактирование задач", + "shortcutCancelEditing": "Отменить редактирование", + "shortcutCancelEditingDescription": "Выйти из режима редактирования задачи или закрыть сведения о ней", + "aboutBusyMax": "О приложении BusyMax", + "aboutBusyMaxDescription": "Календарь и задачи", + "license": "Лицензия", + "apacheLicenseName": "Apache License 2.0", + "website": "Веб-сайт", + "sourceCode": "Исходный код", + "reportAnIssue": "Сообщить о проблеме", + "sendFeedback": "Отправить отзыв", + "feedbackSubmit": "Отправить", + "feedbackCategory": "Категория", + "feedbackSelectCategory": "Выберите категорию", + "feedbackCategoryProblem": "Проблема или ошибка", + "feedbackCategoryFeature": "Запрос функции", + "feedbackCategoryPrivacySecurity": "Проблема конфиденциальности или безопасности", + "feedbackCategoryUsability": "Проблема удобства использования", + "feedbackCategoryOther": "Другое", + "feedbackSubject": "Тема", + "feedbackDetailedMessage": "Подробное сообщение", + "feedbackReplyEmail": "Адрес электронной почты для ответа (необязательно)", + "feedbackIncludeTechnicalDetails": "Включить технические сведения", + "feedbackTechnicalDetailsDisclosure": "Будут добавлены только версия Linux и выбранный язык приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не добавляются.", + "feedbackCategoryRequired": "Выберите категорию.", + "feedbackSubjectLengthError": "Тема должна содержать от 3 до 120 символов.", + "feedbackMessageLengthError": "Сообщение должно содержать от 10 до 5 000 символов.", + "feedbackInvalidEmail": "Введите действительный адрес электронной почты.", + "feedbackConnectionError": "Не удалось подключиться к BusyStack. Проверьте подключение и повторите попытку.", + "feedbackTimeoutError": "Время ожидания запроса истекло. Текст отзыва сохранён. Повторите попытку.", + "feedbackRateLimitedError": "Из этой сети было отправлено слишком много отзывов. Подождите и повторите попытку.", + "feedbackRejectedError": "Сервер отклонил отправку. Проверьте поля и повторите попытку.", + "feedbackServerError": "BusyStack сейчас не может принять ваш отзыв. Текст отзыва сохранён. Повторите попытку.", + "feedbackSuccess": "Отзыв отправлен. Номер: {id}", + "toggleSidebar": "Показать или скрыть боковую панель", + "showSidebar": "Показать боковую панель", + "hideSidebar": "Скрыть боковую панель", + "accounts": "Аккаунты", + "currentAccount": "Текущий аккаунт", + "switchAccount": "Сменить аккаунт", + "addGoogleAccount": "Добавить аккаунт Google", + "addMicrosoftAccount": "Добавить аккаунт Microsoft", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "Выполнен вход", + "removeAccount": "Удалить аккаунт…", + "removingAccount": "Удаление аккаунта…", + "removeAccountDescription": "Остановить синхронизацию и удалить данные этого аккаунта с устройства.", + "removeAccountTitle": "Удалить {account} из BusyMax?", + "removeAccountConfirmation": "С этого устройства будут удалены кэшированные задачи, календари, события, напоминания и локальные изменения, ожидающие синхронизации. Несинхронизированные изменения будут потеряны. В Google и Microsoft ничего не будет удалено.", + "revokeGoogleAccess": "Также отозвать у BusyMax доступ к этому аккаунту Google", + "revokeGoogleAccessDescription": "Перед повторным подключением аккаунта потребуется снова предоставить доступ.", + "removeAccountAction": "Удалить аккаунт", + "removeAccountFailed": "Не удалось завершить удаление аккаунта. Повторите попытку.", + "accountRemovedGoogleRevokeFailed": "Аккаунт удалён с этого устройства, но отозвать доступ BusyMax к Google не удалось. Вы можете отозвать доступ в аккаунте Google.", + "newList": "Новый список", + "signInToViewTaskLists": "Войдите, чтобы просмотреть списки задач.", + "noTaskListsSynced": "Синхронизированных списков задач пока нет.", + "listActions": "Действия со списком", + "rename": "Переименовать", + "delete": "Удалить", + "renameList": "Переименовать список", + "deleteList": "Удалить список", + "builtInMicrosoftList": "Встроенный", + "builtInMicrosoftListCannotRenameDelete": "Встроенные списки Microsoft To Do нельзя переименовывать или удалять.", + "deleteListConfirmation": "Удалить «{title}» из Google Tasks?", + "deleteEvent": "Удалить событие", + "title": "Название", + "create": "Создать", + "newTask": "Новая задача", + "clearCompleted": "Удалить выполненные", + "refreshList": "Обновить список", + "refreshAll": "Обновить всё", + "listRefreshed": "Список обновлён.", + "allTasksRefreshed": "Все аккаунты обновлены.", + "exportedFile": "Экспортировано в {path}", + "exportFailed": "Не удалось экспортировать: {error}", + "refreshFailed": "Не удалось обновить: {error}", + "selectOrCreateTaskList": "Сначала выберите или создайте список задач.", + "signInToViewTasks": "Войдите, чтобы просмотреть задачи.", + "noTasks": "Задач нет.", + "noTasksYet": "Задач пока нет", + "noTasksYetMessage": "Создайте задачу или обновите аккаунты, чтобы начать.", + "noTasksInList": "В этом списке нет задач.", + "overdue": "Просроченные", + "today": "Сегодня", + "tomorrow": "Завтра", + "upcoming": "Предстоящие", + "noDate": "Без даты", + "completed": "Выполненные", + "duePrefix": "Срок: {date}", + "dateTimeDisplay": "{date}, {time}", + "taskDetails": "Сведения о задаче", + "editTask": "Изменить задачу", + "noTaskSelected": "Задача не выбрана.", + "noTaskSelectedHelper": "Выберите задачу, чтобы просмотреть и изменить её сведения.", + "taskUnavailable": "Задача недоступна.", + "signInToEditTasks": "Войдите, чтобы изменять задачи.", + "refreshTask": "Обновить задачу", + "primarySection": "Основные сведения", + "statusSection": "Состояние", + "openStatus": "Открыта", + "doneStatus": "Выполнена", + "notes": "Заметки", + "dueDate": "Срок", + "clearDueDate": "Удалить срок выполнения", + "dueTime": "Время выполнения", + "startDate": "Дата начала", + "startTime": "Время начала", + "endDate": "Дата окончания", + "endTime": "Время окончания", + "reminderDate": "Дата напоминания", + "reminderTime": "Время напоминания", + "reminder": "Напоминание", + "addReminder": "Добавить напоминание", + "addGuest": "Добавить гостя", + "addGuestEmail": "Добавить адрес электронной почты гостя", + "removeReminder": "Удалить напоминание", + "off": "Выкл.", + "repeat": "Повтор", + "repeatNone": "Не повторять", + "noneValue": "Нет", + "repeatDaily": "Ежедневно", + "repeatWeekly": "Еженедельно", + "repeatMonthly": "Ежемесячно", + "repeatYearly": "Ежегодно", + "importance": "Важность", + "importanceLow": "Низкая", + "importanceNormal": "Обычная", + "importanceHigh": "Высокая", + "categories": "Категории", + "scheduleSection": "Расписание", + "dueGroup": "Срок", + "startGroup": "Начало", + "reminderGroup": "Напоминание", + "organizationSection": "Организация", + "actionsSection": "Действия", + "advancedSection": "Дополнительно", + "addCategory": "Добавить категорию", + "list": "Список", + "microsoftMoveUnsupported": "В этой версии перенос между списками для аккаунтов Microsoft To Do не поддерживается.", + "createSubtask": "Создать подзадачу", + "moveToTop": "Переместить в самый верх", + "deleteTask": "Удалить задачу", + "newSubtask": "Новая подзадача", + "deleteTaskConfirmation": "Удалить «{title}» из Google Tasks?", + "metadata": "Метаданные", + "id": "Идентификатор", + "etag": "ETag", + "updated": "Обновлено", + "parent": "Родительская задача", + "position": "Позиция", + "webLink": "Веб-ссылка", + "assignment": "Назначение", + "localState": "Локальное состояние", + "pendingSync": "Ожидает синхронизации", + "synced": "Синхронизировано", + "account": "Аккаунт", + "sync": "Синхронизация", + "manualFullSync": "Полная синхронизация вручную", + "runInBackgroundWhenClosed": "Продолжать работу после закрытия окна", + "showTrayIcon": "Показывать значок в области уведомлений", + "startMinimizedToTray": "Запускать свёрнутым в область уведомлений", + "requiresTrayIcon": "Требуется значок в области уведомлений.", + "syncComplete": "Синхронизация завершена.", + "syncFailed": "Синхронизация не удалась: {error}", + "notifySyncFailures": "Уведомлять об ошибках синхронизации", + "notifyConflicts": "Уведомлять о конфликтах", + "notifyDueToday": "Уведомлять о задачах на сегодня", + "eventReminders": "Напоминания о событиях", + "taskReminders": "Напоминания о задачах", + "notificationDetailLevel": "Содержимое уведомлений", + "notificationDetailPrivate": "Скрывать подробности", + "notificationDetailNormal": "Показывать подробности", + "quietHours": "Период без уведомлений", + "quietHoursDescription": "Не показывать уведомления в это время.", + "quietHoursStart": "Начало периода", + "quietHoursEnd": "Конец периода", + "notifications": "Уведомления", + "appearance": "Внешний вид", + "theme": "Тема", + "themeSystem": "Системная", + "themeLight": "Светлая", + "themeDark": "Тёмная", + "themeFamily": "Семейство тем", + "themeFamilyYaru": "Стандартная тема Ubuntu (Yaru)", + "localization": "Язык", + "currentLocale": "Язык приложения", + "privacy": "Конфиденциальность", + "redactTaskContentInDiagnostics": "Скрывать содержимое задач в диагностике", + "developerDiagnostics": "Диагностика для разработчиков", + "diagnostics": "Диагностика", + "apiInspectorDisabled": "Показать инспектор API", + "googleTasksApi": "API Google Tasks", + "discoveryRevision": "Версия Discovery: {revision}", + "implementedMethods": "Реализованные методы", + "supportsTasksScopes": "Поддерживает области разрешений tasks и tasks.readonly", + "requiresTasksScope": "Требуется область разрешений tasks", + "blockedPendingOperations": "Заблокированные операции", + "signInToInspectPendingOperations": "Войдите, чтобы просмотреть ожидающие операции.", + "noBlockedPendingOperations": "Заблокированных операций нет.", + "operationActions": "Действия с операцией", + "pendingOpListId": "список={id}", + "pendingOpTaskId": "задача={id}", + "pendingOpAttempts": "попытки={count}", + "retry": "Повторить", + "discard": "Отменить", + "discardChangesAction": "Не сохранять", + "discardChanges": "Не сохранять изменения?", + "discardChangesConfirmation": "Несохранённые изменения этой задачи будут потеряны.", + "retryCompleted": "Повторная попытка завершена.", + "discardPendingOperation": "Удалить заблокированную операцию?", + "discardPendingOperationConfirmation": "Заблокированная локальная операция будет удалена. При следующей синхронизации данные будут заново загружены из Google Tasks.", + "pendingOperationDiscarded": "Заблокированная операция удалена.", + "syncFailureNotificationTitle": "Сбой синхронизации BusyMax", + "syncFailureNotificationBody": "Сбой фоновой синхронизации. {message}", + "conflictNotificationTitle": "Конфликт синхронизации BusyMax", + "conflictNotificationBody": "Локальное изменение не удалось синхронизировать. {summary}", + "dueTodayNotificationTitle": "Задачи на сегодня", + "dueTodayNotificationBody": "{count, plural, one{Сегодня нужно выполнить {count} задачу.} few{Сегодня нужно выполнить {count} задачи.} many{Сегодня нужно выполнить {count} задач.} other{Сегодня нужно выполнить {count} задачи.}}", + "eventReminderNotificationTitle": "Напоминание о событии", + "taskReminderNotificationTitle": "Напоминание о задаче", + "eventReminderNotificationBody": "Событие скоро начнётся.", + "taskReminderNotificationBody": "Срок выполнения задачи скоро наступит.", + "notificationOpenAction": "Открыть", + "notificationDetailsHidden": "Сведения скрыты настройками конфиденциальности.", + "previousMonth": "Предыдущий месяц", + "nextMonth": "Следующий месяц", + "openMonthView": "Открыть представление месяца", + "previousYear": "Предыдущий год", + "nextYear": "Следующий год", + "openYearView": "Открыть представление года", + "weekNumberTooltip": "Неделя {number}", + "resizeAllDayPanel": "Изменить размер панели «Весь день»", + "scheduleItemCount": "{count, plural, one{{count} запись} few{{count} записи} many{{count} записей} other{{count} записи}}", + "readOnlyCalendar": "Этот календарь доступен только для чтения.", + "selectTimeZone": "Выберите часовой пояс", + "searchLocations": "Поиск города", + "noLocationsFound": "Ничего не найдено", + "deleteCalendarConfirmation": "Удалить «{title}»?" +} diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb new file mode 100644 index 0000000..1a0d6a5 --- /dev/null +++ b/lib/l10n/app_vi.arb @@ -0,0 +1,374 @@ +{ + "@@locale": "vi", + "appTitle": "BusyMax", + "connectGoogleAccount": "Kết nối tài khoản Google và Microsoft để đồng bộ lịch và công việc.", + "googlePermissionsConsentNotice": "Trên màn hình cấp quyền của Google, hãy chọn cả quyền truy cập Lịch và Công việc.", + "googlePermissionsRequiredRetry": "Cần có quyền truy cập Google Calendar và Google Tasks. Vui lòng thử lại và chọn cả hai hộp kiểm.", + "finishSetup": "Hoàn tất thiết lập", + "continueSetup": "Tiếp tục", + "onboardingSetupTitle": "Thiết lập BusyMax", + "onboardingAccountsStepTitle": "Kết nối tài khoản", + "onboardingAccountsStepDescription": "Thêm tất cả tài khoản Google và Microsoft bạn muốn sử dụng. BusyMax đồng bộ lịch, sự kiện, danh sách công việc và công việc từ mỗi tài khoản.", + "onboardingPreferencesStepTitle": "Chọn cài đặt hệ thống", + "onboardingPreferencesStepDescription": "Thiết lập cách ứng dụng hoạt động trên máy tính, lời nhắc, mức độ chi tiết của thông báo và giao diện trước khi mở lịch biểu.", + "signInWithGoogle": "Đăng nhập bằng Google", + "signInWithMicrosoft": "Đăng nhập bằng Microsoft", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "Dịch vụ này chưa được cấu hình.", + "waitingForGoogleSignIn": "Đang chờ đăng nhập Google...", + "waitingForMicrosoftSignIn": "Đang chờ đăng nhập Microsoft...", + "microsoftSignInNotConfigured": "Tính năng đăng nhập Microsoft chưa được cấu hình. Hãy đặt MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "Hủy", + "close": "Đóng", + "exit": "Thoát", + "options": "Tùy chọn", + "hide": "Ẩn", + "show": "Hiện", + "export": "Xuất", + "save": "Lưu", + "settings": "Cài đặt", + "all": "Tất cả", + "calendarEvents": "Sự kiện", + "calendarTasks": "Công việc", + "calendar": "Lịch", + "calendars": "Lịch", + "newEvent": "Sự kiện mới", + "refreshCalendar": "Làm mới lịch", + "openInProvider": "Mở trong dịch vụ", + "hideFromSchedule": "Ẩn khỏi lịch biểu", + "showInSchedule": "Hiện trong lịch biểu", + "noCalendarsSynced": "Chưa có lịch nào được đồng bộ.", + "allDay": "Cả ngày", + "moreItems": "+{count} mục khác", + "noEventsOrTasks": "Không có sự kiện hoặc công việc", + "scheduleLoading": "Đang tải lịch biểu...", + "scheduleUnavailable": "Lịch biểu không khả dụng", + "scheduleNoSources": "Không có lịch hoặc danh sách công việc nào đang hiển thị", + "scheduleNoSourcesDescription": "Chọn nội dung cần hiển thị trong Cài đặt, sau đó làm mới lịch biểu.", + "scheduleSignInRequired": "Kết nối tài khoản", + "scheduleSignInDescription": "Đăng nhập để đồng bộ lịch và công việc.", + "scheduleNoSearchResults": "Không có sự kiện hoặc công việc phù hợp", + "scheduleNoSearchResultsDescription": "Thử tìm kiếm khác hoặc xóa các bộ lọc hiện tại.", + "refresh": "Làm mới", + "trayOpenBusyMax": "Mở BusyMax", + "agendaLoadMoreOverdue": "Tải thêm công việc quá hạn", + "agendaLoadMoreNoDate": "Tải thêm công việc không có ngày", + "viewDay": "Ngày", + "viewWeek": "Tuần", + "viewMonth": "Tháng", + "viewYear": "Năm", + "viewAgenda": "Lịch biểu", + "scheduleSettings": "Lịch biểu", + "scheduleDisplaySettings": "Hiển thị lịch biểu", + "scheduleDisplayHoursDescription": "Chế độ xem Ngày và Tuần ban đầu hiển thị khoảng thời gian này. Các mục sớm hơn hoặc muộn hơn sẽ mở rộng khoảng hiển thị khi cần.", + "scheduleDayStartsAt": "Ngày bắt đầu lúc", + "scheduleDayEndsAt": "Ngày kết thúc lúc", + "sourceCalendar": "Lịch", + "sourceTaskList": "Danh sách công việc", + "createChoiceTitle": "Tạo", + "createEventAtTime": "Sự kiện", + "createTaskAtDate": "Công việc", + "editEvent": "Chỉnh sửa sự kiện", + "eventTitle": "Tiêu đề sự kiện", + "location": "Địa điểm", + "timeSlot": "Khoảng thời gian", + "startDateTime": "Ngày/giờ bắt đầu", + "endDateTime": "Ngày/giờ kết thúc", + "doesNotRepeat": "Không lặp lại", + "defaultReminder": "Lời nhắc mặc định", + "guests": "Khách mời", + "noGuests": "Không có khách mời", + "description": "Mô tả", + "availabilityShowAs": "Tình trạng rảnh/bận / Hiển thị là", + "busy": "Bận", + "visibility": "Chế độ hiển thị", + "defaultVisibility": "Chế độ hiển thị mặc định", + "conference": "Cuộc họp", + "noConference": "Không có cuộc họp", + "providerCalendar": "Lịch của dịch vụ", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "Đậm", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "Nghiêng", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "Gạch chân", + "reminderMinutesBefore": "{minutes, plural, =1{Trước 1 phút} other{Trước {minutes} phút}}", + "reminderAtStart": "Khi bắt đầu", + "reminderHoursBefore": "{hours, plural, =1{Trước 1 giờ} other{Trước {hours} giờ}}", + "reminderDaysBefore": "{days, plural, =1{Trước 1 ngày} other{Trước {days} ngày}}", + "availabilityFree": "Rảnh", + "availabilityTentative": "Dự kiến", + "availabilityOutOfOffice": "Vắng mặt", + "availabilityWorkingElsewhere": "Làm việc ở nơi khác", + "visibilityDefault": "Mặc định", + "visibilityPublic": "Công khai", + "visibilityPrivate": "Riêng tư", + "visibilityConfidential": "Bảo mật", + "sensitivityNormal": "Bình thường", + "sensitivityPersonal": "Cá nhân", + "tasks": "Công việc", + "allTasks": "Tất cả công việc", + "tasksInList": "Công việc trong {title}", + "taskLists": "Danh sách công việc", + "navigation": "Điều hướng", + "mainMenu": "Trình đơn chính", + "keyboardShortcuts": "Phím tắt", + "shortcutGroupGeneral": "Chung", + "shortcutKeyboardShortcutsDescription": "Hiển thị bảng tham khảo phím tắt này", + "shortcutGroupNavigation": "Điều hướng", + "shortcutNextPeriod": "Khoảng tiếp theo", + "shortcutNextPeriodDescription": "Tuần tiếp theo trong chế độ xem tuần, tháng tiếp theo trong chế độ xem tháng, v.v.", + "shortcutPreviousPeriod": "Khoảng trước đó", + "shortcutPreviousPeriodDescription": "Tuần trước trong chế độ xem tuần, tháng trước trong chế độ xem tháng, v.v.", + "shortcutJumpToToday": "Chuyển đến hôm nay", + "shortcutGroupView": "Chế độ xem", + "shortcutDayView": "Chế độ xem ngày", + "shortcutWeekView": "Chế độ xem tuần", + "shortcutMonthView": "Chế độ xem tháng", + "shortcutYearView": "Chế độ xem năm", + "shortcutAgendaView": "Chế độ xem lịch biểu", + "shortcutGroupCreateAndEdit": "Tạo và chỉnh sửa", + "shortcutSaveItem": "Lưu sự kiện hoặc công việc", + "shortcutDeleteItem": "Xóa sự kiện hoặc công việc", + "shortcutGroupTaskEditing": "Chỉnh sửa công việc", + "shortcutCancelEditing": "Hủy chỉnh sửa", + "shortcutCancelEditingDescription": "Đóng phần chỉnh sửa hoặc chi tiết công việc", + "aboutBusyMax": "Giới thiệu BusyMax", + "aboutBusyMaxDescription": "Lịch và công việc", + "license": "Giấy phép", + "apacheLicenseName": "Apache License 2.0", + "website": "Trang web", + "sourceCode": "Mã nguồn", + "reportAnIssue": "Báo cáo sự cố", + "sendFeedback": "Gửi phản hồi", + "feedbackSubmit": "Gửi", + "feedbackCategory": "Danh mục", + "feedbackSelectCategory": "Chọn một danh mục", + "feedbackCategoryProblem": "Sự cố hoặc lỗi", + "feedbackCategoryFeature": "Yêu cầu tính năng", + "feedbackCategoryPrivacySecurity": "Vấn đề về quyền riêng tư hoặc bảo mật", + "feedbackCategoryUsability": "Vấn đề về khả năng sử dụng", + "feedbackCategoryOther": "Khác", + "feedbackSubject": "Chủ đề", + "feedbackDetailedMessage": "Nội dung chi tiết", + "feedbackReplyEmail": "Địa chỉ email để nhận phản hồi (không bắt buộc)", + "feedbackIncludeTechnicalDetails": "Bao gồm chi tiết kỹ thuật", + "feedbackTechnicalDetailsDisclosure": "Chỉ thêm phiên bản hệ điều hành Linux và ngôn ngữ, khu vực của ứng dụng. Không bao gồm nhật ký, dữ liệu tài khoản, tên tệp hoặc thông tin chẩn đoán khác.", + "feedbackCategoryRequired": "Hãy chọn một danh mục.", + "feedbackSubjectLengthError": "Chủ đề phải có từ 3 đến 120 ký tự.", + "feedbackMessageLengthError": "Nội dung phải có từ 10 đến 5.000 ký tự.", + "feedbackInvalidEmail": "Nhập địa chỉ email hợp lệ.", + "feedbackConnectionError": "Không thể kết nối với BusyStack. Hãy kiểm tra kết nối và thử lại.", + "feedbackTimeoutError": "Yêu cầu đã hết thời gian chờ. Phản hồi của bạn chưa bị xóa; hãy thử lại.", + "feedbackRateLimitedError": "Đã gửi quá nhiều phản hồi từ mạng này. Vui lòng chờ rồi thử lại.", + "feedbackRejectedError": "Máy chủ đã từ chối nội dung gửi. Hãy kiểm tra các trường và thử lại.", + "feedbackServerError": "BusyStack hiện không thể nhận phản hồi của bạn. Phản hồi chưa bị xóa; hãy thử lại.", + "feedbackSuccess": "Đã gửi phản hồi. Mã tham chiếu: {id}", + "toggleSidebar": "Hiện hoặc ẩn thanh bên", + "showSidebar": "Hiện bảng bên", + "hideSidebar": "Ẩn bảng bên", + "accounts": "Tài khoản", + "currentAccount": "Tài khoản hiện tại", + "switchAccount": "Chuyển tài khoản", + "addGoogleAccount": "Thêm tài khoản Google", + "addMicrosoftAccount": "Thêm tài khoản Microsoft", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "Đã đăng nhập", + "removeAccount": "Xóa tài khoản…", + "removingAccount": "Đang xóa tài khoản…", + "removeAccountDescription": "Dừng đồng bộ và xóa dữ liệu của tài khoản này khỏi thiết bị.", + "removeAccountTitle": "Xóa {account} khỏi BusyMax?", + "removeAccountConfirmation": "Thao tác này sẽ xóa công việc, lịch, sự kiện, lời nhắc đã lưu trong bộ nhớ đệm và các thay đổi ngoại tuyến đang chờ khỏi thiết bị. Các thay đổi chưa đồng bộ sẽ bị mất. Không có dữ liệu nào bị xóa khỏi Google hoặc Microsoft.", + "revokeGoogleAccess": "Đồng thời thu hồi quyền truy cập của BusyMax vào tài khoản Google này", + "revokeGoogleAccessDescription": "Bạn sẽ cần cấp lại quyền truy cập trước khi kết nối lại.", + "removeAccountAction": "Xóa tài khoản", + "removeAccountFailed": "Không thể hoàn tất việc xóa tài khoản. Hãy thử lại.", + "accountRemovedGoogleRevokeFailed": "Tài khoản đã bị xóa khỏi thiết bị này, nhưng BusyMax không thể thu hồi quyền truy cập vào tài khoản Google. Bạn có thể thu hồi quyền trong phần cài đặt Tài khoản Google.", + "newList": "Danh sách mới", + "signInToViewTaskLists": "Đăng nhập để xem danh sách công việc.", + "noTaskListsSynced": "Chưa có danh sách công việc nào được đồng bộ.", + "listActions": "Thao tác với danh sách", + "rename": "Đổi tên", + "delete": "Xóa", + "renameList": "Đổi tên danh sách", + "deleteList": "Xóa danh sách", + "builtInMicrosoftList": "Tích hợp sẵn", + "builtInMicrosoftListCannotRenameDelete": "Không thể đổi tên hoặc xóa danh sách tích hợp sẵn của Microsoft To Do.", + "deleteListConfirmation": "Xóa “{title}” khỏi Google Tasks?", + "deleteEvent": "Xóa sự kiện", + "title": "Tiêu đề", + "create": "Tạo", + "newTask": "Công việc mới", + "clearCompleted": "Xóa các công việc đã hoàn thành", + "refreshList": "Làm mới danh sách", + "refreshAll": "Làm mới tất cả", + "listRefreshed": "Đã làm mới danh sách.", + "allTasksRefreshed": "Đã làm mới tất cả tài khoản.", + "exportedFile": "Đã xuất sang {path}", + "exportFailed": "Xuất không thành công: {error}", + "refreshFailed": "Làm mới không thành công: {error}", + "selectOrCreateTaskList": "Chọn hoặc tạo một danh sách công việc để bắt đầu.", + "signInToViewTasks": "Đăng nhập để xem công việc.", + "noTasks": "Không có công việc.", + "noTasksYet": "Chưa có công việc", + "noTasksYetMessage": "Tạo một công việc hoặc làm mới tài khoản để bắt đầu.", + "noTasksInList": "Không có công việc nào trong danh sách này.", + "overdue": "Quá hạn", + "today": "Hôm nay", + "tomorrow": "Ngày mai", + "upcoming": "Sắp tới", + "noDate": "Không có ngày", + "completed": "Đã hoàn thành", + "duePrefix": "Đến hạn {date}", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "Chi tiết công việc", + "editTask": "Chỉnh sửa công việc", + "noTaskSelected": "Chưa chọn công việc.", + "noTaskSelectedHelper": "Chọn một công việc để xem và chỉnh sửa chi tiết.", + "taskUnavailable": "Công việc không khả dụng.", + "signInToEditTasks": "Đăng nhập để chỉnh sửa công việc.", + "refreshTask": "Làm mới công việc", + "primarySection": "Chính", + "statusSection": "Trạng thái", + "openStatus": "Chưa hoàn thành", + "doneStatus": "Đã hoàn thành", + "notes": "Ghi chú", + "dueDate": "Ngày đến hạn", + "clearDueDate": "Xóa ngày đến hạn", + "dueTime": "Giờ đến hạn", + "startDate": "Ngày bắt đầu", + "startTime": "Giờ bắt đầu", + "endDate": "Ngày kết thúc", + "endTime": "Giờ kết thúc", + "reminderDate": "Ngày nhắc", + "reminderTime": "Giờ nhắc", + "reminder": "Lời nhắc", + "addReminder": "Thêm lời nhắc", + "addGuest": "Thêm khách mời", + "addGuestEmail": "Thêm email khách mời", + "removeReminder": "Xóa lời nhắc", + "off": "Tắt", + "repeat": "Lặp lại", + "repeatNone": "Không lặp lại", + "noneValue": "Không có", + "repeatDaily": "Hằng ngày", + "repeatWeekly": "Hằng tuần", + "repeatMonthly": "Hằng tháng", + "repeatYearly": "Hằng năm", + "importance": "Mức độ quan trọng", + "importanceLow": "Thấp", + "importanceNormal": "Bình thường", + "importanceHigh": "Cao", + "categories": "Danh mục", + "scheduleSection": "Lịch", + "dueGroup": "Đến hạn", + "startGroup": "Bắt đầu", + "reminderGroup": "Lời nhắc", + "organizationSection": "Sắp xếp", + "actionsSection": "Thao tác", + "advancedSection": "Nâng cao", + "addCategory": "Thêm danh mục", + "list": "Danh sách", + "microsoftMoveUnsupported": "Phiên bản này không hỗ trợ di chuyển công việc giữa các danh sách trong tài khoản Microsoft To Do.", + "createSubtask": "Tạo công việc con", + "moveToTop": "Chuyển lên đầu", + "deleteTask": "Xóa công việc", + "newSubtask": "Công việc con mới", + "deleteTaskConfirmation": "Xóa “{title}” khỏi Google Tasks?", + "metadata": "Siêu dữ liệu", + "id": "ID", + "etag": "ETag", + "updated": "Đã cập nhật", + "parent": "Công việc cha", + "position": "Vị trí", + "webLink": "Liên kết web", + "assignment": "Phân công", + "localState": "Trạng thái cục bộ", + "pendingSync": "Đang chờ đồng bộ", + "synced": "Đã đồng bộ", + "account": "Tài khoản", + "sync": "Đồng bộ", + "manualFullSync": "Đồng bộ toàn bộ thủ công", + "runInBackgroundWhenClosed": "Tiếp tục chạy khi đóng cửa sổ", + "showTrayIcon": "Hiện biểu tượng khay hệ thống", + "startMinimizedToTray": "Khởi động thu nhỏ vào khay hệ thống", + "requiresTrayIcon": "Yêu cầu biểu tượng khay hệ thống.", + "syncComplete": "Đồng bộ hoàn tất.", + "syncFailed": "Đồng bộ không thành công: {error}", + "notifySyncFailures": "Thông báo khi đồng bộ thất bại", + "notifyConflicts": "Thông báo khi có xung đột", + "notifyDueToday": "Thông báo công việc đến hạn hôm nay", + "eventReminders": "Lời nhắc sự kiện", + "taskReminders": "Lời nhắc công việc", + "notificationDetailLevel": "Mức độ chi tiết của thông báo", + "notificationDetailPrivate": "Riêng tư", + "notificationDetailNormal": "Bình thường", + "quietHours": "Giờ yên tĩnh", + "quietHoursDescription": "Tạm dừng thông báo trong khoảng thời gian này.", + "quietHoursStart": "Bắt đầu giờ yên tĩnh", + "quietHoursEnd": "Kết thúc giờ yên tĩnh", + "notifications": "Thông báo", + "appearance": "Giao diện", + "theme": "Chủ đề", + "themeSystem": "Hệ thống", + "themeLight": "Sáng", + "themeDark": "Tối", + "themeFamily": "Họ chủ đề", + "themeFamilyYaru": "Chủ đề Ubuntu nguyên bản (Yaru)", + "localization": "Ngôn ngữ và khu vực", + "currentLocale": "Ngôn ngữ và khu vực hiện tại", + "privacy": "Quyền riêng tư", + "redactTaskContentInDiagnostics": "Ẩn nội dung công việc trong thông tin chẩn đoán", + "developerDiagnostics": "Chẩn đoán dành cho nhà phát triển", + "diagnostics": "Chẩn đoán", + "apiInspectorDisabled": "Hiện trình kiểm tra API", + "googleTasksApi": "API Google Tasks", + "discoveryRevision": "Bản sửa đổi Discovery: {revision}", + "implementedMethods": "Phương thức đã triển khai", + "supportsTasksScopes": "Hỗ trợ phạm vi tasks và tasks.readonly", + "requiresTasksScope": "Yêu cầu phạm vi tasks", + "blockedPendingOperations": "Thao tác đang chờ bị chặn", + "signInToInspectPendingOperations": "Đăng nhập để kiểm tra các thao tác đang chờ.", + "noBlockedPendingOperations": "Không có thao tác đang chờ nào bị chặn.", + "operationActions": "Hành động cho thao tác", + "pendingOpListId": "danh_sách={id}", + "pendingOpTaskId": "công_việc={id}", + "pendingOpAttempts": "số_lần_thử={count}", + "retry": "Thử lại", + "discard": "Hủy bỏ", + "discardChangesAction": "Không lưu", + "discardChanges": "Hủy bỏ thay đổi?", + "discardChangesConfirmation": "Thao tác này sẽ hủy bỏ các chỉnh sửa chưa lưu đối với công việc.", + "retryCompleted": "Đã thử lại.", + "discardPendingOperation": "Hủy bỏ thao tác đang chờ?", + "discardPendingOperationConfirmation": "Thao tác này sẽ xóa thao tác cục bộ bị chặn. Lần đồng bộ tiếp theo sẽ tải lại dữ liệu từ Google Tasks.", + "pendingOperationDiscarded": "Đã hủy bỏ thao tác đang chờ.", + "syncFailureNotificationTitle": "Đồng bộ BusyMax không thành công", + "syncFailureNotificationBody": "Đồng bộ nền không thành công. {message}", + "conflictNotificationTitle": "Xung đột đồng bộ BusyMax", + "conflictNotificationBody": "Một thay đổi cục bộ đang chờ đã bị chặn. {summary}", + "dueTodayNotificationTitle": "Công việc đến hạn hôm nay", + "dueTodayNotificationBody": "{count, plural, =1{Có một công việc đến hạn hôm nay.} other{Có {count} công việc đến hạn hôm nay.}}", + "eventReminderNotificationTitle": "Lời nhắc sự kiện", + "taskReminderNotificationTitle": "Lời nhắc công việc", + "eventReminderNotificationBody": "Sự kiện sắp bắt đầu.", + "taskReminderNotificationBody": "Công việc sắp đến hạn.", + "notificationOpenAction": "Mở", + "notificationDetailsHidden": "Chi tiết bị ẩn theo cài đặt quyền riêng tư.", + "previousMonth": "Tháng trước", + "nextMonth": "Tháng sau", + "openMonthView": "Mở chế độ xem tháng", + "previousYear": "Năm trước", + "nextYear": "Năm sau", + "openYearView": "Mở chế độ xem năm", + "weekNumberTooltip": "Tuần {number}", + "resizeAllDayPanel": "Đổi kích thước bảng cả ngày", + "scheduleItemCount": "{count, plural, =1{1 mục} other{{count} mục}}", + "readOnlyCalendar": "Lịch này chỉ có thể đọc.", + "selectTimeZone": "Chọn múi giờ", + "searchLocations": "Tìm kiếm địa điểm", + "noLocationsFound": "Không tìm thấy địa điểm", + "deleteCalendarConfirmation": "Xóa “{title}”?" +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb new file mode 100644 index 0000000..71eb8a8 --- /dev/null +++ b/lib/l10n/app_zh.arb @@ -0,0 +1,374 @@ +{ + "@@locale": "zh", + "appTitle": "BusyMax", + "connectGoogleAccount": "连接 Google 和 Microsoft 帐户以同步日历和任务。", + "googlePermissionsConsentNotice": "在 Google 权限页面上,同时选择日历和任务权限。", + "googlePermissionsRequiredRetry": "必须授予 Google 日历和 Google Tasks 权限。请重试并选中两个复选框。", + "finishSetup": "完成设置", + "continueSetup": "继续", + "onboardingSetupTitle": "设置 BusyMax", + "onboardingAccountsStepTitle": "连接帐户", + "onboardingAccountsStepDescription": "添加您要使用的所有 Google 和 Microsoft 帐户。BusyMax 会同步每个帐户中的日历、日程、任务列表和任务。", + "onboardingPreferencesStepTitle": "选择系统设置", + "onboardingPreferencesStepDescription": "打开日程前,请设置桌面行为、提醒、通知详细程度和外观。", + "signInWithGoogle": "使用 Google 登录", + "signInWithMicrosoft": "使用 Microsoft 登录", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "尚未配置此服务。", + "waitingForGoogleSignIn": "正在等待 Google 登录...", + "waitingForMicrosoftSignIn": "正在等待 Microsoft 登录...", + "microsoftSignInNotConfigured": "尚未配置 Microsoft 登录。请设置 MICROSOFT_OAUTH_CLIENT_ID。", + "cancel": "取消", + "close": "关闭", + "exit": "退出", + "options": "选项", + "hide": "隐藏", + "show": "显示", + "export": "导出", + "save": "保存", + "settings": "设置", + "all": "全部", + "calendarEvents": "日程", + "calendarTasks": "任务", + "calendar": "日历", + "calendars": "日历", + "newEvent": "新建日程", + "refreshCalendar": "刷新日历", + "openInProvider": "在服务中打开", + "hideFromSchedule": "从日程中隐藏", + "showInSchedule": "在日程中显示", + "noCalendarsSynced": "尚未同步任何日历。", + "allDay": "全天", + "moreItems": "还有 {count} 项", + "noEventsOrTasks": "没有日程或任务", + "scheduleLoading": "正在加载日程...", + "scheduleUnavailable": "日程不可用", + "scheduleNoSources": "没有可见的日历或任务列表", + "scheduleNoSourcesDescription": "请在设置中选择要显示的内容,然后刷新。", + "scheduleSignInRequired": "连接帐户", + "scheduleSignInDescription": "登录以同步日历和任务。", + "scheduleNoSearchResults": "没有匹配的日程或任务", + "scheduleNoSearchResultsDescription": "请尝试其他搜索内容或清除当前筛选条件。", + "refresh": "刷新", + "trayOpenBusyMax": "打开 BusyMax", + "agendaLoadMoreOverdue": "加载更多逾期任务", + "agendaLoadMoreNoDate": "加载更多无日期任务", + "viewDay": "日", + "viewWeek": "周", + "viewMonth": "月", + "viewYear": "年", + "viewAgenda": "日程", + "scheduleSettings": "日程", + "scheduleDisplaySettings": "日程显示", + "scheduleDisplayHoursDescription": "日视图和周视图最初显示此时间范围。需要时,更早或更晚的项目会扩展该范围。", + "scheduleDayStartsAt": "每日开始时间", + "scheduleDayEndsAt": "每日结束时间", + "sourceCalendar": "日历", + "sourceTaskList": "任务列表", + "createChoiceTitle": "新建", + "createEventAtTime": "日程", + "createTaskAtDate": "任务", + "editEvent": "编辑日程", + "eventTitle": "日程标题", + "location": "地点", + "timeSlot": "时间段", + "startDateTime": "开始日期/时间", + "endDateTime": "结束日期/时间", + "doesNotRepeat": "不重复", + "defaultReminder": "默认提醒", + "guests": "参与者", + "noGuests": "没有参与者", + "description": "说明", + "availabilityShowAs": "空闲状态 / 显示为", + "busy": "忙碌", + "visibility": "可见性", + "defaultVisibility": "默认可见性", + "conference": "会议", + "noConference": "无会议", + "providerCalendar": "服务日历", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "粗体", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "斜体", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "下划线", + "reminderMinutesBefore": "{minutes, plural, =1{1 分钟前} other{{minutes} 分钟前}}", + "reminderAtStart": "开始时", + "reminderHoursBefore": "{hours, plural, =1{1 小时前} other{{hours} 小时前}}", + "reminderDaysBefore": "{days, plural, =1{1 天前} other{{days} 天前}}", + "availabilityFree": "空闲", + "availabilityTentative": "暂定", + "availabilityOutOfOffice": "不在办公室", + "availabilityWorkingElsewhere": "在其他地点办公", + "visibilityDefault": "默认", + "visibilityPublic": "公开", + "visibilityPrivate": "私密", + "visibilityConfidential": "机密", + "sensitivityNormal": "普通", + "sensitivityPersonal": "个人", + "tasks": "任务", + "allTasks": "所有任务", + "tasksInList": "{title}中的任务", + "taskLists": "任务列表", + "navigation": "导航", + "mainMenu": "主菜单", + "keyboardShortcuts": "键盘快捷键", + "shortcutGroupGeneral": "常规", + "shortcutKeyboardShortcutsDescription": "显示快捷键参考表", + "shortcutGroupNavigation": "导航", + "shortcutNextPeriod": "下一时段", + "shortcutNextPeriodDescription": "在周视图中前往下一周,在月视图中前往下个月,依此类推", + "shortcutPreviousPeriod": "上一时段", + "shortcutPreviousPeriodDescription": "在周视图中前往上一周,在月视图中前往上个月,依此类推", + "shortcutJumpToToday": "跳转到今天", + "shortcutGroupView": "视图", + "shortcutDayView": "日视图", + "shortcutWeekView": "周视图", + "shortcutMonthView": "月视图", + "shortcutYearView": "年视图", + "shortcutAgendaView": "日程视图", + "shortcutGroupCreateAndEdit": "新建和编辑", + "shortcutSaveItem": "保存日程或任务", + "shortcutDeleteItem": "删除日程或任务", + "shortcutGroupTaskEditing": "任务编辑", + "shortcutCancelEditing": "取消编辑", + "shortcutCancelEditingDescription": "关闭任务编辑或任务详情", + "aboutBusyMax": "关于 BusyMax", + "aboutBusyMaxDescription": "日历和任务", + "license": "许可证", + "apacheLicenseName": "Apache License 2.0", + "website": "网站", + "sourceCode": "源代码", + "reportAnIssue": "报告问题", + "sendFeedback": "发送反馈", + "feedbackSubmit": "提交", + "feedbackCategory": "类别", + "feedbackSelectCategory": "选择类别", + "feedbackCategoryProblem": "问题或错误", + "feedbackCategoryFeature": "功能请求", + "feedbackCategoryPrivacySecurity": "隐私或安全问题", + "feedbackCategoryUsability": "易用性问题", + "feedbackCategoryOther": "其他", + "feedbackSubject": "主题", + "feedbackDetailedMessage": "详细信息", + "feedbackReplyEmail": "用于接收回复的电子邮件地址(可选)", + "feedbackIncludeTechnicalDetails": "包含技术详情", + "feedbackTechnicalDetailsDisclosure": "仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。", + "feedbackCategoryRequired": "请选择类别。", + "feedbackSubjectLengthError": "主题必须为 3 至 120 个字符。", + "feedbackMessageLengthError": "消息必须为 10 至 5,000 个字符。", + "feedbackInvalidEmail": "请输入有效的电子邮件地址。", + "feedbackConnectionError": "无法连接到 BusyStack。请检查连接,然后重试。", + "feedbackTimeoutError": "请求超时。您的反馈尚未清除,请重试。", + "feedbackRateLimitedError": "从此网络发送的反馈过多。请稍后再试。", + "feedbackRejectedError": "服务器拒绝了提交。请检查各字段,然后重试。", + "feedbackServerError": "BusyStack 目前无法接收您的反馈。您的反馈尚未清除,请重试。", + "feedbackSuccess": "反馈已发送。参考编号:{id}", + "toggleSidebar": "显示或隐藏侧边栏", + "showSidebar": "显示侧边栏面板", + "hideSidebar": "隐藏侧边栏面板", + "accounts": "帐户", + "currentAccount": "当前帐户", + "switchAccount": "切换帐户", + "addGoogleAccount": "添加 Google 帐户", + "addMicrosoftAccount": "添加 Microsoft 帐户", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "已登录", + "removeAccount": "移除帐户…", + "removingAccount": "正在移除帐户…", + "removeAccountDescription": "停止同步并从此设备移除此帐户的数据。", + "removeAccountTitle": "从 BusyMax 中移除 {account}?", + "removeAccountConfirmation": "这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将丢失。不会从 Google 或 Microsoft 删除任何内容。", + "revokeGoogleAccess": "同时撤销 BusyMax 对此 Google 帐户的访问权限", + "revokeGoogleAccessDescription": "重新连接之前,您需要再次授予访问权限。", + "removeAccountAction": "移除帐户", + "removeAccountFailed": "无法完成帐户移除。请重试。", + "accountRemovedGoogleRevokeFailed": "该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。", + "newList": "新建列表", + "signInToViewTaskLists": "登录以查看任务列表。", + "noTaskListsSynced": "尚未同步任何任务列表。", + "listActions": "列表操作", + "rename": "重命名", + "delete": "删除", + "renameList": "重命名列表", + "deleteList": "删除列表", + "builtInMicrosoftList": "内置", + "builtInMicrosoftListCannotRenameDelete": "无法重命名或删除 Microsoft To Do 内置列表。", + "deleteListConfirmation": "从 Google Tasks 中删除“{title}”?", + "deleteEvent": "删除日程", + "title": "标题", + "create": "新建", + "newTask": "新建任务", + "clearCompleted": "清除已完成项", + "refreshList": "刷新列表", + "refreshAll": "全部刷新", + "listRefreshed": "列表已刷新。", + "allTasksRefreshed": "所有帐户均已刷新。", + "exportedFile": "已导出到 {path}", + "exportFailed": "导出失败:{error}", + "refreshFailed": "刷新失败:{error}", + "selectOrCreateTaskList": "请选择或创建任务列表以开始使用。", + "signInToViewTasks": "登录以查看任务。", + "noTasks": "没有任务。", + "noTasksYet": "还没有任务", + "noTasksYetMessage": "创建任务或刷新帐户以开始使用。", + "noTasksInList": "此列表中没有任务。", + "overdue": "已逾期", + "today": "今天", + "tomorrow": "明天", + "upcoming": "即将到期", + "noDate": "无日期", + "completed": "已完成", + "duePrefix": "{date} 到期", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "任务详情", + "editTask": "编辑任务", + "noTaskSelected": "未选择任务。", + "noTaskSelectedHelper": "选择任务以查看和编辑详情。", + "taskUnavailable": "任务不可用。", + "signInToEditTasks": "登录以编辑任务。", + "refreshTask": "刷新任务", + "primarySection": "主要信息", + "statusSection": "状态", + "openStatus": "未完成", + "doneStatus": "已完成", + "notes": "备注", + "dueDate": "截止日期", + "clearDueDate": "清除截止日期", + "dueTime": "截止时间", + "startDate": "开始日期", + "startTime": "开始时间", + "endDate": "结束日期", + "endTime": "结束时间", + "reminderDate": "提醒日期", + "reminderTime": "提醒时间", + "reminder": "提醒", + "addReminder": "添加提醒", + "addGuest": "添加参与者", + "addGuestEmail": "添加参与者电子邮件", + "removeReminder": "移除提醒", + "off": "关闭", + "repeat": "重复", + "repeatNone": "不重复", + "noneValue": "无", + "repeatDaily": "每天", + "repeatWeekly": "每周", + "repeatMonthly": "每月", + "repeatYearly": "每年", + "importance": "重要性", + "importanceLow": "低", + "importanceNormal": "普通", + "importanceHigh": "高", + "categories": "类别", + "scheduleSection": "日程", + "dueGroup": "截止", + "startGroup": "开始", + "reminderGroup": "提醒", + "organizationSection": "整理", + "actionsSection": "操作", + "advancedSection": "高级", + "addCategory": "添加类别", + "list": "列表", + "microsoftMoveUnsupported": "此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。", + "createSubtask": "创建子任务", + "moveToTop": "移到顶部", + "deleteTask": "删除任务", + "newSubtask": "新建子任务", + "deleteTaskConfirmation": "从 Google Tasks 中删除“{title}”?", + "metadata": "元数据", + "id": "ID", + "etag": "ETag", + "updated": "更新时间", + "parent": "父任务", + "position": "位置", + "webLink": "网页链接", + "assignment": "分配", + "localState": "本地状态", + "pendingSync": "等待同步", + "synced": "已同步", + "account": "帐户", + "sync": "同步", + "manualFullSync": "手动完整同步", + "runInBackgroundWhenClosed": "窗口关闭后继续在后台运行", + "showTrayIcon": "显示托盘图标", + "startMinimizedToTray": "启动时最小化到托盘", + "requiresTrayIcon": "需要托盘图标。", + "syncComplete": "同步完成。", + "syncFailed": "同步失败:{error}", + "notifySyncFailures": "同步失败通知", + "notifyConflicts": "冲突通知", + "notifyDueToday": "今天到期任务通知", + "eventReminders": "日程提醒", + "taskReminders": "任务提醒", + "notificationDetailLevel": "通知详细程度", + "notificationDetailPrivate": "私密", + "notificationDetailNormal": "普通", + "quietHours": "免打扰时段", + "quietHoursDescription": "在此时段暂停通知。", + "quietHoursStart": "免打扰开始时间", + "quietHoursEnd": "免打扰结束时间", + "notifications": "通知", + "appearance": "外观", + "theme": "主题", + "themeSystem": "系统", + "themeLight": "浅色", + "themeDark": "深色", + "themeFamily": "主题系列", + "themeFamilyYaru": "Ubuntu 原生主题(Yaru)", + "localization": "语言和区域", + "currentLocale": "当前区域设置", + "privacy": "隐私", + "redactTaskContentInDiagnostics": "在诊断信息中隐藏任务内容", + "developerDiagnostics": "开发者诊断", + "diagnostics": "诊断", + "apiInspectorDisabled": "显示 API 检查器", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "Discovery 修订版:{revision}", + "implementedMethods": "已实现的方法", + "supportsTasksScopes": "支持 tasks 和 tasks.readonly 权限范围", + "requiresTasksScope": "需要 tasks 权限范围", + "blockedPendingOperations": "被阻止的待处理操作", + "signInToInspectPendingOperations": "登录以检查待处理操作。", + "noBlockedPendingOperations": "没有被阻止的待处理操作。", + "operationActions": "操作选项", + "pendingOpListId": "列表={id}", + "pendingOpTaskId": "任务={id}", + "pendingOpAttempts": "尝试次数={count}", + "retry": "重试", + "discard": "舍弃", + "discardChangesAction": "舍弃", + "discardChanges": "舍弃更改?", + "discardChangesConfirmation": "这将舍弃对此任务所做的未保存编辑。", + "retryCompleted": "重试完成。", + "discardPendingOperation": "舍弃待处理操作?", + "discardPendingOperationConfirmation": "这将移除被阻止的本地操作。下次同步时将从 Google Tasks 刷新数据。", + "pendingOperationDiscarded": "已舍弃待处理操作。", + "syncFailureNotificationTitle": "BusyMax 同步失败", + "syncFailureNotificationBody": "后台同步失败。{message}", + "conflictNotificationTitle": "BusyMax 同步冲突", + "conflictNotificationBody": "一项待处理的本地更改被阻止。{summary}", + "dueTodayNotificationTitle": "今天到期的任务", + "dueTodayNotificationBody": "{count, plural, =1{今天有 1 项任务到期。} other{今天有 {count} 项任务到期。}}", + "eventReminderNotificationTitle": "日程提醒", + "taskReminderNotificationTitle": "任务提醒", + "eventReminderNotificationBody": "日程即将开始。", + "taskReminderNotificationBody": "任务即将到期。", + "notificationOpenAction": "打开", + "notificationDetailsHidden": "根据隐私设置,详细信息已隐藏。", + "previousMonth": "上个月", + "nextMonth": "下个月", + "openMonthView": "打开月视图", + "previousYear": "上一年", + "nextYear": "下一年", + "openYearView": "打开年视图", + "weekNumberTooltip": "第 {number} 周", + "resizeAllDayPanel": "调整全天面板的大小", + "scheduleItemCount": "{count, plural, =1{1 项} other{{count} 项}}", + "readOnlyCalendar": "此日历为只读。", + "selectTimeZone": "选择时区", + "searchLocations": "搜索地点", + "noLocationsFound": "未找到地点", + "deleteCalendarConfirmation": "删除“{title}”?" +} diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb new file mode 100644 index 0000000..75338a1 --- /dev/null +++ b/lib/l10n/app_zh_Hans.arb @@ -0,0 +1,374 @@ +{ + "@@locale": "zh_Hans", + "appTitle": "BusyMax", + "connectGoogleAccount": "连接 Google 和 Microsoft 帐户以同步日历和任务。", + "googlePermissionsConsentNotice": "在 Google 权限页面上,同时选择日历和任务权限。", + "googlePermissionsRequiredRetry": "必须授予 Google 日历和 Google Tasks 权限。请重试并选中两个复选框。", + "finishSetup": "完成设置", + "continueSetup": "继续", + "onboardingSetupTitle": "设置 BusyMax", + "onboardingAccountsStepTitle": "连接帐户", + "onboardingAccountsStepDescription": "添加您要使用的所有 Google 和 Microsoft 帐户。BusyMax 会同步每个帐户中的日历、日程、任务列表和任务。", + "onboardingPreferencesStepTitle": "选择系统设置", + "onboardingPreferencesStepDescription": "打开日程前,请设置桌面行为、提醒、通知详细程度和外观。", + "signInWithGoogle": "使用 Google 登录", + "signInWithMicrosoft": "使用 Microsoft 登录", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "尚未配置此服务。", + "waitingForGoogleSignIn": "正在等待 Google 登录...", + "waitingForMicrosoftSignIn": "正在等待 Microsoft 登录...", + "microsoftSignInNotConfigured": "尚未配置 Microsoft 登录。请设置 MICROSOFT_OAUTH_CLIENT_ID。", + "cancel": "取消", + "close": "关闭", + "exit": "退出", + "options": "选项", + "hide": "隐藏", + "show": "显示", + "export": "导出", + "save": "保存", + "settings": "设置", + "all": "全部", + "calendarEvents": "日程", + "calendarTasks": "任务", + "calendar": "日历", + "calendars": "日历", + "newEvent": "新建日程", + "refreshCalendar": "刷新日历", + "openInProvider": "在服务中打开", + "hideFromSchedule": "从日程中隐藏", + "showInSchedule": "在日程中显示", + "noCalendarsSynced": "尚未同步任何日历。", + "allDay": "全天", + "moreItems": "还有 {count} 项", + "noEventsOrTasks": "没有日程或任务", + "scheduleLoading": "正在加载日程...", + "scheduleUnavailable": "日程不可用", + "scheduleNoSources": "没有可见的日历或任务列表", + "scheduleNoSourcesDescription": "请在设置中选择要显示的内容,然后刷新。", + "scheduleSignInRequired": "连接帐户", + "scheduleSignInDescription": "登录以同步日历和任务。", + "scheduleNoSearchResults": "没有匹配的日程或任务", + "scheduleNoSearchResultsDescription": "请尝试其他搜索内容或清除当前筛选条件。", + "refresh": "刷新", + "trayOpenBusyMax": "打开 BusyMax", + "agendaLoadMoreOverdue": "加载更多逾期任务", + "agendaLoadMoreNoDate": "加载更多无日期任务", + "viewDay": "日", + "viewWeek": "周", + "viewMonth": "月", + "viewYear": "年", + "viewAgenda": "日程", + "scheduleSettings": "日程", + "scheduleDisplaySettings": "日程显示", + "scheduleDisplayHoursDescription": "日视图和周视图最初显示此时间范围。需要时,更早或更晚的项目会扩展该范围。", + "scheduleDayStartsAt": "每日开始时间", + "scheduleDayEndsAt": "每日结束时间", + "sourceCalendar": "日历", + "sourceTaskList": "任务列表", + "createChoiceTitle": "新建", + "createEventAtTime": "日程", + "createTaskAtDate": "任务", + "editEvent": "编辑日程", + "eventTitle": "日程标题", + "location": "地点", + "timeSlot": "时间段", + "startDateTime": "开始日期/时间", + "endDateTime": "结束日期/时间", + "doesNotRepeat": "不重复", + "defaultReminder": "默认提醒", + "guests": "参与者", + "noGuests": "没有参与者", + "description": "说明", + "availabilityShowAs": "空闲状态 / 显示为", + "busy": "忙碌", + "visibility": "可见性", + "defaultVisibility": "默认可见性", + "conference": "会议", + "noConference": "无会议", + "providerCalendar": "服务日历", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "粗体", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "斜体", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "下划线", + "reminderMinutesBefore": "{minutes, plural, =1{1 分钟前} other{{minutes} 分钟前}}", + "reminderAtStart": "开始时", + "reminderHoursBefore": "{hours, plural, =1{1 小时前} other{{hours} 小时前}}", + "reminderDaysBefore": "{days, plural, =1{1 天前} other{{days} 天前}}", + "availabilityFree": "空闲", + "availabilityTentative": "暂定", + "availabilityOutOfOffice": "不在办公室", + "availabilityWorkingElsewhere": "在其他地点办公", + "visibilityDefault": "默认", + "visibilityPublic": "公开", + "visibilityPrivate": "私密", + "visibilityConfidential": "机密", + "sensitivityNormal": "普通", + "sensitivityPersonal": "个人", + "tasks": "任务", + "allTasks": "所有任务", + "tasksInList": "{title}中的任务", + "taskLists": "任务列表", + "navigation": "导航", + "mainMenu": "主菜单", + "keyboardShortcuts": "键盘快捷键", + "shortcutGroupGeneral": "常规", + "shortcutKeyboardShortcutsDescription": "显示快捷键参考表", + "shortcutGroupNavigation": "导航", + "shortcutNextPeriod": "下一时段", + "shortcutNextPeriodDescription": "在周视图中前往下一周,在月视图中前往下个月,依此类推", + "shortcutPreviousPeriod": "上一时段", + "shortcutPreviousPeriodDescription": "在周视图中前往上一周,在月视图中前往上个月,依此类推", + "shortcutJumpToToday": "跳转到今天", + "shortcutGroupView": "视图", + "shortcutDayView": "日视图", + "shortcutWeekView": "周视图", + "shortcutMonthView": "月视图", + "shortcutYearView": "年视图", + "shortcutAgendaView": "日程视图", + "shortcutGroupCreateAndEdit": "新建和编辑", + "shortcutSaveItem": "保存日程或任务", + "shortcutDeleteItem": "删除日程或任务", + "shortcutGroupTaskEditing": "任务编辑", + "shortcutCancelEditing": "取消编辑", + "shortcutCancelEditingDescription": "关闭任务编辑或任务详情", + "aboutBusyMax": "关于 BusyMax", + "aboutBusyMaxDescription": "日历和任务", + "license": "许可证", + "apacheLicenseName": "Apache License 2.0", + "website": "网站", + "sourceCode": "源代码", + "reportAnIssue": "报告问题", + "sendFeedback": "发送反馈", + "feedbackSubmit": "提交", + "feedbackCategory": "类别", + "feedbackSelectCategory": "选择类别", + "feedbackCategoryProblem": "问题或错误", + "feedbackCategoryFeature": "功能请求", + "feedbackCategoryPrivacySecurity": "隐私或安全问题", + "feedbackCategoryUsability": "易用性问题", + "feedbackCategoryOther": "其他", + "feedbackSubject": "主题", + "feedbackDetailedMessage": "详细信息", + "feedbackReplyEmail": "用于接收回复的电子邮件地址(可选)", + "feedbackIncludeTechnicalDetails": "包含技术详情", + "feedbackTechnicalDetailsDisclosure": "仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。", + "feedbackCategoryRequired": "请选择类别。", + "feedbackSubjectLengthError": "主题必须为 3 至 120 个字符。", + "feedbackMessageLengthError": "消息必须为 10 至 5,000 个字符。", + "feedbackInvalidEmail": "请输入有效的电子邮件地址。", + "feedbackConnectionError": "无法连接到 BusyStack。请检查连接,然后重试。", + "feedbackTimeoutError": "请求超时。您的反馈尚未清除,请重试。", + "feedbackRateLimitedError": "从此网络发送的反馈过多。请稍后再试。", + "feedbackRejectedError": "服务器拒绝了提交。请检查各字段,然后重试。", + "feedbackServerError": "BusyStack 目前无法接收您的反馈。您的反馈尚未清除,请重试。", + "feedbackSuccess": "反馈已发送。参考编号:{id}", + "toggleSidebar": "显示或隐藏侧边栏", + "showSidebar": "显示侧边栏面板", + "hideSidebar": "隐藏侧边栏面板", + "accounts": "帐户", + "currentAccount": "当前帐户", + "switchAccount": "切换帐户", + "addGoogleAccount": "添加 Google 帐户", + "addMicrosoftAccount": "添加 Microsoft 帐户", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "已登录", + "removeAccount": "移除帐户…", + "removingAccount": "正在移除帐户…", + "removeAccountDescription": "停止同步并从此设备移除此帐户的数据。", + "removeAccountTitle": "从 BusyMax 中移除 {account}?", + "removeAccountConfirmation": "这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将丢失。不会从 Google 或 Microsoft 删除任何内容。", + "revokeGoogleAccess": "同时撤销 BusyMax 对此 Google 帐户的访问权限", + "revokeGoogleAccessDescription": "重新连接之前,您需要再次授予访问权限。", + "removeAccountAction": "移除帐户", + "removeAccountFailed": "无法完成帐户移除。请重试。", + "accountRemovedGoogleRevokeFailed": "该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。", + "newList": "新建列表", + "signInToViewTaskLists": "登录以查看任务列表。", + "noTaskListsSynced": "尚未同步任何任务列表。", + "listActions": "列表操作", + "rename": "重命名", + "delete": "删除", + "renameList": "重命名列表", + "deleteList": "删除列表", + "builtInMicrosoftList": "内置", + "builtInMicrosoftListCannotRenameDelete": "无法重命名或删除 Microsoft To Do 内置列表。", + "deleteListConfirmation": "从 Google Tasks 中删除“{title}”?", + "deleteEvent": "删除日程", + "title": "标题", + "create": "新建", + "newTask": "新建任务", + "clearCompleted": "清除已完成项", + "refreshList": "刷新列表", + "refreshAll": "全部刷新", + "listRefreshed": "列表已刷新。", + "allTasksRefreshed": "所有帐户均已刷新。", + "exportedFile": "已导出到 {path}", + "exportFailed": "导出失败:{error}", + "refreshFailed": "刷新失败:{error}", + "selectOrCreateTaskList": "请选择或创建任务列表以开始使用。", + "signInToViewTasks": "登录以查看任务。", + "noTasks": "没有任务。", + "noTasksYet": "还没有任务", + "noTasksYetMessage": "创建任务或刷新帐户以开始使用。", + "noTasksInList": "此列表中没有任务。", + "overdue": "已逾期", + "today": "今天", + "tomorrow": "明天", + "upcoming": "即将到期", + "noDate": "无日期", + "completed": "已完成", + "duePrefix": "{date} 到期", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "任务详情", + "editTask": "编辑任务", + "noTaskSelected": "未选择任务。", + "noTaskSelectedHelper": "选择任务以查看和编辑详情。", + "taskUnavailable": "任务不可用。", + "signInToEditTasks": "登录以编辑任务。", + "refreshTask": "刷新任务", + "primarySection": "主要信息", + "statusSection": "状态", + "openStatus": "未完成", + "doneStatus": "已完成", + "notes": "备注", + "dueDate": "截止日期", + "clearDueDate": "清除截止日期", + "dueTime": "截止时间", + "startDate": "开始日期", + "startTime": "开始时间", + "endDate": "结束日期", + "endTime": "结束时间", + "reminderDate": "提醒日期", + "reminderTime": "提醒时间", + "reminder": "提醒", + "addReminder": "添加提醒", + "addGuest": "添加参与者", + "addGuestEmail": "添加参与者电子邮件", + "removeReminder": "移除提醒", + "off": "关闭", + "repeat": "重复", + "repeatNone": "不重复", + "noneValue": "无", + "repeatDaily": "每天", + "repeatWeekly": "每周", + "repeatMonthly": "每月", + "repeatYearly": "每年", + "importance": "重要性", + "importanceLow": "低", + "importanceNormal": "普通", + "importanceHigh": "高", + "categories": "类别", + "scheduleSection": "日程", + "dueGroup": "截止", + "startGroup": "开始", + "reminderGroup": "提醒", + "organizationSection": "整理", + "actionsSection": "操作", + "advancedSection": "高级", + "addCategory": "添加类别", + "list": "列表", + "microsoftMoveUnsupported": "此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。", + "createSubtask": "创建子任务", + "moveToTop": "移到顶部", + "deleteTask": "删除任务", + "newSubtask": "新建子任务", + "deleteTaskConfirmation": "从 Google Tasks 中删除“{title}”?", + "metadata": "元数据", + "id": "ID", + "etag": "ETag", + "updated": "更新时间", + "parent": "父任务", + "position": "位置", + "webLink": "网页链接", + "assignment": "分配", + "localState": "本地状态", + "pendingSync": "等待同步", + "synced": "已同步", + "account": "帐户", + "sync": "同步", + "manualFullSync": "手动完整同步", + "runInBackgroundWhenClosed": "窗口关闭后继续在后台运行", + "showTrayIcon": "显示托盘图标", + "startMinimizedToTray": "启动时最小化到托盘", + "requiresTrayIcon": "需要托盘图标。", + "syncComplete": "同步完成。", + "syncFailed": "同步失败:{error}", + "notifySyncFailures": "同步失败通知", + "notifyConflicts": "冲突通知", + "notifyDueToday": "今天到期任务通知", + "eventReminders": "日程提醒", + "taskReminders": "任务提醒", + "notificationDetailLevel": "通知详细程度", + "notificationDetailPrivate": "私密", + "notificationDetailNormal": "普通", + "quietHours": "免打扰时段", + "quietHoursDescription": "在此时段暂停通知。", + "quietHoursStart": "免打扰开始时间", + "quietHoursEnd": "免打扰结束时间", + "notifications": "通知", + "appearance": "外观", + "theme": "主题", + "themeSystem": "系统", + "themeLight": "浅色", + "themeDark": "深色", + "themeFamily": "主题系列", + "themeFamilyYaru": "Ubuntu 原生主题(Yaru)", + "localization": "语言和区域", + "currentLocale": "当前区域设置", + "privacy": "隐私", + "redactTaskContentInDiagnostics": "在诊断信息中隐藏任务内容", + "developerDiagnostics": "开发者诊断", + "diagnostics": "诊断", + "apiInspectorDisabled": "显示 API 检查器", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "Discovery 修订版:{revision}", + "implementedMethods": "已实现的方法", + "supportsTasksScopes": "支持 tasks 和 tasks.readonly 权限范围", + "requiresTasksScope": "需要 tasks 权限范围", + "blockedPendingOperations": "被阻止的待处理操作", + "signInToInspectPendingOperations": "登录以检查待处理操作。", + "noBlockedPendingOperations": "没有被阻止的待处理操作。", + "operationActions": "操作选项", + "pendingOpListId": "列表={id}", + "pendingOpTaskId": "任务={id}", + "pendingOpAttempts": "尝试次数={count}", + "retry": "重试", + "discard": "舍弃", + "discardChangesAction": "舍弃", + "discardChanges": "舍弃更改?", + "discardChangesConfirmation": "这将舍弃对此任务所做的未保存编辑。", + "retryCompleted": "重试完成。", + "discardPendingOperation": "舍弃待处理操作?", + "discardPendingOperationConfirmation": "这将移除被阻止的本地操作。下次同步时将从 Google Tasks 刷新数据。", + "pendingOperationDiscarded": "已舍弃待处理操作。", + "syncFailureNotificationTitle": "BusyMax 同步失败", + "syncFailureNotificationBody": "后台同步失败。{message}", + "conflictNotificationTitle": "BusyMax 同步冲突", + "conflictNotificationBody": "一项待处理的本地更改被阻止。{summary}", + "dueTodayNotificationTitle": "今天到期的任务", + "dueTodayNotificationBody": "{count, plural, =1{今天有 1 项任务到期。} other{今天有 {count} 项任务到期。}}", + "eventReminderNotificationTitle": "日程提醒", + "taskReminderNotificationTitle": "任务提醒", + "eventReminderNotificationBody": "日程即将开始。", + "taskReminderNotificationBody": "任务即将到期。", + "notificationOpenAction": "打开", + "notificationDetailsHidden": "根据隐私设置,详细信息已隐藏。", + "previousMonth": "上个月", + "nextMonth": "下个月", + "openMonthView": "打开月视图", + "previousYear": "上一年", + "nextYear": "下一年", + "openYearView": "打开年视图", + "weekNumberTooltip": "第 {number} 周", + "resizeAllDayPanel": "调整全天面板的大小", + "scheduleItemCount": "{count, plural, =1{1 项} other{{count} 项}}", + "readOnlyCalendar": "此日历为只读。", + "selectTimeZone": "选择时区", + "searchLocations": "搜索地点", + "noLocationsFound": "未找到地点", + "deleteCalendarConfirmation": "删除“{title}”?" +} diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb new file mode 100644 index 0000000..778d8cc --- /dev/null +++ b/lib/l10n/app_zh_Hant.arb @@ -0,0 +1,374 @@ +{ + "@@locale": "zh_Hant", + "appTitle": "BusyMax", + "connectGoogleAccount": "連結 Google 和 Microsoft 帳戶以同步行事曆和待辦事項。", + "googlePermissionsConsentNotice": "在 Google 權限畫面中,同時選取行事曆和待辦事項權限。", + "googlePermissionsRequiredRetry": "必須授予 Google 日曆和 Google Tasks 權限。請再試一次並勾選兩個核取方塊。", + "finishSetup": "完成設定", + "continueSetup": "繼續", + "onboardingSetupTitle": "設定 BusyMax", + "onboardingAccountsStepTitle": "連結帳戶", + "onboardingAccountsStepDescription": "新增您要使用的所有 Google 和 Microsoft 帳戶。BusyMax 會同步每個帳戶中的行事曆、活動、待辦清單和待辦事項。", + "onboardingPreferencesStepTitle": "選擇系統設定", + "onboardingPreferencesStepDescription": "開啟行程前,請設定桌面行為、提醒、通知詳細程度和外觀。", + "signInWithGoogle": "使用 Google 登入", + "signInWithMicrosoft": "使用 Microsoft 登入", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "尚未設定此服務。", + "waitingForGoogleSignIn": "正在等候 Google 登入...", + "waitingForMicrosoftSignIn": "正在等候 Microsoft 登入...", + "microsoftSignInNotConfigured": "尚未設定 Microsoft 登入。請設定 MICROSOFT_OAUTH_CLIENT_ID。", + "cancel": "取消", + "close": "關閉", + "exit": "結束", + "options": "選項", + "hide": "隱藏", + "show": "顯示", + "export": "匯出", + "save": "儲存", + "settings": "設定", + "all": "全部", + "calendarEvents": "活動", + "calendarTasks": "待辦事項", + "calendar": "行事曆", + "calendars": "行事曆", + "newEvent": "新增活動", + "refreshCalendar": "重新整理行事曆", + "openInProvider": "在服務中開啟", + "hideFromSchedule": "從行程中隱藏", + "showInSchedule": "在行程中顯示", + "noCalendarsSynced": "尚未同步任何行事曆。", + "allDay": "全天", + "moreItems": "還有 {count} 項", + "noEventsOrTasks": "沒有活動或待辦事項", + "scheduleLoading": "正在載入行程...", + "scheduleUnavailable": "無法使用行程", + "scheduleNoSources": "沒有可見的行事曆或待辦清單", + "scheduleNoSourcesDescription": "請在設定中選擇要顯示的內容,然後重新整理。", + "scheduleSignInRequired": "連結帳戶", + "scheduleSignInDescription": "登入以同步行事曆和待辦事項。", + "scheduleNoSearchResults": "沒有相符的活動或待辦事項", + "scheduleNoSearchResultsDescription": "請嘗試其他搜尋內容或清除目前的篩選條件。", + "refresh": "重新整理", + "trayOpenBusyMax": "開啟 BusyMax", + "agendaLoadMoreOverdue": "載入更多逾期待辦事項", + "agendaLoadMoreNoDate": "載入更多無日期待辦事項", + "viewDay": "日", + "viewWeek": "週", + "viewMonth": "月", + "viewYear": "年", + "viewAgenda": "行程", + "scheduleSettings": "行程", + "scheduleDisplaySettings": "行程顯示", + "scheduleDisplayHoursDescription": "日檢視和週檢視一開始會顯示此時間範圍。必要時,較早或較晚的項目會擴大此範圍。", + "scheduleDayStartsAt": "每日開始時間", + "scheduleDayEndsAt": "每日結束時間", + "sourceCalendar": "行事曆", + "sourceTaskList": "待辦清單", + "createChoiceTitle": "新增", + "createEventAtTime": "活動", + "createTaskAtDate": "待辦事項", + "editEvent": "編輯活動", + "eventTitle": "活動標題", + "location": "地點", + "timeSlot": "時段", + "startDateTime": "開始日期/時間", + "endDateTime": "結束日期/時間", + "doesNotRepeat": "不重複", + "defaultReminder": "預設提醒", + "guests": "參與者", + "noGuests": "沒有參與者", + "description": "說明", + "availabilityShowAs": "空閒狀態 / 顯示為", + "busy": "忙碌", + "visibility": "顯示設定", + "defaultVisibility": "預設顯示設定", + "conference": "會議", + "noConference": "無會議", + "providerCalendar": "服務行事曆", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "粗體", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "斜體", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "底線", + "reminderMinutesBefore": "{minutes, plural, =1{1 分鐘前} other{{minutes} 分鐘前}}", + "reminderAtStart": "開始時", + "reminderHoursBefore": "{hours, plural, =1{1 小時前} other{{hours} 小時前}}", + "reminderDaysBefore": "{days, plural, =1{1 天前} other{{days} 天前}}", + "availabilityFree": "有空", + "availabilityTentative": "暫定", + "availabilityOutOfOffice": "不在辦公室", + "availabilityWorkingElsewhere": "在其他地點工作", + "visibilityDefault": "預設", + "visibilityPublic": "公開", + "visibilityPrivate": "私人", + "visibilityConfidential": "機密", + "sensitivityNormal": "一般", + "sensitivityPersonal": "個人", + "tasks": "待辦事項", + "allTasks": "所有待辦事項", + "tasksInList": "{title}中的待辦事項", + "taskLists": "待辦清單", + "navigation": "導覽", + "mainMenu": "主選單", + "keyboardShortcuts": "鍵盤快速鍵", + "shortcutGroupGeneral": "一般", + "shortcutKeyboardShortcutsDescription": "顯示快速鍵參考表", + "shortcutGroupNavigation": "導覽", + "shortcutNextPeriod": "下一時段", + "shortcutNextPeriodDescription": "在週檢視中前往下一週,在月檢視中前往下個月,依此類推", + "shortcutPreviousPeriod": "上一時段", + "shortcutPreviousPeriodDescription": "在週檢視中前往上一週,在月檢視中前往上個月,依此類推", + "shortcutJumpToToday": "跳至今天", + "shortcutGroupView": "檢視", + "shortcutDayView": "日檢視", + "shortcutWeekView": "週檢視", + "shortcutMonthView": "月檢視", + "shortcutYearView": "年檢視", + "shortcutAgendaView": "行程檢視", + "shortcutGroupCreateAndEdit": "新增和編輯", + "shortcutSaveItem": "儲存活動或待辦事項", + "shortcutDeleteItem": "刪除活動或待辦事項", + "shortcutGroupTaskEditing": "待辦事項編輯", + "shortcutCancelEditing": "取消編輯", + "shortcutCancelEditingDescription": "關閉待辦事項編輯或詳細資料", + "aboutBusyMax": "關於 BusyMax", + "aboutBusyMaxDescription": "行事曆與待辦事項", + "license": "授權", + "apacheLicenseName": "Apache License 2.0", + "website": "網站", + "sourceCode": "原始碼", + "reportAnIssue": "回報問題", + "sendFeedback": "傳送意見", + "feedbackSubmit": "提交", + "feedbackCategory": "類別", + "feedbackSelectCategory": "選擇類別", + "feedbackCategoryProblem": "問題或錯誤", + "feedbackCategoryFeature": "功能請求", + "feedbackCategoryPrivacySecurity": "隱私權或安全性疑慮", + "feedbackCategoryUsability": "易用性疑慮", + "feedbackCategoryOther": "其他", + "feedbackSubject": "主旨", + "feedbackDetailedMessage": "詳細訊息", + "feedbackReplyEmail": "回覆用電子郵件地址(選填)", + "feedbackIncludeTechnicalDetails": "包含技術詳細資料", + "feedbackTechnicalDetailsDisclosure": "只會加入您的 Linux 作業系統版本和應用程式語系。不會包含記錄、帳戶資料、檔案名稱或其他診斷資訊。", + "feedbackCategoryRequired": "請選擇類別。", + "feedbackSubjectLengthError": "主旨必須介於 3 到 120 個字元之間。", + "feedbackMessageLengthError": "訊息必須介於 10 到 5,000 個字元之間。", + "feedbackInvalidEmail": "請輸入有效的電子郵件地址。", + "feedbackConnectionError": "無法連線至 BusyStack。請檢查連線,然後再試一次。", + "feedbackTimeoutError": "要求逾時。您的意見尚未清除,請再試一次。", + "feedbackRateLimitedError": "此網路已傳送太多意見。請稍候再試。", + "feedbackRejectedError": "伺服器拒絕了提交內容。請檢查各欄位,然後再試一次。", + "feedbackServerError": "BusyStack 目前無法接收您的意見。您的意見尚未清除,請再試一次。", + "feedbackSuccess": "意見已傳送。參考編號:{id}", + "toggleSidebar": "顯示或隱藏側邊欄", + "showSidebar": "顯示側邊欄面板", + "hideSidebar": "隱藏側邊欄面板", + "accounts": "帳戶", + "currentAccount": "目前帳戶", + "switchAccount": "切換帳戶", + "addGoogleAccount": "新增 Google 帳戶", + "addMicrosoftAccount": "新增 Microsoft 帳戶", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "已登入", + "removeAccount": "移除帳戶…", + "removingAccount": "正在移除帳戶…", + "removeAccountDescription": "停止同步並從此裝置移除此帳戶的資料。", + "removeAccountTitle": "要從 BusyMax 移除 {account} 嗎?", + "removeAccountConfirmation": "這會從此裝置刪除快取的待辦事項、行事曆、活動、提醒和待處理的離線變更。未同步的變更將會遺失。不會從 Google 或 Microsoft 刪除任何內容。", + "revokeGoogleAccess": "同時撤銷 BusyMax 對此 Google 帳戶的存取權", + "revokeGoogleAccessDescription": "重新連結前,您必須再次授予存取權。", + "removeAccountAction": "移除帳戶", + "removeAccountFailed": "無法完成帳戶移除。請再試一次。", + "accountRemovedGoogleRevokeFailed": "該帳戶已從此裝置移除,但無法撤銷 BusyMax 對您的 Google 帳戶的存取權。您可以在 Google 帳戶中手動撤銷該權限。", + "newList": "新增清單", + "signInToViewTaskLists": "登入以查看待辦清單。", + "noTaskListsSynced": "尚未同步任何待辦清單。", + "listActions": "清單動作", + "rename": "重新命名", + "delete": "刪除", + "renameList": "重新命名清單", + "deleteList": "刪除清單", + "builtInMicrosoftList": "內建", + "builtInMicrosoftListCannotRenameDelete": "無法重新命名或刪除 Microsoft To Do 內建清單。", + "deleteListConfirmation": "要從 Google Tasks 刪除「{title}」嗎?", + "deleteEvent": "刪除活動", + "title": "標題", + "create": "新增", + "newTask": "新增待辦事項", + "clearCompleted": "清除已完成項目", + "refreshList": "重新整理清單", + "refreshAll": "全部重新整理", + "listRefreshed": "清單已重新整理。", + "allTasksRefreshed": "所有帳戶都已重新整理。", + "exportedFile": "已匯出至 {path}", + "exportFailed": "匯出失敗:{error}", + "refreshFailed": "重新整理失敗:{error}", + "selectOrCreateTaskList": "請選擇或建立待辦清單以開始使用。", + "signInToViewTasks": "登入以查看待辦事項。", + "noTasks": "沒有待辦事項。", + "noTasksYet": "還沒有待辦事項", + "noTasksYetMessage": "建立待辦事項或重新整理帳戶以開始使用。", + "noTasksInList": "此清單中沒有待辦事項。", + "overdue": "已逾期", + "today": "今天", + "tomorrow": "明天", + "upcoming": "即將到期", + "noDate": "無日期", + "completed": "已完成", + "duePrefix": "{date} 到期", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "待辦事項詳細資料", + "editTask": "編輯待辦事項", + "noTaskSelected": "未選取待辦事項。", + "noTaskSelectedHelper": "選擇待辦事項以查看和編輯詳細資料。", + "taskUnavailable": "無法使用待辦事項。", + "signInToEditTasks": "登入以編輯待辦事項。", + "refreshTask": "重新整理待辦事項", + "primarySection": "主要資訊", + "statusSection": "狀態", + "openStatus": "未完成", + "doneStatus": "已完成", + "notes": "備註", + "dueDate": "到期日", + "clearDueDate": "清除到期日", + "dueTime": "到期時間", + "startDate": "開始日期", + "startTime": "開始時間", + "endDate": "結束日期", + "endTime": "結束時間", + "reminderDate": "提醒日期", + "reminderTime": "提醒時間", + "reminder": "提醒", + "addReminder": "新增提醒", + "addGuest": "新增參與者", + "addGuestEmail": "新增參與者電子郵件", + "removeReminder": "移除提醒", + "off": "關閉", + "repeat": "重複", + "repeatNone": "不重複", + "noneValue": "無", + "repeatDaily": "每天", + "repeatWeekly": "每週", + "repeatMonthly": "每月", + "repeatYearly": "每年", + "importance": "重要性", + "importanceLow": "低", + "importanceNormal": "一般", + "importanceHigh": "高", + "categories": "類別", + "scheduleSection": "行程", + "dueGroup": "到期", + "startGroup": "開始", + "reminderGroup": "提醒", + "organizationSection": "整理", + "actionsSection": "動作", + "advancedSection": "進階", + "addCategory": "新增類別", + "list": "清單", + "microsoftMoveUnsupported": "此版本不支援在 Microsoft To Do 帳戶的清單之間移動待辦事項。", + "createSubtask": "建立子待辦事項", + "moveToTop": "移至頂端", + "deleteTask": "刪除待辦事項", + "newSubtask": "新增子待辦事項", + "deleteTaskConfirmation": "要從 Google Tasks 刪除「{title}」嗎?", + "metadata": "中繼資料", + "id": "ID", + "etag": "ETag", + "updated": "更新時間", + "parent": "上層待辦事項", + "position": "位置", + "webLink": "網頁連結", + "assignment": "指派", + "localState": "本機狀態", + "pendingSync": "等候同步", + "synced": "已同步", + "account": "帳戶", + "sync": "同步", + "manualFullSync": "手動完整同步", + "runInBackgroundWhenClosed": "視窗關閉後繼續在背景執行", + "showTrayIcon": "顯示系統匣圖示", + "startMinimizedToTray": "啟動時最小化至系統匣", + "requiresTrayIcon": "需要系統匣圖示。", + "syncComplete": "同步完成。", + "syncFailed": "同步失敗:{error}", + "notifySyncFailures": "同步失敗通知", + "notifyConflicts": "衝突通知", + "notifyDueToday": "今天到期待辦事項通知", + "eventReminders": "活動提醒", + "taskReminders": "待辦事項提醒", + "notificationDetailLevel": "通知詳細程度", + "notificationDetailPrivate": "私人", + "notificationDetailNormal": "一般", + "quietHours": "勿擾時段", + "quietHoursDescription": "在此時段暫停通知。", + "quietHoursStart": "勿擾開始時間", + "quietHoursEnd": "勿擾結束時間", + "notifications": "通知", + "appearance": "外觀", + "theme": "主題", + "themeSystem": "系統", + "themeLight": "淺色", + "themeDark": "深色", + "themeFamily": "主題系列", + "themeFamilyYaru": "Ubuntu 原生主題(Yaru)", + "localization": "語言與地區", + "currentLocale": "目前語系", + "privacy": "隱私權", + "redactTaskContentInDiagnostics": "在診斷資訊中隱藏待辦事項內容", + "developerDiagnostics": "開發人員診斷", + "diagnostics": "診斷", + "apiInspectorDisabled": "顯示 API 檢查器", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "Discovery 修訂版本:{revision}", + "implementedMethods": "已實作的方法", + "supportsTasksScopes": "支援 tasks 和 tasks.readonly 權限範圍", + "requiresTasksScope": "需要 tasks 權限範圍", + "blockedPendingOperations": "遭封鎖的待處理作業", + "signInToInspectPendingOperations": "登入以檢查待處理作業。", + "noBlockedPendingOperations": "沒有遭封鎖的待處理作業。", + "operationActions": "操作選項", + "pendingOpListId": "清單={id}", + "pendingOpTaskId": "待辦事項={id}", + "pendingOpAttempts": "嘗試次數={count}", + "retry": "再試一次", + "discard": "捨棄", + "discardChangesAction": "捨棄", + "discardChanges": "要捨棄變更嗎?", + "discardChangesConfirmation": "這會捨棄此待辦事項中尚未儲存的編輯內容。", + "retryCompleted": "重試完成。", + "discardPendingOperation": "要捨棄待處理作業嗎?", + "discardPendingOperationConfirmation": "這會移除遭封鎖的本機作業。下次同步時將從 Google Tasks 重新整理資料。", + "pendingOperationDiscarded": "已捨棄待處理作業。", + "syncFailureNotificationTitle": "BusyMax 同步失敗", + "syncFailureNotificationBody": "背景同步失敗。{message}", + "conflictNotificationTitle": "BusyMax 同步衝突", + "conflictNotificationBody": "一項待處理的本機變更遭到封鎖。{summary}", + "dueTodayNotificationTitle": "今天到期的待辦事項", + "dueTodayNotificationBody": "{count, plural, =1{今天有 1 項待辦事項到期。} other{今天有 {count} 項待辦事項到期。}}", + "eventReminderNotificationTitle": "活動提醒", + "taskReminderNotificationTitle": "待辦事項提醒", + "eventReminderNotificationBody": "活動即將開始。", + "taskReminderNotificationBody": "待辦事項即將到期。", + "notificationOpenAction": "開啟", + "notificationDetailsHidden": "根據隱私權設定,詳細資料已隱藏。", + "previousMonth": "上個月", + "nextMonth": "下個月", + "openMonthView": "開啟月檢視", + "previousYear": "上一年", + "nextYear": "下一年", + "openYearView": "開啟年檢視", + "weekNumberTooltip": "第 {number} 週", + "resizeAllDayPanel": "調整全天面板大小", + "scheduleItemCount": "{count, plural, =1{1 項} other{{count} 項}}", + "readOnlyCalendar": "此行事曆為唯讀。", + "selectTimeZone": "選擇時區", + "searchLocations": "搜尋地點", + "noLocationsFound": "找不到地點", + "deleteCalendarConfirmation": "要刪除「{title}」嗎?" +} diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 5795906..77b8b4c 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1,3 +1,7 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + import 'dart:async'; import 'package:flutter/foundation.dart'; @@ -5,10 +9,22 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:intl/intl.dart' as intl; +import 'app_localizations_ar.dart'; import 'app_localizations_de.dart'; import 'app_localizations_en.dart'; import 'app_localizations_es.dart'; +import 'app_localizations_et.dart'; +import 'app_localizations_fa.dart'; +import 'app_localizations_fi.dart'; import 'app_localizations_fr.dart'; +import 'app_localizations_hi.dart'; +import 'app_localizations_it.dart'; +import 'app_localizations_ja.dart'; +import 'app_localizations_ko.dart'; +import 'app_localizations_pt.dart'; +import 'app_localizations_ru.dart'; +import 'app_localizations_vi.dart'; +import 'app_localizations_zh.dart'; // ignore_for_file: type=lint @@ -96,10 +112,24 @@ abstract class AppLocalizations { /// A list of this localizations delegate's supported locales. static const List supportedLocales = [ + Locale('ar'), Locale('de'), Locale('en'), Locale('es'), + Locale('et'), + Locale('fa'), + Locale('fi'), Locale('fr'), + Locale('hi'), + Locale('it'), + Locale('ja'), + Locale('ko'), + Locale('pt'), + Locale('ru'), + Locale('vi'), + Locale('zh'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), ]; /// No description provided for @appTitle. @@ -339,7 +369,7 @@ abstract class AppLocalizations { /// No description provided for @allDay. /// /// In en, this message translates to: - /// **'All Day'** + /// **'All day'** String get allDay; /// No description provided for @moreItems. @@ -354,125 +384,65 @@ abstract class AppLocalizations { /// **'No events or tasks'** String get noEventsOrTasks; - /// No description provided for @trayAgendaLoading. - /// - /// In en, this message translates to: - /// **'Loading agenda...'** - String get trayAgendaLoading; - - /// No description provided for @trayAgendaSignInRequired. - /// - /// In en, this message translates to: - /// **'Sign in to show agenda.'** - String get trayAgendaSignInRequired; - - /// No description provided for @trayAgendaNoSources. - /// - /// In en, this message translates to: - /// **'No visible calendars or task lists.'** - String get trayAgendaNoSources; - - /// No description provided for @trayAgendaOpenBusyMax. - /// - /// In en, this message translates to: - /// **'Open app'** - String get trayAgendaOpenBusyMax; - - /// No description provided for @trayAgendaRefresh. - /// - /// In en, this message translates to: - /// **'Refresh'** - String get trayAgendaRefresh; - - /// No description provided for @trayAgendaError. + /// No description provided for @scheduleLoading. /// /// In en, this message translates to: - /// **'Agenda unavailable'** - String get trayAgendaError; + /// **'Loading schedule...'** + String get scheduleLoading; - /// No description provided for @compactAgendaTitle. + /// No description provided for @scheduleUnavailable. /// /// In en, this message translates to: - /// **'Agenda'** - String get compactAgendaTitle; + /// **'Schedule unavailable'** + String get scheduleUnavailable; - /// No description provided for @compactAgendaSubtitle. + /// No description provided for @scheduleNoSources. /// /// In en, this message translates to: - /// **'Upcoming'** - String get compactAgendaSubtitle; + /// **'No visible calendars or task lists'** + String get scheduleNoSources; - /// No description provided for @compactAgendaOverdue. + /// No description provided for @scheduleNoSourcesDescription. /// /// In en, this message translates to: - /// **'Overdue'** - String get compactAgendaOverdue; + /// **'Choose what to show in Settings, then refresh.'** + String get scheduleNoSourcesDescription; - /// No description provided for @compactAgendaClear. + /// No description provided for @scheduleSignInRequired. /// /// In en, this message translates to: - /// **'Clear for now'** - String get compactAgendaClear; + /// **'Connect an account'** + String get scheduleSignInRequired; - /// No description provided for @compactAgendaOpenBusyMax. + /// No description provided for @scheduleSignInDescription. /// /// In en, this message translates to: - /// **'Open BusyMax'** - String get compactAgendaOpenBusyMax; + /// **'Sign in to sync calendars and tasks.'** + String get scheduleSignInDescription; - /// No description provided for @compactAgendaHide. + /// No description provided for @scheduleNoSearchResults. /// /// In en, this message translates to: - /// **'Hide'** - String get compactAgendaHide; + /// **'No matching events or tasks'** + String get scheduleNoSearchResults; - /// No description provided for @compactAgendaNewTask. + /// No description provided for @scheduleNoSearchResultsDescription. /// /// In en, this message translates to: - /// **'New task'** - String get compactAgendaNewTask; + /// **'Try a different search or clear the current filters.'** + String get scheduleNoSearchResultsDescription; - /// No description provided for @compactAgendaRetry. - /// - /// In en, this message translates to: - /// **'Retry'** - String get compactAgendaRetry; - - /// No description provided for @compactAgendaRefresh. + /// No description provided for @refresh. /// /// In en, this message translates to: /// **'Refresh'** - String get compactAgendaRefresh; - - /// No description provided for @compactAgendaAllDay. - /// - /// In en, this message translates to: - /// **'All day'** - String get compactAgendaAllDay; + String get refresh; - /// No description provided for @compactAgendaDueToday. + /// No description provided for @trayOpenBusyMax. /// /// In en, this message translates to: - /// **'Due today'** - String get compactAgendaDueToday; - - /// No description provided for @compactAgendaDueTomorrow. - /// - /// In en, this message translates to: - /// **'Due tomorrow'** - String get compactAgendaDueTomorrow; - - /// No description provided for @compactAgendaDueOn. - /// - /// In en, this message translates to: - /// **'Due {date}'** - String compactAgendaDueOn(String date); - - /// No description provided for @compactAgendaMoreOverdue. - /// - /// In en, this message translates to: - /// **'Load more overdue tasks'** - String get compactAgendaMoreOverdue; + /// **'Open BusyMax'** + String get trayOpenBusyMax; /// No description provided for @agendaLoadMoreOverdue. /// @@ -597,7 +567,7 @@ abstract class AppLocalizations { /// No description provided for @timeSlot. /// /// In en, this message translates to: - /// **'Time Slot'** + /// **'Time slot'** String get timeSlot; /// No description provided for @startDateTime. @@ -684,6 +654,126 @@ abstract class AppLocalizations { /// **'Provider calendar'** String get providerCalendar; + /// No description provided for @formatBoldShortLabel. + /// + /// In en, this message translates to: + /// **'B'** + String get formatBoldShortLabel; + + /// No description provided for @formatBoldTooltip. + /// + /// In en, this message translates to: + /// **'Bold'** + String get formatBoldTooltip; + + /// No description provided for @formatItalicShortLabel. + /// + /// In en, this message translates to: + /// **'I'** + String get formatItalicShortLabel; + + /// No description provided for @formatItalicTooltip. + /// + /// In en, this message translates to: + /// **'Italic'** + String get formatItalicTooltip; + + /// No description provided for @formatUnderlineShortLabel. + /// + /// In en, this message translates to: + /// **'U'** + String get formatUnderlineShortLabel; + + /// No description provided for @formatUnderlineTooltip. + /// + /// In en, this message translates to: + /// **'Underline'** + String get formatUnderlineTooltip; + + /// No description provided for @reminderMinutesBefore. + /// + /// In en, this message translates to: + /// **'{minutes, plural, =1{1 minute before} other{{minutes} minutes before}}'** + String reminderMinutesBefore(int minutes); + + /// No description provided for @reminderAtStart. + /// + /// In en, this message translates to: + /// **'At start'** + String get reminderAtStart; + + /// No description provided for @reminderHoursBefore. + /// + /// In en, this message translates to: + /// **'{hours, plural, =1{1 hour before} other{{hours} hours before}}'** + String reminderHoursBefore(int hours); + + /// No description provided for @reminderDaysBefore. + /// + /// In en, this message translates to: + /// **'{days, plural, =1{1 day before} other{{days} days before}}'** + String reminderDaysBefore(int days); + + /// No description provided for @availabilityFree. + /// + /// In en, this message translates to: + /// **'Free'** + String get availabilityFree; + + /// No description provided for @availabilityTentative. + /// + /// In en, this message translates to: + /// **'Tentative'** + String get availabilityTentative; + + /// No description provided for @availabilityOutOfOffice. + /// + /// In en, this message translates to: + /// **'Out of office'** + String get availabilityOutOfOffice; + + /// No description provided for @availabilityWorkingElsewhere. + /// + /// In en, this message translates to: + /// **'Working elsewhere'** + String get availabilityWorkingElsewhere; + + /// No description provided for @visibilityDefault. + /// + /// In en, this message translates to: + /// **'Default'** + String get visibilityDefault; + + /// No description provided for @visibilityPublic. + /// + /// In en, this message translates to: + /// **'Public'** + String get visibilityPublic; + + /// No description provided for @visibilityPrivate. + /// + /// In en, this message translates to: + /// **'Private'** + String get visibilityPrivate; + + /// No description provided for @visibilityConfidential. + /// + /// In en, this message translates to: + /// **'Confidential'** + String get visibilityConfidential; + + /// No description provided for @sensitivityNormal. + /// + /// In en, this message translates to: + /// **'Normal'** + String get sensitivityNormal; + + /// No description provided for @sensitivityPersonal. + /// + /// In en, this message translates to: + /// **'Personal'** + String get sensitivityPersonal; + /// No description provided for @tasks. /// /// In en, this message translates to: @@ -846,35 +936,29 @@ abstract class AppLocalizations { /// **'Close task editing or task details'** String get shortcutCancelEditingDescription; - /// No description provided for @shortcutGroupCompactAgenda. - /// - /// In en, this message translates to: - /// **'Compact agenda'** - String get shortcutGroupCompactAgenda; - - /// No description provided for @shortcutRefreshCompactAgendaDescription. + /// No description provided for @aboutBusyMax. /// /// In en, this message translates to: - /// **'Refresh the compact agenda window'** - String get shortcutRefreshCompactAgendaDescription; + /// **'About BusyMax'** + String get aboutBusyMax; - /// No description provided for @shortcutHideCompactAgendaDescription. + /// No description provided for @aboutBusyMaxDescription. /// /// In en, this message translates to: - /// **'Hide the compact agenda window'** - String get shortcutHideCompactAgendaDescription; + /// **'Calendar and tasks'** + String get aboutBusyMaxDescription; - /// No description provided for @aboutBusyMax. + /// No description provided for @license. /// /// In en, this message translates to: - /// **'About BusyMax'** - String get aboutBusyMax; + /// **'License'** + String get license; - /// No description provided for @aboutBusyMaxDescription. + /// No description provided for @apacheLicenseName. /// /// In en, this message translates to: - /// **'ToDo and Calendar'** - String get aboutBusyMaxDescription; + /// **'Apache License 2.0'** + String get apacheLicenseName; /// No description provided for @website. /// @@ -882,6 +966,12 @@ abstract class AppLocalizations { /// **'Website'** String get website; + /// No description provided for @sourceCode. + /// + /// In en, this message translates to: + /// **'Source code'** + String get sourceCode; + /// No description provided for @reportAnIssue. /// /// In en, this message translates to: @@ -1038,6 +1128,18 @@ abstract class AppLocalizations { /// **'Toggle Sidebar'** String get toggleSidebar; + /// No description provided for @showSidebar. + /// + /// In en, this message translates to: + /// **'Show sidebar panel'** + String get showSidebar; + + /// No description provided for @hideSidebar. + /// + /// In en, this message translates to: + /// **'Hide sidebar panel'** + String get hideSidebar; + /// No description provided for @accounts. /// /// In en, this message translates to: @@ -1086,29 +1188,65 @@ abstract class AppLocalizations { /// **'Signed in'** String get signedInAccount; - /// No description provided for @signOutThisAccount. + /// No description provided for @removeAccount. + /// + /// In en, this message translates to: + /// **'Remove account…'** + String get removeAccount; + + /// No description provided for @removingAccount. + /// + /// In en, this message translates to: + /// **'Removing account…'** + String get removingAccount; + + /// No description provided for @removeAccountDescription. + /// + /// In en, this message translates to: + /// **'Stop syncing and remove this account’s data from this device.'** + String get removeAccountDescription; + + /// No description provided for @removeAccountTitle. + /// + /// In en, this message translates to: + /// **'Remove {account} from BusyMax?'** + String removeAccountTitle(String account); + + /// No description provided for @removeAccountConfirmation. /// /// In en, this message translates to: - /// **'Sign out this account'** - String get signOutThisAccount; + /// **'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Nothing will be deleted from Google or Microsoft.'** + String get removeAccountConfirmation; - /// No description provided for @revokeThisAccount. + /// No description provided for @revokeGoogleAccess. /// /// In en, this message translates to: - /// **'Revoke this account'** - String get revokeThisAccount; + /// **'Also revoke BusyMax’s access to this Google Account'** + String get revokeGoogleAccess; - /// No description provided for @disconnectThisAccount. + /// No description provided for @revokeGoogleAccessDescription. /// /// In en, this message translates to: - /// **'Disconnect this account'** - String get disconnectThisAccount; + /// **'You will need to grant access again before reconnecting.'** + String get revokeGoogleAccessDescription; - /// No description provided for @deleteLocalDataForThisAccount. + /// No description provided for @removeAccountAction. /// /// In en, this message translates to: - /// **'Delete local data for this account'** - String get deleteLocalDataForThisAccount; + /// **'Remove account'** + String get removeAccountAction; + + /// No description provided for @removeAccountFailed. + /// + /// In en, this message translates to: + /// **'Could not finish removing the account. Try again.'** + String get removeAccountFailed; + + /// No description provided for @accountRemovedGoogleRevokeFailed. + /// + /// In en, this message translates to: + /// **'The account was removed from this device, but BusyMax could not revoke Google access. You can revoke it from your Google Account.'** + String get accountRemovedGoogleRevokeFailed; /// No description provided for @newList. /// @@ -1332,36 +1470,6 @@ abstract class AppLocalizations { /// **'{date} · {time}'** String dateTimeDisplay(String date, String time); - /// No description provided for @searchTasks. - /// - /// In en, this message translates to: - /// **'Search tasks'** - String get searchTasks; - - /// No description provided for @advancedFilters. - /// - /// In en, this message translates to: - /// **'Advanced filters'** - String get advancedFilters; - - /// No description provided for @showCompleted. - /// - /// In en, this message translates to: - /// **'Show completed'** - String get showCompleted; - - /// No description provided for @showHidden. - /// - /// In en, this message translates to: - /// **'Show hidden'** - String get showHidden; - - /// No description provided for @showAssigned. - /// - /// In en, this message translates to: - /// **'Show assigned'** - String get showAssigned; - /// No description provided for @taskDetails. /// /// In en, this message translates to: @@ -1758,41 +1866,41 @@ abstract class AppLocalizations { /// **'Account'** String get account; - /// No description provided for @signOut. + /// No description provided for @sync. /// /// In en, this message translates to: - /// **'Sign out'** - String get signOut; + /// **'Sync'** + String get sync; - /// No description provided for @revokeGoogleAuthorization. + /// No description provided for @manualFullSync. /// /// In en, this message translates to: - /// **'Revoke Google authorization'** - String get revokeGoogleAuthorization; + /// **'Manual full sync'** + String get manualFullSync; - /// No description provided for @deleteLocalData. + /// No description provided for @runInBackgroundWhenClosed. /// /// In en, this message translates to: - /// **'Delete local data'** - String get deleteLocalData; + /// **'Continue running when the window is closed'** + String get runInBackgroundWhenClosed; - /// No description provided for @deleteLocalDataConfirmation. + /// No description provided for @showTrayIcon. /// /// In en, this message translates to: - /// **'This removes the local account, synced tasks, and pending offline changes from this device.'** - String get deleteLocalDataConfirmation; + /// **'Show tray icon'** + String get showTrayIcon; - /// No description provided for @sync. + /// No description provided for @startMinimizedToTray. /// /// In en, this message translates to: - /// **'Sync'** - String get sync; + /// **'Start minimized to the tray'** + String get startMinimizedToTray; - /// No description provided for @manualFullSync. + /// No description provided for @requiresTrayIcon. /// /// In en, this message translates to: - /// **'Manual full sync'** - String get manualFullSync; + /// **'Requires the tray icon.'** + String get requiresTrayIcon; /// No description provided for @syncComplete. /// @@ -1824,6 +1932,60 @@ abstract class AppLocalizations { /// **'Due-today notifications'** String get notifyDueToday; + /// No description provided for @eventReminders. + /// + /// In en, this message translates to: + /// **'Event reminders'** + String get eventReminders; + + /// No description provided for @taskReminders. + /// + /// In en, this message translates to: + /// **'Task reminders'** + String get taskReminders; + + /// No description provided for @notificationDetailLevel. + /// + /// In en, this message translates to: + /// **'Notification detail level'** + String get notificationDetailLevel; + + /// No description provided for @notificationDetailPrivate. + /// + /// In en, this message translates to: + /// **'Private'** + String get notificationDetailPrivate; + + /// No description provided for @notificationDetailNormal. + /// + /// In en, this message translates to: + /// **'Normal'** + String get notificationDetailNormal; + + /// No description provided for @quietHours. + /// + /// In en, this message translates to: + /// **'Quiet hours'** + String get quietHours; + + /// No description provided for @quietHoursDescription. + /// + /// In en, this message translates to: + /// **'Pause notifications during this period.'** + String get quietHoursDescription; + + /// No description provided for @quietHoursStart. + /// + /// In en, this message translates to: + /// **'Quiet hours start'** + String get quietHoursStart; + + /// No description provided for @quietHoursEnd. + /// + /// In en, this message translates to: + /// **'Quiet hours end'** + String get quietHoursEnd; + /// No description provided for @notifications. /// /// In en, this message translates to: @@ -1896,12 +2058,6 @@ abstract class AppLocalizations { /// **'Redact task content in diagnostics'** String get redactTaskContentInDiagnostics; - /// No description provided for @detailedNotifications. - /// - /// In en, this message translates to: - /// **'Detailed notification text'** - String get detailedNotifications; - /// No description provided for @developerDiagnostics. /// /// In en, this message translates to: @@ -2004,6 +2160,12 @@ abstract class AppLocalizations { /// **'Discard'** String get discard; + /// Destructive confirmation button that throws away unsaved edits. Translate it distinctly from Cancel. + /// + /// In en, this message translates to: + /// **'Discard'** + String get discardChangesAction; + /// No description provided for @discardChanges. /// /// In en, this message translates to: @@ -2076,11 +2238,125 @@ abstract class AppLocalizations { /// **'{count, plural, =1{One task is due today.} other{{count} tasks are due today.}}'** String dueTodayNotificationBody(int count); + /// No description provided for @eventReminderNotificationTitle. + /// + /// In en, this message translates to: + /// **'Event reminder'** + String get eventReminderNotificationTitle; + + /// No description provided for @taskReminderNotificationTitle. + /// + /// In en, this message translates to: + /// **'Task reminder'** + String get taskReminderNotificationTitle; + + /// No description provided for @eventReminderNotificationBody. + /// + /// In en, this message translates to: + /// **'Event starts soon.'** + String get eventReminderNotificationBody; + + /// No description provided for @taskReminderNotificationBody. + /// + /// In en, this message translates to: + /// **'Task is due soon.'** + String get taskReminderNotificationBody; + + /// No description provided for @notificationOpenAction. + /// + /// In en, this message translates to: + /// **'Open'** + String get notificationOpenAction; + /// No description provided for @notificationDetailsHidden. /// /// In en, this message translates to: /// **'Details are hidden by privacy settings.'** String get notificationDetailsHidden; + + /// No description provided for @previousMonth. + /// + /// In en, this message translates to: + /// **'Previous month'** + String get previousMonth; + + /// No description provided for @nextMonth. + /// + /// In en, this message translates to: + /// **'Next month'** + String get nextMonth; + + /// No description provided for @openMonthView. + /// + /// In en, this message translates to: + /// **'Open month view'** + String get openMonthView; + + /// No description provided for @previousYear. + /// + /// In en, this message translates to: + /// **'Previous year'** + String get previousYear; + + /// No description provided for @nextYear. + /// + /// In en, this message translates to: + /// **'Next year'** + String get nextYear; + + /// No description provided for @openYearView. + /// + /// In en, this message translates to: + /// **'Open year view'** + String get openYearView; + + /// No description provided for @weekNumberTooltip. + /// + /// In en, this message translates to: + /// **'Week {number}'** + String weekNumberTooltip(int number); + + /// No description provided for @resizeAllDayPanel. + /// + /// In en, this message translates to: + /// **'Resize the all-day panel'** + String get resizeAllDayPanel; + + /// No description provided for @scheduleItemCount. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 item} other{{count} items}}'** + String scheduleItemCount(int count); + + /// No description provided for @readOnlyCalendar. + /// + /// In en, this message translates to: + /// **'This calendar is read-only.'** + String get readOnlyCalendar; + + /// No description provided for @selectTimeZone. + /// + /// In en, this message translates to: + /// **'Select Timezone'** + String get selectTimeZone; + + /// No description provided for @searchLocations. + /// + /// In en, this message translates to: + /// **'Search locations'** + String get searchLocations; + + /// No description provided for @noLocationsFound. + /// + /// In en, this message translates to: + /// **'No locations found'** + String get noLocationsFound; + + /// No description provided for @deleteCalendarConfirmation. + /// + /// In en, this message translates to: + /// **'Delete \"{title}\"?'** + String deleteCalendarConfirmation(String title); } class _AppLocalizationsDelegate @@ -2093,24 +2369,78 @@ class _AppLocalizationsDelegate } @override - bool isSupported(Locale locale) => - ['de', 'en', 'es', 'fr'].contains(locale.languageCode); + bool isSupported(Locale locale) => [ + 'ar', + 'de', + 'en', + 'es', + 'et', + 'fa', + 'fi', + 'fr', + 'hi', + 'it', + 'ja', + 'ko', + 'pt', + 'ru', + 'vi', + 'zh', + ].contains(locale.languageCode); @override bool shouldReload(_AppLocalizationsDelegate old) => false; } AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when language+script codes are specified. + switch (locale.languageCode) { + case 'zh': + { + switch (locale.scriptCode) { + case 'Hans': + return AppLocalizationsZhHans(); + case 'Hant': + return AppLocalizationsZhHant(); + } + break; + } + } + // Lookup logic when only language code is specified. switch (locale.languageCode) { + case 'ar': + return AppLocalizationsAr(); case 'de': return AppLocalizationsDe(); case 'en': return AppLocalizationsEn(); case 'es': return AppLocalizationsEs(); + case 'et': + return AppLocalizationsEt(); + case 'fa': + return AppLocalizationsFa(); + case 'fi': + return AppLocalizationsFi(); case 'fr': return AppLocalizationsFr(); + case 'hi': + return AppLocalizationsHi(); + case 'it': + return AppLocalizationsIt(); + case 'ja': + return AppLocalizationsJa(); + case 'ko': + return AppLocalizationsKo(); + case 'pt': + return AppLocalizationsPt(); + case 'ru': + return AppLocalizationsRu(); + case 'vi': + return AppLocalizationsVi(); + case 'zh': + return AppLocalizationsZh(); } throw FlutterError( diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart new file mode 100644 index 0000000..f65376c --- /dev/null +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -0,0 +1,1263 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Arabic (`ar`). +class AppLocalizationsAr extends AppLocalizations { + AppLocalizationsAr([String locale = 'ar']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'اربط حسابات Google وMicrosoft لمزامنة التقويمات والمهام.'; + + @override + String get googlePermissionsConsentNotice => + 'في شاشة أذونات Google، حدّد أذونات التقويم والمهام معًا.'; + + @override + String get googlePermissionsRequiredRetry => + 'أذونات تقويم Google وGoogle Tasks مطلوبة. حاول مرة أخرى وحدّد مربعي الاختيار.'; + + @override + String get finishSetup => 'إنهاء الإعداد'; + + @override + String get continueSetup => 'متابعة'; + + @override + String get onboardingSetupTitle => 'إعداد BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'ربط الحسابات'; + + @override + String get onboardingAccountsStepDescription => + 'أضف جميع حسابات Google وMicrosoft التي تريد استخدامها. يزامن BusyMax التقويمات والأحداث وقوائم المهام والمهام من كل حساب.'; + + @override + String get onboardingPreferencesStepTitle => 'اختيار إعدادات النظام'; + + @override + String get onboardingPreferencesStepDescription => + 'اضبط سلوك التطبيق على سطح المكتب والتذكيرات ومستوى تفاصيل الإشعارات والمظهر قبل فتح جدولك.'; + + @override + String get signInWithGoogle => 'تسجيل الدخول باستخدام Google'; + + @override + String get signInWithMicrosoft => 'تسجيل الدخول باستخدام Microsoft'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'هذه الخدمة غير مهيأة.'; + + @override + String get waitingForGoogleSignIn => 'في انتظار تسجيل الدخول إلى Google...'; + + @override + String get waitingForMicrosoftSignIn => + 'في انتظار تسجيل الدخول إلى Microsoft...'; + + @override + String get microsoftSignInNotConfigured => + 'تسجيل الدخول إلى Microsoft غير مهيأ. اضبط MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'إلغاء'; + + @override + String get close => 'إغلاق'; + + @override + String get exit => 'خروج'; + + @override + String get options => 'خيارات'; + + @override + String get hide => 'إخفاء'; + + @override + String get show => 'إظهار'; + + @override + String get export => 'تصدير'; + + @override + String get save => 'حفظ'; + + @override + String get settings => 'الإعدادات'; + + @override + String get all => 'الكل'; + + @override + String get calendarEvents => 'الأحداث'; + + @override + String get calendarTasks => 'المهام'; + + @override + String get calendar => 'التقويم'; + + @override + String get calendars => 'التقويمات'; + + @override + String get newEvent => 'حدث جديد'; + + @override + String get refreshCalendar => 'تحديث التقويم'; + + @override + String get openInProvider => 'فتح في الخدمة'; + + @override + String get hideFromSchedule => 'إخفاء من الجدول'; + + @override + String get showInSchedule => 'إظهار في الجدول'; + + @override + String get noCalendarsSynced => 'لم تتم مزامنة أي تقويمات بعد.'; + + @override + String get allDay => 'طوال اليوم'; + + @override + String moreItems(int count) { + return '+⁨$count⁩ عناصر أخرى'; + } + + @override + String get noEventsOrTasks => 'لا توجد أحداث أو مهام'; + + @override + String get scheduleLoading => 'جارٍ تحميل الجدول...'; + + @override + String get scheduleUnavailable => 'الجدول غير متاح'; + + @override + String get scheduleNoSources => 'لا توجد تقويمات أو قوائم مهام ظاهرة'; + + @override + String get scheduleNoSourcesDescription => + 'اختر ما تريد إظهاره في الإعدادات، ثم حدّث الجدول.'; + + @override + String get scheduleSignInRequired => 'ربط حساب'; + + @override + String get scheduleSignInDescription => + 'سجّل الدخول لمزامنة التقويمات والمهام.'; + + @override + String get scheduleNoSearchResults => 'لا توجد أحداث أو مهام مطابقة'; + + @override + String get scheduleNoSearchResultsDescription => + 'جرّب بحثًا مختلفًا أو امسح عوامل التصفية الحالية.'; + + @override + String get refresh => 'تحديث'; + + @override + String get trayOpenBusyMax => 'فتح BusyMax'; + + @override + String get agendaLoadMoreOverdue => 'تحميل المزيد من المهام المتأخرة'; + + @override + String get agendaLoadMoreNoDate => 'تحميل المزيد من المهام بلا تاريخ'; + + @override + String get viewDay => 'يوم'; + + @override + String get viewWeek => 'أسبوع'; + + @override + String get viewMonth => 'شهر'; + + @override + String get viewYear => 'سنة'; + + @override + String get viewAgenda => 'جدول الأعمال'; + + @override + String get scheduleSettings => 'الجدول'; + + @override + String get scheduleDisplaySettings => 'عرض الجدول'; + + @override + String get scheduleDisplayHoursDescription => + 'تفتح طريقتا عرض اليوم والأسبوع ضمن هذه الساعات. توسّع العناصر المبكرة والمتأخرة النطاق عند الحاجة.'; + + @override + String get scheduleDayStartsAt => 'يبدأ اليوم في'; + + @override + String get scheduleDayEndsAt => 'ينتهي اليوم في'; + + @override + String get sourceCalendar => 'التقويم'; + + @override + String get sourceTaskList => 'قائمة المهام'; + + @override + String get createChoiceTitle => 'إنشاء'; + + @override + String get createEventAtTime => 'حدث'; + + @override + String get createTaskAtDate => 'مهمة'; + + @override + String get editEvent => 'تعديل الحدث'; + + @override + String get eventTitle => 'عنوان الحدث'; + + @override + String get location => 'الموقع'; + + @override + String get timeSlot => 'الفترة الزمنية'; + + @override + String get startDateTime => 'تاريخ/وقت البدء'; + + @override + String get endDateTime => 'تاريخ/وقت الانتهاء'; + + @override + String get doesNotRepeat => 'لا يتكرر'; + + @override + String get defaultReminder => 'التذكير الافتراضي'; + + @override + String get guests => 'المدعوون'; + + @override + String get noGuests => 'لا يوجد مدعوون'; + + @override + String get description => 'الوصف'; + + @override + String get availabilityShowAs => 'التوفر / إظهار كـ'; + + @override + String get busy => 'مشغول'; + + @override + String get visibility => 'إمكانية العرض'; + + @override + String get defaultVisibility => 'إمكانية العرض الافتراضية'; + + @override + String get conference => 'اجتماع'; + + @override + String get noConference => 'لا يوجد اجتماع'; + + @override + String get providerCalendar => 'تقويم الخدمة'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => 'عريض'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'مائل'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => 'تحته خط'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: 'قبل ⁨$minutes⁩ دقيقة', + many: 'قبل ⁨$minutes⁩ دقيقة', + few: 'قبل ⁨$minutes⁩ دقائق', + two: 'قبل دقيقتين', + one: 'قبل دقيقة واحدة', + zero: 'عند البدء', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'عند البدء'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: 'قبل ⁨$hours⁩ ساعة', + many: 'قبل ⁨$hours⁩ ساعة', + few: 'قبل ⁨$hours⁩ ساعات', + two: 'قبل ساعتين', + one: 'قبل ساعة واحدة', + zero: 'عند البدء', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: 'قبل ⁨$days⁩ يوم', + many: 'قبل ⁨$days⁩ يومًا', + few: 'قبل ⁨$days⁩ أيام', + two: 'قبل يومين', + one: 'قبل يوم واحد', + zero: 'في اليوم نفسه', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'متاح'; + + @override + String get availabilityTentative => 'مبدئي'; + + @override + String get availabilityOutOfOffice => 'خارج المكتب'; + + @override + String get availabilityWorkingElsewhere => 'العمل من مكان آخر'; + + @override + String get visibilityDefault => 'افتراضي'; + + @override + String get visibilityPublic => 'عام'; + + @override + String get visibilityPrivate => 'خاص'; + + @override + String get visibilityConfidential => 'سري'; + + @override + String get sensitivityNormal => 'عادي'; + + @override + String get sensitivityPersonal => 'شخصي'; + + @override + String get tasks => 'المهام'; + + @override + String get allTasks => 'كل المهام'; + + @override + String tasksInList(String title) { + return 'المهام في ⁨$title⁩'; + } + + @override + String get taskLists => 'قوائم المهام'; + + @override + String get navigation => 'التنقل'; + + @override + String get mainMenu => 'القائمة الرئيسية'; + + @override + String get keyboardShortcuts => 'اختصارات لوحة المفاتيح'; + + @override + String get shortcutGroupGeneral => 'عام'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'إظهار مرجع الاختصارات هذا'; + + @override + String get shortcutGroupNavigation => 'التنقل'; + + @override + String get shortcutNextPeriod => 'الفترة التالية'; + + @override + String get shortcutNextPeriodDescription => + 'الأسبوع التالي في عرض الأسبوع، والشهر التالي في عرض الشهر، وهكذا'; + + @override + String get shortcutPreviousPeriod => 'الفترة السابقة'; + + @override + String get shortcutPreviousPeriodDescription => + 'الأسبوع السابق في عرض الأسبوع، والشهر السابق في عرض الشهر، وهكذا'; + + @override + String get shortcutJumpToToday => 'الانتقال إلى اليوم'; + + @override + String get shortcutGroupView => 'العرض'; + + @override + String get shortcutDayView => 'عرض اليوم'; + + @override + String get shortcutWeekView => 'عرض الأسبوع'; + + @override + String get shortcutMonthView => 'عرض الشهر'; + + @override + String get shortcutYearView => 'عرض السنة'; + + @override + String get shortcutAgendaView => 'عرض جدول الأعمال'; + + @override + String get shortcutGroupCreateAndEdit => 'الإنشاء والتعديل'; + + @override + String get shortcutSaveItem => 'حفظ الحدث أو المهمة'; + + @override + String get shortcutDeleteItem => 'حذف الحدث أو المهمة'; + + @override + String get shortcutGroupTaskEditing => 'تعديل المهام'; + + @override + String get shortcutCancelEditing => 'إلغاء التعديل'; + + @override + String get shortcutCancelEditingDescription => + 'إغلاق تعديل المهمة أو تفاصيلها'; + + @override + String get aboutBusyMax => 'حول BusyMax'; + + @override + String get aboutBusyMaxDescription => 'التقويم والمهام'; + + @override + String get license => 'الترخيص'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => 'الموقع الإلكتروني'; + + @override + String get sourceCode => 'الشيفرة المصدرية'; + + @override + String get reportAnIssue => 'الإبلاغ عن مشكلة'; + + @override + String get sendFeedback => 'إرسال الملاحظات'; + + @override + String get feedbackSubmit => 'إرسال'; + + @override + String get feedbackCategory => 'الفئة'; + + @override + String get feedbackSelectCategory => 'اختر فئة'; + + @override + String get feedbackCategoryProblem => 'مشكلة أو خلل'; + + @override + String get feedbackCategoryFeature => 'طلب ميزة'; + + @override + String get feedbackCategoryPrivacySecurity => + 'مشكلة تتعلق بالخصوصية أو الأمان'; + + @override + String get feedbackCategoryUsability => 'مشكلة في سهولة الاستخدام'; + + @override + String get feedbackCategoryOther => 'أخرى'; + + @override + String get feedbackSubject => 'الموضوع'; + + @override + String get feedbackDetailedMessage => 'رسالة مفصّلة'; + + @override + String get feedbackReplyEmail => 'البريد الإلكتروني للرد (اختياري)'; + + @override + String get feedbackIncludeTechnicalDetails => 'تضمين التفاصيل التقنية'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'يضيف فقط إصدار نظام التشغيل Linux ولغة التطبيق ومنطقته. لا يتم تضمين أي سجلات أو بيانات حسابات أو أسماء ملفات أو معلومات تشخيصية أخرى.'; + + @override + String get feedbackCategoryRequired => 'اختر فئة.'; + + @override + String get feedbackSubjectLengthError => + 'يجب أن يتراوح الموضوع بين 3 و120 حرفًا.'; + + @override + String get feedbackMessageLengthError => + 'يجب أن تتراوح الرسالة بين 10 و5,000 حرف.'; + + @override + String get feedbackInvalidEmail => 'أدخل عنوان بريد إلكتروني صالحًا.'; + + @override + String get feedbackConnectionError => + 'تعذر الاتصال بـ BusyStack. تحقق من اتصالك وحاول مرة أخرى.'; + + @override + String get feedbackTimeoutError => + 'انتهت مهلة الطلب. لم تُمسح ملاحظاتك؛ حاول مرة أخرى.'; + + @override + String get feedbackRateLimitedError => + 'أُرسلت ملاحظات كثيرة جدًا من هذه الشبكة. انتظر وحاول مرة أخرى.'; + + @override + String get feedbackRejectedError => + 'رفض الخادم الإرسال. راجع الحقول وحاول مرة أخرى.'; + + @override + String get feedbackServerError => + 'يتعذر على BusyStack قبول ملاحظاتك الآن. لم تُمسح ملاحظاتك؛ حاول مرة أخرى.'; + + @override + String feedbackSuccess(String id) { + return 'تم إرسال الملاحظات. المرجع: ⁨$id⁩'; + } + + @override + String get toggleSidebar => 'إظهار الشريط الجانبي أو إخفاؤه'; + + @override + String get showSidebar => 'إظهار اللوحة الجانبية'; + + @override + String get hideSidebar => 'إخفاء اللوحة الجانبية'; + + @override + String get accounts => 'الحسابات'; + + @override + String get currentAccount => 'الحساب الحالي'; + + @override + String get switchAccount => 'تبديل الحساب'; + + @override + String get addGoogleAccount => 'إضافة حساب Google'; + + @override + String get addMicrosoftAccount => 'إضافة حساب Microsoft'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'تم تسجيل الدخول'; + + @override + String get removeAccount => 'إزالة الحساب…'; + + @override + String get removingAccount => 'جارٍ إزالة الحساب…'; + + @override + String get removeAccountDescription => + 'إيقاف المزامنة وإزالة بيانات هذا الحساب من هذا الجهاز.'; + + @override + String removeAccountTitle(String account) { + return 'إزالة ⁨$account⁩ من BusyMax؟'; + } + + @override + String get removeAccountConfirmation => + 'سيؤدي ذلك إلى حذف المهام والتقويمات والأحداث والتذكيرات والتغييرات غير المتصلة المعلّقة المخزّنة مؤقتًا من هذا الجهاز. ستُفقد التغييرات غير المتزامنة. لن يُحذف أي شيء من Google أو Microsoft.'; + + @override + String get revokeGoogleAccess => + 'إلغاء وصول BusyMax إلى حساب Google هذا أيضًا'; + + @override + String get revokeGoogleAccessDescription => + 'ستحتاج إلى منح الوصول مرة أخرى قبل إعادة الاتصال.'; + + @override + String get removeAccountAction => 'إزالة الحساب'; + + @override + String get removeAccountFailed => 'تعذر إكمال إزالة الحساب. حاول مرة أخرى.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'تمت إزالة الحساب من هذا الجهاز، لكن تعذر على BusyMax إلغاء الوصول إلى Google. يمكنك إلغاء الوصول من حسابك على Google.'; + + @override + String get newList => 'قائمة جديدة'; + + @override + String get signInToViewTaskLists => 'سجّل الدخول لعرض قوائم المهام.'; + + @override + String get noTaskListsSynced => 'لم تتم مزامنة أي قوائم مهام بعد.'; + + @override + String get listActions => 'إجراءات القائمة'; + + @override + String get rename => 'إعادة تسمية'; + + @override + String get delete => 'حذف'; + + @override + String get renameList => 'إعادة تسمية القائمة'; + + @override + String get deleteList => 'حذف القائمة'; + + @override + String get builtInMicrosoftList => 'مدمجة'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'لا يمكن إعادة تسمية قوائم Microsoft To Do المدمجة أو حذفها.'; + + @override + String deleteListConfirmation(String title) { + return 'حذف «⁨$title⁩» من Google Tasks؟'; + } + + @override + String get deleteEvent => 'حذف الحدث'; + + @override + String get title => 'العنوان'; + + @override + String get create => 'إنشاء'; + + @override + String get newTask => 'مهمة جديدة'; + + @override + String get clearCompleted => 'مسح المهام المكتملة'; + + @override + String get refreshList => 'تحديث القائمة'; + + @override + String get refreshAll => 'تحديث الكل'; + + @override + String get listRefreshed => 'تم تحديث القائمة.'; + + @override + String get allTasksRefreshed => 'تم تحديث جميع الحسابات.'; + + @override + String exportedFile(String path) { + return 'تم التصدير إلى ⁨$path⁩'; + } + + @override + String exportFailed(String error) { + return 'فشل التصدير: ⁨$error⁩'; + } + + @override + String refreshFailed(String error) { + return 'فشل التحديث: ⁨$error⁩'; + } + + @override + String get selectOrCreateTaskList => 'اختر قائمة مهام أو أنشئ واحدة للبدء.'; + + @override + String get signInToViewTasks => 'سجّل الدخول لعرض المهام.'; + + @override + String get noTasks => 'لا توجد مهام.'; + + @override + String get noTasksYet => 'لا توجد مهام بعد'; + + @override + String get noTasksYetMessage => 'أنشئ مهمة أو حدّث حساباتك للبدء.'; + + @override + String get noTasksInList => 'لا توجد مهام في هذه القائمة.'; + + @override + String get overdue => 'متأخرة'; + + @override + String get today => 'اليوم'; + + @override + String get tomorrow => 'غدًا'; + + @override + String get upcoming => 'القادمة'; + + @override + String get noDate => 'بلا تاريخ'; + + @override + String get completed => 'مكتملة'; + + @override + String duePrefix(String date) { + return 'مستحقة في ⁨$date⁩'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '⁨$date⁩ · ⁨$time⁩'; + } + + @override + String get taskDetails => 'تفاصيل المهمة'; + + @override + String get editTask => 'تعديل المهمة'; + + @override + String get noTaskSelected => 'لم يتم تحديد مهمة.'; + + @override + String get noTaskSelectedHelper => 'حدّد مهمة لعرض تفاصيلها وتعديلها.'; + + @override + String get taskUnavailable => 'المهمة غير متاحة.'; + + @override + String get signInToEditTasks => 'سجّل الدخول لتعديل المهام.'; + + @override + String get refreshTask => 'تحديث المهمة'; + + @override + String get primarySection => 'أساسي'; + + @override + String get statusSection => 'الحالة'; + + @override + String get openStatus => 'مفتوحة'; + + @override + String get doneStatus => 'منجزة'; + + @override + String get notes => 'ملاحظات'; + + @override + String get dueDate => 'تاريخ الاستحقاق'; + + @override + String get clearDueDate => 'مسح تاريخ الاستحقاق'; + + @override + String get dueTime => 'وقت الاستحقاق'; + + @override + String get startDate => 'تاريخ البدء'; + + @override + String get startTime => 'وقت البدء'; + + @override + String get endDate => 'تاريخ الانتهاء'; + + @override + String get endTime => 'وقت الانتهاء'; + + @override + String get reminderDate => 'تاريخ التذكير'; + + @override + String get reminderTime => 'وقت التذكير'; + + @override + String get reminder => 'تذكير'; + + @override + String get addReminder => 'إضافة تذكير'; + + @override + String get addGuest => 'إضافة مدعو'; + + @override + String get addGuestEmail => 'إضافة بريد المدعو الإلكتروني'; + + @override + String get removeReminder => 'إزالة التذكير'; + + @override + String get off => 'إيقاف'; + + @override + String get repeat => 'التكرار'; + + @override + String get repeatNone => 'بلا تكرار'; + + @override + String get noneValue => 'لا شيء'; + + @override + String get repeatDaily => 'يوميًا'; + + @override + String get repeatWeekly => 'أسبوعيًا'; + + @override + String get repeatMonthly => 'شهريًا'; + + @override + String get repeatYearly => 'سنويًا'; + + @override + String get importance => 'الأهمية'; + + @override + String get importanceLow => 'منخفضة'; + + @override + String get importanceNormal => 'عادية'; + + @override + String get importanceHigh => 'مرتفعة'; + + @override + String get categories => 'الفئات'; + + @override + String get scheduleSection => 'الجدول'; + + @override + String get dueGroup => 'الاستحقاق'; + + @override + String get startGroup => 'البدء'; + + @override + String get reminderGroup => 'التذكير'; + + @override + String get organizationSection => 'التنظيم'; + + @override + String get actionsSection => 'الإجراءات'; + + @override + String get advancedSection => 'متقدم'; + + @override + String get addCategory => 'إضافة فئة'; + + @override + String get list => 'القائمة'; + + @override + String get microsoftMoveUnsupported => + 'نقل المهام بين القوائم غير مدعوم لحسابات Microsoft To Do في هذا الإصدار.'; + + @override + String get createSubtask => 'إنشاء مهمة فرعية'; + + @override + String get moveToTop => 'نقل إلى الأعلى'; + + @override + String get deleteTask => 'حذف المهمة'; + + @override + String get newSubtask => 'مهمة فرعية جديدة'; + + @override + String deleteTaskConfirmation(String title) { + return 'حذف «⁨$title⁩» من Google Tasks؟'; + } + + @override + String get metadata => 'البيانات الوصفية'; + + @override + String get id => 'المعرّف'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'آخر تحديث'; + + @override + String get parent => 'المهمة الأصلية'; + + @override + String get position => 'الموضع'; + + @override + String get webLink => 'رابط الويب'; + + @override + String get assignment => 'التعيين'; + + @override + String get localState => 'الحالة المحلية'; + + @override + String get pendingSync => 'في انتظار المزامنة'; + + @override + String get synced => 'تمت المزامنة'; + + @override + String get account => 'الحساب'; + + @override + String get sync => 'المزامنة'; + + @override + String get manualFullSync => 'مزامنة كاملة يدويًا'; + + @override + String get runInBackgroundWhenClosed => 'متابعة التشغيل عند إغلاق النافذة'; + + @override + String get showTrayIcon => 'إظهار أيقونة شريط النظام'; + + @override + String get startMinimizedToTray => 'البدء مصغّرًا في شريط النظام'; + + @override + String get requiresTrayIcon => 'يتطلب أيقونة شريط النظام.'; + + @override + String get syncComplete => 'اكتملت المزامنة.'; + + @override + String syncFailed(String error) { + return 'فشلت المزامنة: ⁨$error⁩'; + } + + @override + String get notifySyncFailures => 'إشعارات عند فشل المزامنة'; + + @override + String get notifyConflicts => 'إشعارات عند حدوث تعارضات'; + + @override + String get notifyDueToday => 'إشعارات المهام المستحقة اليوم'; + + @override + String get eventReminders => 'تذكيرات الأحداث'; + + @override + String get taskReminders => 'تذكيرات المهام'; + + @override + String get notificationDetailLevel => 'مستوى تفاصيل الإشعارات'; + + @override + String get notificationDetailPrivate => 'خاص'; + + @override + String get notificationDetailNormal => 'عادي'; + + @override + String get quietHours => 'ساعات الهدوء'; + + @override + String get quietHoursDescription => 'إيقاف الإشعارات مؤقتًا خلال هذه الفترة.'; + + @override + String get quietHoursStart => 'بداية ساعات الهدوء'; + + @override + String get quietHoursEnd => 'نهاية ساعات الهدوء'; + + @override + String get notifications => 'الإشعارات'; + + @override + String get appearance => 'المظهر'; + + @override + String get theme => 'السمة'; + + @override + String get themeSystem => 'النظام'; + + @override + String get themeLight => 'فاتحة'; + + @override + String get themeDark => 'داكنة'; + + @override + String get themeFamily => 'عائلة السمات'; + + @override + String get themeFamilyYaru => 'سمة Ubuntu الأصلية (Yaru)'; + + @override + String get localization => 'اللغة والمنطقة'; + + @override + String get currentLocale => 'اللغة والمنطقة الحالية'; + + @override + String get privacy => 'الخصوصية'; + + @override + String get redactTaskContentInDiagnostics => + 'إخفاء محتوى المهام في معلومات التشخيص'; + + @override + String get developerDiagnostics => 'تشخيصات المطور'; + + @override + String get diagnostics => 'التشخيصات'; + + @override + String get apiInspectorDisabled => 'إظهار فاحص API'; + + @override + String get googleTasksApi => 'واجهة Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'مراجعة Discovery: ⁨$revision⁩'; + } + + @override + String get implementedMethods => 'الطرق المنفذة'; + + @override + String get supportsTasksScopes => 'يدعم نطاقَي tasks وtasks.readonly'; + + @override + String get requiresTasksScope => 'يتطلب نطاق tasks'; + + @override + String get blockedPendingOperations => 'العمليات المعلّقة المحظورة'; + + @override + String get signInToInspectPendingOperations => + 'سجّل الدخول لفحص العمليات المعلّقة.'; + + @override + String get noBlockedPendingOperations => 'لا توجد عمليات معلّقة محظورة.'; + + @override + String get operationActions => 'إجراءات العملية'; + + @override + String pendingOpListId(String id) { + return 'القائمة=⁨$id⁩'; + } + + @override + String pendingOpTaskId(String id) { + return 'المهمة=⁨$id⁩'; + } + + @override + String pendingOpAttempts(int count) { + return 'المحاولات=⁨$count⁩'; + } + + @override + String get retry => 'إعادة المحاولة'; + + @override + String get discard => 'تجاهل'; + + @override + String get discardChangesAction => 'تجاهل التغييرات'; + + @override + String get discardChanges => 'تجاهل التغييرات؟'; + + @override + String get discardChangesConfirmation => + 'سيؤدي ذلك إلى تجاهل التعديلات غير المحفوظة على هذه المهمة.'; + + @override + String get retryCompleted => 'اكتملت إعادة المحاولة.'; + + @override + String get discardPendingOperation => 'تجاهل العملية المعلّقة؟'; + + @override + String get discardPendingOperationConfirmation => + 'سيؤدي ذلك إلى إزالة العملية المحلية المحظورة. ستُحدّث البيانات من Google Tasks في المزامنة التالية.'; + + @override + String get pendingOperationDiscarded => 'تم تجاهل العملية المعلّقة.'; + + @override + String get syncFailureNotificationTitle => 'فشلت مزامنة BusyMax'; + + @override + String syncFailureNotificationBody(String message) { + return 'فشلت المزامنة في الخلفية. ⁨$message⁩'; + } + + @override + String get conflictNotificationTitle => 'تعارض في مزامنة BusyMax'; + + @override + String conflictNotificationBody(String summary) { + return 'تم حظر تغيير محلي معلّق. ⁨$summary⁩'; + } + + @override + String get dueTodayNotificationTitle => 'المهام المستحقة اليوم'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'هناك ⁨$count⁩ مهمة مستحقة اليوم.', + many: 'هناك ⁨$count⁩ مهمة مستحقة اليوم.', + few: 'هناك ⁨$count⁩ مهام مستحقة اليوم.', + two: 'هناك مهمتان مستحقتان اليوم.', + one: 'هناك مهمة واحدة مستحقة اليوم.', + zero: 'لا توجد مهام مستحقة اليوم.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'تذكير بحدث'; + + @override + String get taskReminderNotificationTitle => 'تذكير بمهمة'; + + @override + String get eventReminderNotificationBody => 'سيبدأ الحدث قريبًا.'; + + @override + String get taskReminderNotificationBody => 'ستحلّ مهلة المهمة قريبًا.'; + + @override + String get notificationOpenAction => 'فتح'; + + @override + String get notificationDetailsHidden => + 'التفاصيل مخفية وفقًا لإعدادات الخصوصية.'; + + @override + String get previousMonth => 'الشهر السابق'; + + @override + String get nextMonth => 'الشهر التالي'; + + @override + String get openMonthView => 'فتح عرض الشهر'; + + @override + String get previousYear => 'السنة السابقة'; + + @override + String get nextYear => 'السنة التالية'; + + @override + String get openYearView => 'فتح عرض السنة'; + + @override + String weekNumberTooltip(int number) { + return 'الأسبوع ⁨$number⁩'; + } + + @override + String get resizeAllDayPanel => 'تغيير حجم لوحة اليوم الكامل'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '⁨$count⁩ عنصر', + many: '⁨$count⁩ عنصرًا', + few: '⁨$count⁩ عناصر', + two: 'عنصران', + one: 'عنصر واحد', + zero: 'لا عناصر', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'هذا التقويم للقراءة فقط.'; + + @override + String get selectTimeZone => 'اختيار المنطقة الزمنية'; + + @override + String get searchLocations => 'البحث عن مواقع'; + + @override + String get noLocationsFound => 'لم يتم العثور على مواقع'; + + @override + String deleteCalendarConfirmation(String title) { + return 'حذف «⁨$title⁩»؟'; + } +} diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index c758ce0..0060526 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1,3 +1,7 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; @@ -21,7 +25,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get googlePermissionsRequiredRetry => - 'Google Calendar- und Google Tasks-Berechtigungen sind erforderlich. Versuchen Sie es erneut und wählen Sie beide Kontrollkästchen aus.'; + 'Die Berechtigungen für Google Kalender und Google Tasks sind erforderlich. Versuchen Sie es erneut und aktivieren Sie beide Kontrollkästchen.'; @override String get finishSetup => 'Einrichtung abschließen'; @@ -136,75 +140,45 @@ class AppLocalizationsDe extends AppLocalizations { @override String moreItems(int count) { - return '+$count weitere'; + return '+$count mehr'; } @override String get noEventsOrTasks => 'Keine Termine oder Aufgaben'; @override - String get trayAgendaLoading => 'Agenda wird geladen...'; - - @override - String get trayAgendaSignInRequired => - 'Melden Sie sich an, um die Agenda anzuzeigen.'; - - @override - String get trayAgendaNoSources => - 'Keine sichtbaren Kalender oder Aufgabenlisten.'; - - @override - String get trayAgendaOpenBusyMax => 'App öffnen'; - - @override - String get trayAgendaRefresh => 'Aktualisieren'; + String get scheduleLoading => 'Zeitplan wird geladen...'; @override - String get trayAgendaError => 'Agenda nicht verfügbar'; + String get scheduleUnavailable => 'Zeitplan nicht verfügbar'; @override - String get compactAgendaTitle => 'Agenda'; + String get scheduleNoSources => + 'Keine sichtbaren Kalender oder Aufgabenlisten'; @override - String get compactAgendaSubtitle => 'Anstehend'; + String get scheduleNoSourcesDescription => + 'Wählen Sie in den Einstellungen aus, was angezeigt werden soll, und aktualisieren Sie anschließend den Zeitplan.'; @override - String get compactAgendaOverdue => 'Überfällig'; + String get scheduleSignInRequired => 'Konto verbinden'; @override - String get compactAgendaClear => 'Im Moment frei'; + String get scheduleSignInDescription => + 'Melden Sie sich an, um Kalender und Aufgaben zu synchronisieren.'; @override - String get compactAgendaOpenBusyMax => 'BusyMax öffnen'; + String get scheduleNoSearchResults => 'Keine passenden Termine oder Aufgaben'; @override - String get compactAgendaHide => 'Ausblenden'; + String get scheduleNoSearchResultsDescription => + 'Versuchen Sie es mit einer anderen Suche oder setzen Sie die aktuellen Filter zurück.'; @override - String get compactAgendaNewTask => 'Neue Aufgabe'; - - @override - String get compactAgendaRetry => 'Erneut versuchen'; - - @override - String get compactAgendaRefresh => 'Aktualisieren'; - - @override - String get compactAgendaAllDay => 'Ganztägig'; - - @override - String get compactAgendaDueToday => 'Heute fällig'; - - @override - String get compactAgendaDueTomorrow => 'Morgen fällig'; - - @override - String compactAgendaDueOn(String date) { - return 'Fällig $date'; - } + String get refresh => 'Aktualisieren'; @override - String get compactAgendaMoreOverdue => 'Weitere überfällige Aufgaben laden'; + String get trayOpenBusyMax => 'BusyMax öffnen'; @override String get agendaLoadMoreOverdue => 'Weitere überfällige Aufgaben laden'; @@ -235,7 +209,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get scheduleDisplayHoursDescription => - 'Tages- und Wochenansicht öffnen innerhalb dieser Zeiten. Frühe und späte Einträge erweitern den Bereich bei Bedarf.'; + 'In der Tages- und Wochenansicht wird zunächst dieser Zeitraum angezeigt. Frühere oder spätere Einträge erweitern ihn bei Bedarf.'; @override String get scheduleDayStartsAt => 'Tag beginnt um'; @@ -312,6 +286,90 @@ class AppLocalizationsDe extends AppLocalizations { @override String get providerCalendar => 'Anbieterkalender'; + @override + String get formatBoldShortLabel => 'F'; + + @override + String get formatBoldTooltip => 'Fett'; + + @override + String get formatItalicShortLabel => 'K'; + + @override + String get formatItalicTooltip => 'Kursiv'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => 'Unterstrichen'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes Minuten vorher', + one: '1 Minute vorher', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'Zum Startzeitpunkt'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours Stunden vorher', + one: '1 Stunde vorher', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days Tage vorher', + one: '1 Tag vorher', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Frei'; + + @override + String get availabilityTentative => 'Mit Vorbehalt'; + + @override + String get availabilityOutOfOffice => 'Abwesend'; + + @override + String get availabilityWorkingElsewhere => 'An einem anderen Ort tätig'; + + @override + String get visibilityDefault => 'Standard'; + + @override + String get visibilityPublic => 'Öffentlich'; + + @override + String get visibilityPrivate => 'Privat'; + + @override + String get visibilityConfidential => 'Vertraulich'; + + @override + String get sensitivityNormal => 'Normal'; + + @override + String get sensitivityPersonal => 'Persönlich'; + @override String get tasks => 'Aufgaben'; @@ -360,7 +418,7 @@ class AppLocalizationsDe extends AppLocalizations { 'Vorherige Woche in der Wochenansicht, vorheriger Monat in der Monatsansicht usw.'; @override - String get shortcutJumpToToday => 'Zu Heute springen'; + String get shortcutJumpToToday => 'Zum heutigen Tag springen'; @override String get shortcutGroupView => 'Ansicht'; @@ -400,24 +458,22 @@ class AppLocalizationsDe extends AppLocalizations { 'Aufgabenbearbeitung oder Aufgabendetails schließen'; @override - String get shortcutGroupCompactAgenda => 'Kompakte Agenda'; + String get aboutBusyMax => 'Über BusyMax'; @override - String get shortcutRefreshCompactAgendaDescription => - 'Das kompakte Agenda-Fenster aktualisieren'; + String get aboutBusyMaxDescription => 'Kalender und Aufgaben'; @override - String get shortcutHideCompactAgendaDescription => - 'Das kompakte Agenda-Fenster ausblenden'; + String get license => 'Lizenz'; @override - String get aboutBusyMax => 'Über BusyMax'; + String get apacheLicenseName => 'Apache License 2.0'; @override - String get aboutBusyMaxDescription => 'ToDo und Kalender'; + String get website => 'Website'; @override - String get website => 'Website'; + String get sourceCode => 'Quellcode'; @override String get reportAnIssue => 'Problem melden'; @@ -458,7 +514,7 @@ class AppLocalizationsDe extends AppLocalizations { String get feedbackDetailedMessage => 'Ausführliche Nachricht'; @override - String get feedbackReplyEmail => 'E-Mail-Adresse für Antwort (optional)'; + String get feedbackReplyEmail => 'E-Mail-Adresse für Antworten (optional)'; @override String get feedbackIncludeTechnicalDetails => 'Technische Details hinzufügen'; @@ -510,6 +566,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get toggleSidebar => 'Seitenleiste umschalten'; + @override + String get showSidebar => 'Seitenbereich anzeigen'; + + @override + String get hideSidebar => 'Seitenbereich ausblenden'; + @override String get accounts => 'Konten'; @@ -535,17 +597,42 @@ class AppLocalizationsDe extends AppLocalizations { String get signedInAccount => 'Angemeldet'; @override - String get signOutThisAccount => 'Dieses Konto abmelden'; + String get removeAccount => 'Konto entfernen…'; @override - String get revokeThisAccount => 'Dieses Konto widerrufen'; + String get removingAccount => 'Konto wird entfernt…'; + + @override + String get removeAccountDescription => + 'Synchronisierung beenden und die Daten dieses Kontos von diesem Gerät entfernen.'; + + @override + String removeAccountTitle(String account) { + return '$account aus BusyMax entfernen?'; + } @override - String get disconnectThisAccount => 'Dieses Konto trennen'; + String get removeAccountConfirmation => + 'Dadurch werden zwischengespeicherte Aufgaben, Kalender, Termine, Erinnerungen und ausstehende Offline-Änderungen von diesem Gerät gelöscht. Nicht synchronisierte Änderungen gehen verloren. Bei Google oder Microsoft wird nichts gelöscht.'; @override - String get deleteLocalDataForThisAccount => - 'Lokale Daten für dieses Konto löschen'; + String get revokeGoogleAccess => + 'BusyMax-Zugriff auf dieses Google-Konto ebenfalls widerrufen'; + + @override + String get revokeGoogleAccessDescription => + 'Vor einer erneuten Verbindung müssen Sie den Zugriff wieder gewähren.'; + + @override + String get removeAccountAction => 'Konto entfernen'; + + @override + String get removeAccountFailed => + 'Das Konto konnte nicht vollständig entfernt werden. Versuchen Sie es erneut.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'Das Konto wurde von diesem Gerät entfernt, aber BusyMax konnte den Google-Zugriff nicht widerrufen. Sie können ihn in Ihrem Google-Konto widerrufen.'; @override String get newList => 'Neue Liste'; @@ -585,7 +672,7 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get deleteEvent => 'Ereignis löschen'; + String get deleteEvent => 'Termin löschen'; @override String get title => 'Titel'; @@ -628,7 +715,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get selectOrCreateTaskList => - 'Wählen oder erstellen Sie eine Aufgabenliste.'; + 'Wählen oder erstellen Sie zunächst eine Aufgabenliste.'; @override String get signInToViewTasks => 'Melden Sie sich an, um Aufgaben zu sehen.'; @@ -641,7 +728,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get noTasksYetMessage => - 'Erstellen Sie eine Aufgabe oder aktualisieren Sie Ihre Konten.'; + 'Erstellen Sie eine Aufgabe oder aktualisieren Sie Ihre Konten, um loszulegen.'; @override String get noTasksInList => 'Keine Aufgaben in dieser Liste.'; @@ -674,21 +761,6 @@ class AppLocalizationsDe extends AppLocalizations { return '$date, $time'; } - @override - String get searchTasks => 'Aufgaben suchen'; - - @override - String get advancedFilters => 'Erweiterte Filter'; - - @override - String get showCompleted => 'Erledigte anzeigen'; - - @override - String get showHidden => 'Ausgeblendete anzeigen'; - - @override - String get showAssigned => 'Zugewiesene anzeigen'; - @override String get taskDetails => 'Aufgabendetails'; @@ -843,7 +915,7 @@ class AppLocalizationsDe extends AppLocalizations { String get createSubtask => 'Unteraufgabe erstellen'; @override - String get moveToTop => 'Nach oben verschieben'; + String get moveToTop => 'Ganz nach oben verschieben'; @override String get deleteTask => 'Aufgabe löschen'; @@ -869,7 +941,7 @@ class AppLocalizationsDe extends AppLocalizations { String get updated => 'Aktualisiert'; @override - String get parent => 'Übergeordnet'; + String get parent => 'Übergeordnete Aufgabe'; @override String get position => 'Position'; @@ -893,23 +965,25 @@ class AppLocalizationsDe extends AppLocalizations { String get account => 'Konto'; @override - String get signOut => 'Abmelden'; + String get sync => 'Synchronisierung'; @override - String get revokeGoogleAuthorization => 'Google-Autorisierung widerrufen'; + String get manualFullSync => 'Manuelle vollständige Synchronisierung'; @override - String get deleteLocalData => 'Lokale Daten löschen'; + String get runInBackgroundWhenClosed => + 'Nach dem Schließen des Fensters weiter ausführen'; @override - String get deleteLocalDataConfirmation => - 'Dies entfernt das lokale Konto, synchronisierte Aufgaben und ausstehende Offline-Änderungen von diesem Gerät.'; + String get showTrayIcon => 'Symbol im Benachrichtigungsbereich anzeigen'; @override - String get sync => 'Synchronisierung'; + String get startMinimizedToTray => + 'Minimiert im Benachrichtigungsbereich starten'; @override - String get manualFullSync => 'Manuelle vollständige Synchronisierung'; + String get requiresTrayIcon => + 'Erfordert das Symbol im Benachrichtigungsbereich.'; @override String get syncComplete => 'Synchronisierung abgeschlossen.'; @@ -929,6 +1003,34 @@ class AppLocalizationsDe extends AppLocalizations { @override String get notifyDueToday => 'Benachrichtigungen für heute fällige Aufgaben'; + @override + String get eventReminders => 'Terminerinnerungen'; + + @override + String get taskReminders => 'Aufgabenerinnerungen'; + + @override + String get notificationDetailLevel => 'Detailgrad der Benachrichtigungen'; + + @override + String get notificationDetailPrivate => 'Privat'; + + @override + String get notificationDetailNormal => 'Normal'; + + @override + String get quietHours => 'Ruhezeiten'; + + @override + String get quietHoursDescription => + 'Benachrichtigungen während dieses Zeitraums pausieren.'; + + @override + String get quietHoursStart => 'Beginn der Ruhezeit'; + + @override + String get quietHoursEnd => 'Ende der Ruhezeit'; + @override String get notifications => 'Benachrichtigungen'; @@ -936,7 +1038,7 @@ class AppLocalizationsDe extends AppLocalizations { String get appearance => 'Darstellung'; @override - String get theme => 'Theme'; + String get theme => 'Design'; @override String get themeSystem => 'System'; @@ -948,10 +1050,10 @@ class AppLocalizationsDe extends AppLocalizations { String get themeDark => 'Dunkel'; @override - String get themeFamily => 'Theme-Familie'; + String get themeFamily => 'Designfamilie'; @override - String get themeFamilyYaru => 'Natives Ubuntu (Yaru)'; + String get themeFamilyYaru => 'Natives Ubuntu-Design (Yaru)'; @override String get localization => 'Lokalisierung'; @@ -966,9 +1068,6 @@ class AppLocalizationsDe extends AppLocalizations { String get redactTaskContentInDiagnostics => 'Aufgabeninhalte in Diagnosen schwärzen'; - @override - String get detailedNotifications => 'Detaillierte Benachrichtigungstexte'; - @override String get developerDiagnostics => 'Entwicklerdiagnose'; @@ -991,10 +1090,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get supportsTasksScopes => - 'Unterstützt tasks- und tasks.readonly-Berechtigungen'; + 'Unterstützt die Berechtigungsbereiche tasks und tasks.readonly'; @override - String get requiresTasksScope => 'Benötigt tasks-Berechtigung'; + String get requiresTasksScope => 'Erfordert den Berechtigungsbereich tasks'; @override String get blockedPendingOperations => 'Blockierte ausstehende Vorgänge'; @@ -1031,6 +1130,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get discard => 'Verwerfen'; + @override + String get discardChangesAction => 'Verwerfen'; + @override String get discardChanges => 'Änderungen verwerfen?'; @@ -1046,7 +1148,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get discardPendingOperationConfirmation => - 'Dies entfernt den blockierten lokalen Vorgang. Die nächste Synchronisierung aktualisiert von Google Tasks.'; + 'Dadurch wird der blockierte lokale Vorgang entfernt. Bei der nächsten Synchronisierung werden die Daten aus Google Tasks neu geladen.'; @override String get pendingOperationDiscarded => 'Ausstehender Vorgang verworfen.'; @@ -1082,7 +1184,77 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } + @override + String get eventReminderNotificationTitle => 'Terminerinnerung'; + + @override + String get taskReminderNotificationTitle => 'Aufgabenerinnerung'; + + @override + String get eventReminderNotificationBody => 'Der Termin beginnt bald.'; + + @override + String get taskReminderNotificationBody => 'Die Aufgabe ist bald fällig.'; + + @override + String get notificationOpenAction => 'Öffnen'; + @override String get notificationDetailsHidden => 'Details werden durch Datenschutzeinstellungen ausgeblendet.'; + + @override + String get previousMonth => 'Vorheriger Monat'; + + @override + String get nextMonth => 'Nächster Monat'; + + @override + String get openMonthView => 'Monatsansicht öffnen'; + + @override + String get previousYear => 'Vorheriges Jahr'; + + @override + String get nextYear => 'Nächstes Jahr'; + + @override + String get openYearView => 'Jahresansicht öffnen'; + + @override + String weekNumberTooltip(int number) { + return 'Woche $number'; + } + + @override + String get resizeAllDayPanel => + 'Ganztägigen Bereich vergrößern oder verkleinern'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Einträge', + one: '1 Eintrag', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Dieser Kalender ist schreibgeschützt.'; + + @override + String get selectTimeZone => 'Zeitzone auswählen'; + + @override + String get searchLocations => 'Orte suchen'; + + @override + String get noLocationsFound => 'Keine Orte gefunden'; + + @override + String deleteCalendarConfirmation(String title) { + return '\"$title\" löschen?'; + } } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 684be92..e858e70 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1,3 +1,7 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; @@ -132,7 +136,7 @@ class AppLocalizationsEn extends AppLocalizations { String get noCalendarsSynced => 'No calendars synced yet.'; @override - String get allDay => 'All Day'; + String get allDay => 'All day'; @override String moreItems(int count) { @@ -143,66 +147,37 @@ class AppLocalizationsEn extends AppLocalizations { String get noEventsOrTasks => 'No events or tasks'; @override - String get trayAgendaLoading => 'Loading agenda...'; - - @override - String get trayAgendaSignInRequired => 'Sign in to show agenda.'; - - @override - String get trayAgendaNoSources => 'No visible calendars or task lists.'; - - @override - String get trayAgendaOpenBusyMax => 'Open app'; - - @override - String get trayAgendaRefresh => 'Refresh'; + String get scheduleLoading => 'Loading schedule...'; @override - String get trayAgendaError => 'Agenda unavailable'; + String get scheduleUnavailable => 'Schedule unavailable'; @override - String get compactAgendaTitle => 'Agenda'; + String get scheduleNoSources => 'No visible calendars or task lists'; @override - String get compactAgendaSubtitle => 'Upcoming'; + String get scheduleNoSourcesDescription => + 'Choose what to show in Settings, then refresh.'; @override - String get compactAgendaOverdue => 'Overdue'; + String get scheduleSignInRequired => 'Connect an account'; @override - String get compactAgendaClear => 'Clear for now'; + String get scheduleSignInDescription => + 'Sign in to sync calendars and tasks.'; @override - String get compactAgendaOpenBusyMax => 'Open BusyMax'; + String get scheduleNoSearchResults => 'No matching events or tasks'; @override - String get compactAgendaHide => 'Hide'; + String get scheduleNoSearchResultsDescription => + 'Try a different search or clear the current filters.'; @override - String get compactAgendaNewTask => 'New task'; - - @override - String get compactAgendaRetry => 'Retry'; - - @override - String get compactAgendaRefresh => 'Refresh'; - - @override - String get compactAgendaAllDay => 'All day'; - - @override - String get compactAgendaDueToday => 'Due today'; - - @override - String get compactAgendaDueTomorrow => 'Due tomorrow'; - - @override - String compactAgendaDueOn(String date) { - return 'Due $date'; - } + String get refresh => 'Refresh'; @override - String get compactAgendaMoreOverdue => 'Load more overdue tasks'; + String get trayOpenBusyMax => 'Open BusyMax'; @override String get agendaLoadMoreOverdue => 'Load more overdue tasks'; @@ -266,7 +241,7 @@ class AppLocalizationsEn extends AppLocalizations { String get location => 'Location'; @override - String get timeSlot => 'Time Slot'; + String get timeSlot => 'Time slot'; @override String get startDateTime => 'Start date/time'; @@ -310,6 +285,90 @@ class AppLocalizationsEn extends AppLocalizations { @override String get providerCalendar => 'Provider calendar'; + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => 'Bold'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'Italic'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => 'Underline'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutes before', + one: '1 minute before', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'At start'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours hours before', + one: '1 hour before', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days days before', + one: '1 day before', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Free'; + + @override + String get availabilityTentative => 'Tentative'; + + @override + String get availabilityOutOfOffice => 'Out of office'; + + @override + String get availabilityWorkingElsewhere => 'Working elsewhere'; + + @override + String get visibilityDefault => 'Default'; + + @override + String get visibilityPublic => 'Public'; + + @override + String get visibilityPrivate => 'Private'; + + @override + String get visibilityConfidential => 'Confidential'; + + @override + String get sensitivityNormal => 'Normal'; + + @override + String get sensitivityPersonal => 'Personal'; + @override String get tasks => 'Tasks'; @@ -398,24 +457,22 @@ class AppLocalizationsEn extends AppLocalizations { 'Close task editing or task details'; @override - String get shortcutGroupCompactAgenda => 'Compact agenda'; + String get aboutBusyMax => 'About BusyMax'; @override - String get shortcutRefreshCompactAgendaDescription => - 'Refresh the compact agenda window'; + String get aboutBusyMaxDescription => 'Calendar and tasks'; @override - String get shortcutHideCompactAgendaDescription => - 'Hide the compact agenda window'; + String get license => 'License'; @override - String get aboutBusyMax => 'About BusyMax'; + String get apacheLicenseName => 'Apache License 2.0'; @override - String get aboutBusyMaxDescription => 'ToDo and Calendar'; + String get website => 'Website'; @override - String get website => 'Website'; + String get sourceCode => 'Source code'; @override String get reportAnIssue => 'Report an issue'; @@ -505,6 +562,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get toggleSidebar => 'Toggle Sidebar'; + @override + String get showSidebar => 'Show sidebar panel'; + + @override + String get hideSidebar => 'Hide sidebar panel'; + @override String get accounts => 'Accounts'; @@ -530,17 +593,42 @@ class AppLocalizationsEn extends AppLocalizations { String get signedInAccount => 'Signed in'; @override - String get signOutThisAccount => 'Sign out this account'; + String get removeAccount => 'Remove account…'; @override - String get revokeThisAccount => 'Revoke this account'; + String get removingAccount => 'Removing account…'; + + @override + String get removeAccountDescription => + 'Stop syncing and remove this account’s data from this device.'; + + @override + String removeAccountTitle(String account) { + return 'Remove $account from BusyMax?'; + } @override - String get disconnectThisAccount => 'Disconnect this account'; + String get removeAccountConfirmation => + 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Nothing will be deleted from Google or Microsoft.'; @override - String get deleteLocalDataForThisAccount => - 'Delete local data for this account'; + String get revokeGoogleAccess => + 'Also revoke BusyMax’s access to this Google Account'; + + @override + String get revokeGoogleAccessDescription => + 'You will need to grant access again before reconnecting.'; + + @override + String get removeAccountAction => 'Remove account'; + + @override + String get removeAccountFailed => + 'Could not finish removing the account. Try again.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'The account was removed from this device, but BusyMax could not revoke Google access. You can revoke it from your Google Account.'; @override String get newList => 'New list'; @@ -667,21 +755,6 @@ class AppLocalizationsEn extends AppLocalizations { return '$date · $time'; } - @override - String get searchTasks => 'Search tasks'; - - @override - String get advancedFilters => 'Advanced filters'; - - @override - String get showCompleted => 'Show completed'; - - @override - String get showHidden => 'Show hidden'; - - @override - String get showAssigned => 'Show assigned'; - @override String get taskDetails => 'Task details'; @@ -884,23 +957,23 @@ class AppLocalizationsEn extends AppLocalizations { String get account => 'Account'; @override - String get signOut => 'Sign out'; + String get sync => 'Sync'; @override - String get revokeGoogleAuthorization => 'Revoke Google authorization'; + String get manualFullSync => 'Manual full sync'; @override - String get deleteLocalData => 'Delete local data'; + String get runInBackgroundWhenClosed => + 'Continue running when the window is closed'; @override - String get deleteLocalDataConfirmation => - 'This removes the local account, synced tasks, and pending offline changes from this device.'; + String get showTrayIcon => 'Show tray icon'; @override - String get sync => 'Sync'; + String get startMinimizedToTray => 'Start minimized to the tray'; @override - String get manualFullSync => 'Manual full sync'; + String get requiresTrayIcon => 'Requires the tray icon.'; @override String get syncComplete => 'Sync complete.'; @@ -919,6 +992,33 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notifyDueToday => 'Due-today notifications'; + @override + String get eventReminders => 'Event reminders'; + + @override + String get taskReminders => 'Task reminders'; + + @override + String get notificationDetailLevel => 'Notification detail level'; + + @override + String get notificationDetailPrivate => 'Private'; + + @override + String get notificationDetailNormal => 'Normal'; + + @override + String get quietHours => 'Quiet hours'; + + @override + String get quietHoursDescription => 'Pause notifications during this period.'; + + @override + String get quietHoursStart => 'Quiet hours start'; + + @override + String get quietHoursEnd => 'Quiet hours end'; + @override String get notifications => 'Notifications'; @@ -956,9 +1056,6 @@ class AppLocalizationsEn extends AppLocalizations { String get redactTaskContentInDiagnostics => 'Redact task content in diagnostics'; - @override - String get detailedNotifications => 'Detailed notification text'; - @override String get developerDiagnostics => 'Developer diagnostics'; @@ -1019,6 +1116,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get discard => 'Discard'; + @override + String get discardChangesAction => 'Discard'; + @override String get discardChanges => 'Discard changes?'; @@ -1069,7 +1169,76 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } + @override + String get eventReminderNotificationTitle => 'Event reminder'; + + @override + String get taskReminderNotificationTitle => 'Task reminder'; + + @override + String get eventReminderNotificationBody => 'Event starts soon.'; + + @override + String get taskReminderNotificationBody => 'Task is due soon.'; + + @override + String get notificationOpenAction => 'Open'; + @override String get notificationDetailsHidden => 'Details are hidden by privacy settings.'; + + @override + String get previousMonth => 'Previous month'; + + @override + String get nextMonth => 'Next month'; + + @override + String get openMonthView => 'Open month view'; + + @override + String get previousYear => 'Previous year'; + + @override + String get nextYear => 'Next year'; + + @override + String get openYearView => 'Open year view'; + + @override + String weekNumberTooltip(int number) { + return 'Week $number'; + } + + @override + String get resizeAllDayPanel => 'Resize the all-day panel'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count items', + one: '1 item', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'This calendar is read-only.'; + + @override + String get selectTimeZone => 'Select Timezone'; + + @override + String get searchLocations => 'Search locations'; + + @override + String get noLocationsFound => 'No locations found'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Delete \"$title\"?'; + } } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 519ff32..6a0da5f 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1,3 +1,7 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; @@ -44,7 +48,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get onboardingPreferencesStepDescription => - 'Configura el comportamiento de escritorio, recordatorios, detalle de notificaciones y apariencia antes de abrir tu agenda.'; + 'Configura el comportamiento de la aplicación en el escritorio, los recordatorios, el nivel de detalle de las notificaciones y la apariencia antes de abrir tu agenda.'; @override String get signInWithGoogle => 'Iniciar sesión con Google'; @@ -122,7 +126,7 @@ class AppLocalizationsEs extends AppLocalizations { String get refreshCalendar => 'Actualizar calendario'; @override - String get openInProvider => 'Abrir en proveedor'; + String get openInProvider => 'Abrir en el proveedor'; @override String get hideFromSchedule => 'Ocultar de la agenda'; @@ -145,68 +149,38 @@ class AppLocalizationsEs extends AppLocalizations { String get noEventsOrTasks => 'No hay eventos ni tareas'; @override - String get trayAgendaLoading => 'Cargando agenda...'; - - @override - String get trayAgendaSignInRequired => - 'Inicia sesión para mostrar la agenda.'; - - @override - String get trayAgendaNoSources => - 'No hay calendarios ni listas de tareas visibles.'; - - @override - String get trayAgendaOpenBusyMax => 'Abrir app'; - - @override - String get trayAgendaRefresh => 'Actualizar'; + String get scheduleLoading => 'Cargando la agenda...'; @override - String get trayAgendaError => 'Agenda no disponible'; + String get scheduleUnavailable => 'Agenda no disponible'; @override - String get compactAgendaTitle => 'Agenda'; + String get scheduleNoSources => + 'No hay calendarios ni listas de tareas visibles'; @override - String get compactAgendaSubtitle => 'Próximamente'; + String get scheduleNoSourcesDescription => + 'Elige qué mostrar en Configuración y, después, actualiza la agenda.'; @override - String get compactAgendaOverdue => 'Vencidas'; + String get scheduleSignInRequired => 'Conectar una cuenta'; @override - String get compactAgendaClear => 'Libre por ahora'; + String get scheduleSignInDescription => + 'Inicia sesión para sincronizar calendarios y tareas.'; @override - String get compactAgendaOpenBusyMax => 'Abrir BusyMax'; + String get scheduleNoSearchResults => 'No hay eventos ni tareas coincidentes'; @override - String get compactAgendaHide => 'Ocultar'; + String get scheduleNoSearchResultsDescription => + 'Prueba con otra búsqueda o borra los filtros actuales.'; @override - String get compactAgendaNewTask => 'Nueva tarea'; - - @override - String get compactAgendaRetry => 'Reintentar'; - - @override - String get compactAgendaRefresh => 'Actualizar'; - - @override - String get compactAgendaAllDay => 'Todo el día'; - - @override - String get compactAgendaDueToday => 'Vence hoy'; - - @override - String get compactAgendaDueTomorrow => 'Vence mañana'; - - @override - String compactAgendaDueOn(String date) { - return 'Vence $date'; - } + String get refresh => 'Actualizar'; @override - String get compactAgendaMoreOverdue => 'Cargar más tareas vencidas'; + String get trayOpenBusyMax => 'Abrir BusyMax'; @override String get agendaLoadMoreOverdue => 'Cargar más tareas vencidas'; @@ -237,7 +211,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get scheduleDisplayHoursDescription => - 'Las vistas Día y Semana se abren dentro de este horario. Los elementos tempranos y tardíos amplían el intervalo si hace falta.'; + 'Las vistas de día y semana muestran inicialmente este intervalo horario. Los elementos anteriores o posteriores lo amplían cuando es necesario.'; @override String get scheduleDayStartsAt => 'El día empieza a las'; @@ -314,6 +288,90 @@ class AppLocalizationsEs extends AppLocalizations { @override String get providerCalendar => 'Calendario del proveedor'; + @override + String get formatBoldShortLabel => 'N'; + + @override + String get formatBoldTooltip => 'Negrita'; + + @override + String get formatItalicShortLabel => 'C'; + + @override + String get formatItalicTooltip => 'Cursiva'; + + @override + String get formatUnderlineShortLabel => 'S'; + + @override + String get formatUnderlineTooltip => 'Subrayado'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutos antes', + one: '1 minuto antes', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'A la hora de inicio'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours horas antes', + one: '1 hora antes', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days días antes', + one: '1 día antes', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Libre'; + + @override + String get availabilityTentative => 'Provisional'; + + @override + String get availabilityOutOfOffice => 'Fuera de la oficina'; + + @override + String get availabilityWorkingElsewhere => 'Trabajando en otro lugar'; + + @override + String get visibilityDefault => 'Predeterminada'; + + @override + String get visibilityPublic => 'Pública'; + + @override + String get visibilityPrivate => 'Privada'; + + @override + String get visibilityConfidential => 'Confidencial'; + + @override + String get sensitivityNormal => 'Normal'; + + @override + String get sensitivityPersonal => 'Personal'; + @override String get tasks => 'Tareas'; @@ -362,7 +420,7 @@ class AppLocalizationsEs extends AppLocalizations { 'Semana anterior en la vista semanal, mes anterior en la vista mensual, etc.'; @override - String get shortcutJumpToToday => 'Ir a hoy'; + String get shortcutJumpToToday => 'Ir a la fecha de hoy'; @override String get shortcutGroupView => 'Vista'; @@ -402,24 +460,22 @@ class AppLocalizationsEs extends AppLocalizations { 'Cerrar la edición o los detalles de la tarea'; @override - String get shortcutGroupCompactAgenda => 'Agenda compacta'; + String get aboutBusyMax => 'Acerca de BusyMax'; @override - String get shortcutRefreshCompactAgendaDescription => - 'Actualizar la ventana de agenda compacta'; + String get aboutBusyMaxDescription => 'Calendario y tareas'; @override - String get shortcutHideCompactAgendaDescription => - 'Ocultar la ventana de agenda compacta'; + String get license => 'Licencia'; @override - String get aboutBusyMax => 'Acerca de BusyMax'; + String get apacheLicenseName => 'Apache License 2.0'; @override - String get aboutBusyMaxDescription => 'ToDo y calendario'; + String get website => 'Sitio web'; @override - String get website => 'Sitio web'; + String get sourceCode => 'Código fuente'; @override String get reportAnIssue => 'Informar de un problema'; @@ -459,7 +515,8 @@ class AppLocalizationsEs extends AppLocalizations { String get feedbackDetailedMessage => 'Mensaje detallado'; @override - String get feedbackReplyEmail => 'Correo electrónico de respuesta (opcional)'; + String get feedbackReplyEmail => + 'Correo electrónico para recibir una respuesta (opcional)'; @override String get feedbackIncludeTechnicalDetails => 'Incluir detalles técnicos'; @@ -489,7 +546,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get feedbackTimeoutError => - 'La solicitud agotó el tiempo de espera. Tus comentarios no se han borrado; inténtalo de nuevo.'; + 'Se agotó el tiempo de espera de la solicitud. Tus comentarios no se han borrado; inténtalo de nuevo.'; @override String get feedbackRateLimitedError => @@ -509,7 +566,13 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get toggleSidebar => 'Alternar barra lateral'; + String get toggleSidebar => 'Mostrar u ocultar la barra lateral'; + + @override + String get showSidebar => 'Mostrar panel lateral'; + + @override + String get hideSidebar => 'Ocultar panel lateral'; @override String get accounts => 'Cuentas'; @@ -536,17 +599,42 @@ class AppLocalizationsEs extends AppLocalizations { String get signedInAccount => 'Sesión iniciada'; @override - String get signOutThisAccount => 'Cerrar sesión de esta cuenta'; + String get removeAccount => 'Eliminar cuenta…'; @override - String get revokeThisAccount => 'Revocar esta cuenta'; + String get removingAccount => 'Eliminando cuenta…'; + + @override + String get removeAccountDescription => + 'Detener la sincronización y eliminar de este dispositivo los datos de esta cuenta.'; + + @override + String removeAccountTitle(String account) { + return '¿Eliminar $account de BusyMax?'; + } @override - String get disconnectThisAccount => 'Desconectar esta cuenta'; + String get removeAccountConfirmation => + 'Esto elimina de este dispositivo las tareas, los calendarios, los eventos, los recordatorios y los cambios sin conexión pendientes almacenados en caché. Los cambios no sincronizados se perderán. No se eliminará nada de Google ni Microsoft.'; @override - String get deleteLocalDataForThisAccount => - 'Eliminar datos locales de esta cuenta'; + String get revokeGoogleAccess => + 'Revocar también el acceso de BusyMax a esta cuenta de Google'; + + @override + String get revokeGoogleAccessDescription => + 'Tendrás que volver a conceder acceso antes de reconectar la cuenta.'; + + @override + String get removeAccountAction => 'Eliminar cuenta'; + + @override + String get removeAccountFailed => + 'No se pudo terminar de eliminar la cuenta. Inténtalo de nuevo.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'La cuenta se eliminó de este dispositivo, pero BusyMax no pudo revocar su acceso a tu cuenta de Google. Puedes revocarlo desde tu cuenta de Google.'; @override String get newList => 'Nueva lista'; @@ -628,7 +716,8 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get selectOrCreateTaskList => 'Selecciona o crea una lista de tareas.'; + String get selectOrCreateTaskList => + 'Selecciona o crea una lista de tareas para empezar.'; @override String get signInToViewTasks => 'Inicia sesión para ver las tareas.'; @@ -674,21 +763,6 @@ class AppLocalizationsEs extends AppLocalizations { return '$date, $time'; } - @override - String get searchTasks => 'Buscar tareas'; - - @override - String get advancedFilters => 'Filtros avanzados'; - - @override - String get showCompleted => 'Mostrar completadas'; - - @override - String get showHidden => 'Mostrar ocultas'; - - @override - String get showAssigned => 'Mostrar asignadas'; - @override String get taskDetails => 'Detalles de la tarea'; @@ -836,13 +910,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get microsoftMoveUnsupported => - 'Mover tareas entre listas no es compatible con las cuentas de Microsoft To Do en esta versión.'; + 'En esta versión, no se pueden mover tareas entre listas en cuentas de Microsoft To Do.'; @override String get createSubtask => 'Crear subtarea'; @override - String get moveToTop => 'Mover arriba'; + String get moveToTop => 'Mover al principio'; @override String get deleteTask => 'Eliminar tarea'; @@ -868,7 +942,7 @@ class AppLocalizationsEs extends AppLocalizations { String get updated => 'Actualizada'; @override - String get parent => 'Padre'; + String get parent => 'Tarea principal'; @override String get position => 'Posición'; @@ -892,23 +966,24 @@ class AppLocalizationsEs extends AppLocalizations { String get account => 'Cuenta'; @override - String get signOut => 'Cerrar sesión'; + String get sync => 'Sincronización'; @override - String get revokeGoogleAuthorization => 'Revocar autorización de Google'; + String get manualFullSync => 'Sincronización completa manual'; @override - String get deleteLocalData => 'Eliminar datos locales'; + String get runInBackgroundWhenClosed => + 'Seguir ejecutándose al cerrar la ventana'; @override - String get deleteLocalDataConfirmation => - 'Esto elimina de este dispositivo la cuenta local, las tareas sincronizadas y los cambios sin conexión pendientes.'; + String get showTrayIcon => 'Mostrar el icono en la bandeja del sistema'; @override - String get sync => 'Sincronización'; + String get startMinimizedToTray => + 'Iniciar minimizado en la bandeja del sistema'; @override - String get manualFullSync => 'Sincronización completa manual'; + String get requiresTrayIcon => 'Requiere el icono de la bandeja del sistema.'; @override String get syncComplete => 'Sincronización completa.'; @@ -926,7 +1001,36 @@ class AppLocalizationsEs extends AppLocalizations { String get notifyConflicts => 'Notificaciones de conflictos'; @override - String get notifyDueToday => 'Notificaciones de tareas para hoy'; + String get notifyDueToday => 'Notificaciones de tareas que vencen hoy'; + + @override + String get eventReminders => 'Recordatorios de eventos'; + + @override + String get taskReminders => 'Recordatorios de tareas'; + + @override + String get notificationDetailLevel => + 'Nivel de detalle de las notificaciones'; + + @override + String get notificationDetailPrivate => 'Privado'; + + @override + String get notificationDetailNormal => 'Normal'; + + @override + String get quietHours => 'Horario de silencio'; + + @override + String get quietHoursDescription => + 'Pausar las notificaciones durante este período.'; + + @override + String get quietHoursStart => 'Inicio del horario de silencio'; + + @override + String get quietHoursEnd => 'Fin del horario de silencio'; @override String get notifications => 'Notificaciones'; @@ -947,10 +1051,10 @@ class AppLocalizationsEs extends AppLocalizations { String get themeDark => 'Oscuro'; @override - String get themeFamily => 'Familia de tema'; + String get themeFamily => 'Familia de temas'; @override - String get themeFamilyYaru => 'Ubuntu nativo (Yaru)'; + String get themeFamilyYaru => 'Tema nativo de Ubuntu (Yaru)'; @override String get localization => 'Localización'; @@ -965,9 +1069,6 @@ class AppLocalizationsEs extends AppLocalizations { String get redactTaskContentInDiagnostics => 'Ocultar contenido de tareas en diagnósticos'; - @override - String get detailedNotifications => 'Texto detallado en notificaciones'; - @override String get developerDiagnostics => 'Diagnósticos de desarrollo'; @@ -1007,7 +1108,7 @@ class AppLocalizationsEs extends AppLocalizations { 'No hay operaciones pendientes bloqueadas.'; @override - String get operationActions => 'Acciones de operación'; + String get operationActions => 'Acciones de la operación'; @override String pendingOpListId(String id) { @@ -1030,6 +1131,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get discard => 'Descartar'; + @override + String get discardChangesAction => 'Descartar'; + @override String get discardChanges => '¿Descartar cambios?'; @@ -1045,7 +1149,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get discardPendingOperationConfirmation => - 'Esto elimina la operación local bloqueada. La próxima sincronización actualizará desde Google Tasks.'; + 'Esto elimina la operación local bloqueada. En la próxima sincronización, se volverán a cargar los datos desde Google Tasks.'; @override String get pendingOperationDiscarded => 'Operación pendiente descartada.'; @@ -1082,7 +1186,76 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } + @override + String get eventReminderNotificationTitle => 'Recordatorio de evento'; + + @override + String get taskReminderNotificationTitle => 'Recordatorio de tarea'; + + @override + String get eventReminderNotificationBody => 'El evento empieza pronto.'; + + @override + String get taskReminderNotificationBody => 'La tarea vence pronto.'; + + @override + String get notificationOpenAction => 'Abrir'; + @override String get notificationDetailsHidden => 'Los detalles están ocultos por la configuración de privacidad.'; + + @override + String get previousMonth => 'Mes anterior'; + + @override + String get nextMonth => 'Mes siguiente'; + + @override + String get openMonthView => 'Abrir la vista mensual'; + + @override + String get previousYear => 'Año anterior'; + + @override + String get nextYear => 'Año siguiente'; + + @override + String get openYearView => 'Abrir la vista anual'; + + @override + String weekNumberTooltip(int number) { + return 'Semana $number'; + } + + @override + String get resizeAllDayPanel => 'Cambiar el tamaño del panel de todo el día'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count elementos', + one: '1 elemento', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Este calendario es de solo lectura.'; + + @override + String get selectTimeZone => 'Seleccionar zona horaria'; + + @override + String get searchLocations => 'Buscar ubicaciones'; + + @override + String get noLocationsFound => 'No se encontraron ubicaciones'; + + @override + String deleteCalendarConfirmation(String title) { + return '¿Eliminar \"$title\"?'; + } } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart new file mode 100644 index 0000000..cba33c2 --- /dev/null +++ b/lib/l10n/generated/app_localizations_et.dart @@ -0,0 +1,1251 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Estonian (`et`). +class AppLocalizationsEt extends AppLocalizations { + AppLocalizationsEt([String locale = 'et']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Ühendage Google\'i ja Microsofti kontod kalendrite ja ülesannete sünkroonimiseks.'; + + @override + String get googlePermissionsConsentNotice => + 'Valige Google\'i õiguste kuval nii kalendri kui ka ülesannete õigused.'; + + @override + String get googlePermissionsRequiredRetry => + 'Google Calendari ja Google Tasksi õigused on nõutavad. Proovige uuesti ja märkige mõlemad ruudud.'; + + @override + String get finishSetup => 'Lõpeta seadistamine'; + + @override + String get continueSetup => 'Jätka'; + + @override + String get onboardingSetupTitle => 'BusyMaxi seadistamine'; + + @override + String get onboardingAccountsStepTitle => 'Kontode ühendamine'; + + @override + String get onboardingAccountsStepDescription => + 'Lisage kõik Google\'i ja Microsofti kontod, mida soovite kasutada. BusyMax sünkroonib iga konto kalendrid, sündmused, ülesandeloendid ja ülesanded.'; + + @override + String get onboardingPreferencesStepTitle => 'Süsteemiseadete valimine'; + + @override + String get onboardingPreferencesStepDescription => + 'Enne ajakava avamist määrake töölauakäitumine, meeldetuletused, teavituste üksikasjalikkus ja välimus.'; + + @override + String get signInWithGoogle => 'Logi Google\'iga sisse'; + + @override + String get signInWithMicrosoft => 'Logi Microsoftiga sisse'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'See teenusepakkuja pole seadistatud.'; + + @override + String get waitingForGoogleSignIn => 'Google\'isse sisselogimise ootel...'; + + @override + String get waitingForMicrosoftSignIn => 'Microsofti sisselogimise ootel...'; + + @override + String get microsoftSignInNotConfigured => + 'Microsofti sisselogimine pole seadistatud. Määrake MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'Tühista'; + + @override + String get close => 'Sulge'; + + @override + String get exit => 'Välju'; + + @override + String get options => 'Valikud'; + + @override + String get hide => 'Peida'; + + @override + String get show => 'Kuva'; + + @override + String get export => 'Ekspordi'; + + @override + String get save => 'Salvesta'; + + @override + String get settings => 'Seaded'; + + @override + String get all => 'Kõik'; + + @override + String get calendarEvents => 'Sündmused'; + + @override + String get calendarTasks => 'Ülesanded'; + + @override + String get calendar => 'Kalender'; + + @override + String get calendars => 'Kalendrid'; + + @override + String get newEvent => 'Uus sündmus'; + + @override + String get refreshCalendar => 'Värskenda kalendrit'; + + @override + String get openInProvider => 'Ava teenuses'; + + @override + String get hideFromSchedule => 'Peida ajakavast'; + + @override + String get showInSchedule => 'Kuva ajakavas'; + + @override + String get noCalendarsSynced => 'Ühtegi kalendrit pole veel sünkroonitud.'; + + @override + String get allDay => 'Kogu päev'; + + @override + String moreItems(int count) { + return '+$count veel'; + } + + @override + String get noEventsOrTasks => 'Sündmusi ega ülesandeid pole'; + + @override + String get scheduleLoading => 'Ajakava laadimine...'; + + @override + String get scheduleUnavailable => 'Ajakava pole saadaval'; + + @override + String get scheduleNoSources => + 'Nähtavaid kalendreid ega ülesandeloendeid pole'; + + @override + String get scheduleNoSourcesDescription => + 'Valige seadetes, mida kuvada, ja seejärel värskendage.'; + + @override + String get scheduleSignInRequired => 'Ühendage konto'; + + @override + String get scheduleSignInDescription => + 'Kalendrite ja ülesannete sünkroonimiseks logige sisse.'; + + @override + String get scheduleNoSearchResults => + 'Sobivaid sündmusi ega ülesandeid ei leitud'; + + @override + String get scheduleNoSearchResultsDescription => + 'Proovige teistsugust otsingut või eemaldage praegused filtrid.'; + + @override + String get refresh => 'Värskenda'; + + @override + String get trayOpenBusyMax => 'Ava BusyMax'; + + @override + String get agendaLoadMoreOverdue => 'Laadi veel tähtaja ületanud ülesandeid'; + + @override + String get agendaLoadMoreNoDate => 'Laadi veel kuupäevata ülesandeid'; + + @override + String get viewDay => 'Päev'; + + @override + String get viewWeek => 'Nädal'; + + @override + String get viewMonth => 'Kuu'; + + @override + String get viewYear => 'Aasta'; + + @override + String get viewAgenda => 'Päevakava'; + + @override + String get scheduleSettings => 'Ajakava'; + + @override + String get scheduleDisplaySettings => 'Ajakava kuvamine'; + + @override + String get scheduleDisplayHoursDescription => + 'Päeva- ja nädalavaade avanevad nende kellaaegade piires. Vajaduse korral laiendavad varasemad ja hilisemad kirjed vahemikku.'; + + @override + String get scheduleDayStartsAt => 'Päev algab'; + + @override + String get scheduleDayEndsAt => 'Päev lõpeb'; + + @override + String get sourceCalendar => 'Kalender'; + + @override + String get sourceTaskList => 'Ülesandeloend'; + + @override + String get createChoiceTitle => 'Loo'; + + @override + String get createEventAtTime => 'Sündmus'; + + @override + String get createTaskAtDate => 'Ülesanne'; + + @override + String get editEvent => 'Muuda sündmust'; + + @override + String get eventTitle => 'Sündmuse pealkiri'; + + @override + String get location => 'Asukoht'; + + @override + String get timeSlot => 'Ajavahemik'; + + @override + String get startDateTime => 'Alguskuupäev ja -kellaaeg'; + + @override + String get endDateTime => 'Lõppkuupäev ja -kellaaeg'; + + @override + String get doesNotRepeat => 'Ei kordu'; + + @override + String get defaultReminder => 'Vaikemeeldetuletus'; + + @override + String get guests => 'Külalised'; + + @override + String get noGuests => 'Külalisi pole'; + + @override + String get description => 'Kirjeldus'; + + @override + String get availabilityShowAs => 'Hõivatus / Kuva kui'; + + @override + String get busy => 'Hõivatud'; + + @override + String get visibility => 'Nähtavus'; + + @override + String get defaultVisibility => 'Vaikimisi nähtavus'; + + @override + String get conference => 'Konverents'; + + @override + String get noConference => 'Konverentsi pole'; + + @override + String get providerCalendar => 'Teenuse kalender'; + + @override + String get formatBoldShortLabel => 'R'; + + @override + String get formatBoldTooltip => 'Rasvane'; + + @override + String get formatItalicShortLabel => 'K'; + + @override + String get formatItalicTooltip => 'Kursiiv'; + + @override + String get formatUnderlineShortLabel => 'A'; + + @override + String get formatUnderlineTooltip => 'Allajoonitud'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutit varem', + one: '1 minut varem', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'Algusajal'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours tundi varem', + one: '1 tund varem', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days päeva varem', + one: '1 päev varem', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Vaba'; + + @override + String get availabilityTentative => 'Esialgne'; + + @override + String get availabilityOutOfOffice => 'Kontorist väljas'; + + @override + String get availabilityWorkingElsewhere => 'Töötab mujal'; + + @override + String get visibilityDefault => 'Vaikimisi'; + + @override + String get visibilityPublic => 'Avalik'; + + @override + String get visibilityPrivate => 'Privaatne'; + + @override + String get visibilityConfidential => 'Konfidentsiaalne'; + + @override + String get sensitivityNormal => 'Tavaline'; + + @override + String get sensitivityPersonal => 'Isiklik'; + + @override + String get tasks => 'Ülesanded'; + + @override + String get allTasks => 'Kõik ülesanded'; + + @override + String tasksInList(String title) { + return 'Loendi „$title” ülesanded'; + } + + @override + String get taskLists => 'Ülesandeloendid'; + + @override + String get navigation => 'Navigeerimine'; + + @override + String get mainMenu => 'Peamenüü'; + + @override + String get keyboardShortcuts => 'Klaviatuuri otseteed'; + + @override + String get shortcutGroupGeneral => 'Üldine'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'Kuva klaviatuuri otseteede loend'; + + @override + String get shortcutGroupNavigation => 'Navigeerimine'; + + @override + String get shortcutNextPeriod => 'Järgmine periood'; + + @override + String get shortcutNextPeriodDescription => + 'Nädalavaates järgmine nädal, kuuvaates järgmine kuu jne'; + + @override + String get shortcutPreviousPeriod => 'Eelmine periood'; + + @override + String get shortcutPreviousPeriodDescription => + 'Nädalavaates eelmine nädal, kuuvaates eelmine kuu jne'; + + @override + String get shortcutJumpToToday => 'Mine tänasele kuupäevale'; + + @override + String get shortcutGroupView => 'Vaade'; + + @override + String get shortcutDayView => 'Päevavaade'; + + @override + String get shortcutWeekView => 'Nädalavaade'; + + @override + String get shortcutMonthView => 'Kuuvaade'; + + @override + String get shortcutYearView => 'Aastavaade'; + + @override + String get shortcutAgendaView => 'Päevakavavaade'; + + @override + String get shortcutGroupCreateAndEdit => 'Loomine ja muutmine'; + + @override + String get shortcutSaveItem => 'Salvesta sündmus või ülesanne'; + + @override + String get shortcutDeleteItem => 'Kustuta sündmus või ülesanne'; + + @override + String get shortcutGroupTaskEditing => 'Ülesande muutmine'; + + @override + String get shortcutCancelEditing => 'Tühista muutmine'; + + @override + String get shortcutCancelEditingDescription => + 'Sulge ülesande muutmine või ülesande üksikasjad'; + + @override + String get aboutBusyMax => 'Teave BusyMaxi kohta'; + + @override + String get aboutBusyMaxDescription => 'Kalender ja ülesanded'; + + @override + String get license => 'Litsents'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => 'Veebisait'; + + @override + String get sourceCode => 'Lähtekood'; + + @override + String get reportAnIssue => 'Teata probleemist'; + + @override + String get sendFeedback => 'Saada tagasisidet'; + + @override + String get feedbackSubmit => 'Saada'; + + @override + String get feedbackCategory => 'Kategooria'; + + @override + String get feedbackSelectCategory => 'Valige kategooria'; + + @override + String get feedbackCategoryProblem => 'Probleem või viga'; + + @override + String get feedbackCategoryFeature => 'Funktsioonisoov'; + + @override + String get feedbackCategoryPrivacySecurity => 'Privaatsus- või turbeprobleem'; + + @override + String get feedbackCategoryUsability => 'Kasutatavusprobleem'; + + @override + String get feedbackCategoryOther => 'Muu'; + + @override + String get feedbackSubject => 'Teema'; + + @override + String get feedbackDetailedMessage => 'Üksikasjalik sõnum'; + + @override + String get feedbackReplyEmail => 'Vastamise e-posti aadress (valikuline)'; + + @override + String get feedbackIncludeTechnicalDetails => 'Lisa tehnilised üksikasjad'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Lisatakse ainult teie Linuxi operatsioonisüsteemi versioon ning rakenduse keel ja piirkonnaseaded. Logisid, kontoandmeid, failinimesid ega muid diagnostikaandmeid ei lisata.'; + + @override + String get feedbackCategoryRequired => 'Valige kategooria.'; + + @override + String get feedbackSubjectLengthError => + 'Teema peab olema 3–120 tähemärki pikk.'; + + @override + String get feedbackMessageLengthError => + 'Sõnum peab olema 10–5000 tähemärki pikk.'; + + @override + String get feedbackInvalidEmail => 'Sisestage kehtiv e-posti aadress.'; + + @override + String get feedbackConnectionError => + 'BusyStackiga ei saanud ühendust luua. Kontrollige ühendust ja proovige uuesti.'; + + @override + String get feedbackTimeoutError => + 'Päring aegus. Teie tagasisidet ei kustutatud; proovige uuesti.'; + + @override + String get feedbackRateLimitedError => + 'Sellest võrgust on saadetud liiga palju tagasisidet. Oodake ja proovige uuesti.'; + + @override + String get feedbackRejectedError => + 'Server lükkas saatmise tagasi. Kontrollige välju ja proovige uuesti.'; + + @override + String get feedbackServerError => + 'BusyStack ei saa praegu teie tagasisidet vastu võtta. Teie tagasisidet ei kustutatud; proovige uuesti.'; + + @override + String feedbackSuccess(String id) { + return 'Tagasiside saadetud. Viide: $id'; + } + + @override + String get toggleSidebar => 'Kuva või peida külgriba'; + + @override + String get showSidebar => 'Kuva külgpaneel'; + + @override + String get hideSidebar => 'Peida külgpaneel'; + + @override + String get accounts => 'Kontod'; + + @override + String get currentAccount => 'Praegune konto'; + + @override + String get switchAccount => 'Vaheta kontot'; + + @override + String get addGoogleAccount => 'Lisa Google\'i konto'; + + @override + String get addMicrosoftAccount => 'Lisa Microsofti konto'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'Sisse logitud'; + + @override + String get removeAccount => 'Eemalda konto…'; + + @override + String get removingAccount => 'Konto eemaldamine…'; + + @override + String get removeAccountDescription => + 'Lõpeta sünkroonimine ja eemalda selle konto andmed seadmest.'; + + @override + String removeAccountTitle(String account) { + return 'Kas eemaldada $account BusyMaxist?'; + } + + @override + String get removeAccountConfirmation => + 'See kustutab seadmest vahemällu salvestatud ülesanded, kalendrid, sündmused, meeldetuletused ja sünkroonimist ootavad võrguühenduseta muudatused. Sünkroonimata muudatused lähevad kaotsi. Google\'ist ega Microsoftist midagi ei kustutata.'; + + @override + String get revokeGoogleAccess => + 'Tühista ka BusyMaxi juurdepääs sellele Google\'i kontole'; + + @override + String get revokeGoogleAccessDescription => + 'Enne uuesti ühendamist peate juurdepääsu uuesti andma.'; + + @override + String get removeAccountAction => 'Eemalda konto'; + + @override + String get removeAccountFailed => + 'Konto eemaldamist ei saanud lõpetada. Proovige uuesti.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'Konto eemaldati sellest seadmest, kuid BusyMaxi juurdepääsu teie Google\'i kontole ei saanud tühistada. Saate selle oma Google\'i kontol käsitsi tühistada.'; + + @override + String get newList => 'Uus loend'; + + @override + String get signInToViewTaskLists => + 'Ülesandeloendite vaatamiseks logige sisse.'; + + @override + String get noTaskListsSynced => + 'Ühtegi ülesandeloendit pole veel sünkroonitud.'; + + @override + String get listActions => 'Loendi toimingud'; + + @override + String get rename => 'Nimeta ümber'; + + @override + String get delete => 'Kustuta'; + + @override + String get renameList => 'Nimeta loend ümber'; + + @override + String get deleteList => 'Kustuta loend'; + + @override + String get builtInMicrosoftList => 'Sisseehitatud'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Microsoft To Do sisseehitatud loendeid ei saa ümber nimetada ega kustutada.'; + + @override + String deleteListConfirmation(String title) { + return 'Kas kustutada „$title” Google Tasksist?'; + } + + @override + String get deleteEvent => 'Kustuta sündmus'; + + @override + String get title => 'Pealkiri'; + + @override + String get create => 'Loo'; + + @override + String get newTask => 'Uus ülesanne'; + + @override + String get clearCompleted => 'Eemalda lõpetatud ülesanded'; + + @override + String get refreshList => 'Värskenda loendit'; + + @override + String get refreshAll => 'Värskenda kõiki'; + + @override + String get listRefreshed => 'Loend on värskendatud.'; + + @override + String get allTasksRefreshed => 'Kõik kontod on värskendatud.'; + + @override + String exportedFile(String path) { + return 'Eksporditud asukohta $path'; + } + + @override + String exportFailed(String error) { + return 'Eksportimine nurjus: $error'; + } + + @override + String refreshFailed(String error) { + return 'Värskendamine nurjus: $error'; + } + + @override + String get selectOrCreateTaskList => + 'Alustamiseks valige või looge ülesandeloend.'; + + @override + String get signInToViewTasks => 'Ülesannete vaatamiseks logige sisse.'; + + @override + String get noTasks => 'Ülesandeid pole.'; + + @override + String get noTasksYet => 'Ülesandeid veel pole'; + + @override + String get noTasksYetMessage => + 'Alustamiseks looge ülesanne või värskendage kontosid.'; + + @override + String get noTasksInList => 'Selles loendis pole ülesandeid.'; + + @override + String get overdue => 'Tähtaja ületanud'; + + @override + String get today => 'Täna'; + + @override + String get tomorrow => 'Homme'; + + @override + String get upcoming => 'Tulekul'; + + @override + String get noDate => 'Kuupäevata'; + + @override + String get completed => 'Lõpetatud'; + + @override + String duePrefix(String date) { + return 'Tähtaeg $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => 'Ülesande üksikasjad'; + + @override + String get editTask => 'Muuda ülesannet'; + + @override + String get noTaskSelected => 'Ülesannet pole valitud.'; + + @override + String get noTaskSelectedHelper => + 'Üksikasjade vaatamiseks ja muutmiseks valige ülesanne.'; + + @override + String get taskUnavailable => 'Ülesanne pole saadaval.'; + + @override + String get signInToEditTasks => 'Ülesannete muutmiseks logige sisse.'; + + @override + String get refreshTask => 'Värskenda ülesannet'; + + @override + String get primarySection => 'Põhiteave'; + + @override + String get statusSection => 'Olek'; + + @override + String get openStatus => 'Pooleli'; + + @override + String get doneStatus => 'Valmis'; + + @override + String get notes => 'Märkmed'; + + @override + String get dueDate => 'Tähtaeg'; + + @override + String get clearDueDate => 'Eemalda tähtaeg'; + + @override + String get dueTime => 'Tähtaja kellaaeg'; + + @override + String get startDate => 'Alguskuupäev'; + + @override + String get startTime => 'Alguskellaaeg'; + + @override + String get endDate => 'Lõppkuupäev'; + + @override + String get endTime => 'Lõppkellaaeg'; + + @override + String get reminderDate => 'Meeldetuletuse kuupäev'; + + @override + String get reminderTime => 'Meeldetuletuse kellaaeg'; + + @override + String get reminder => 'Meeldetuletus'; + + @override + String get addReminder => 'Lisa meeldetuletus'; + + @override + String get addGuest => 'Lisa külaline'; + + @override + String get addGuestEmail => 'Lisa külalise e-posti aadress'; + + @override + String get removeReminder => 'Eemalda meeldetuletus'; + + @override + String get off => 'Väljas'; + + @override + String get repeat => 'Kordus'; + + @override + String get repeatNone => 'Ei kordu'; + + @override + String get noneValue => 'Puudub'; + + @override + String get repeatDaily => 'Iga päev'; + + @override + String get repeatWeekly => 'Iga nädal'; + + @override + String get repeatMonthly => 'Iga kuu'; + + @override + String get repeatYearly => 'Iga aasta'; + + @override + String get importance => 'Tähtsus'; + + @override + String get importanceLow => 'Madal'; + + @override + String get importanceNormal => 'Tavaline'; + + @override + String get importanceHigh => 'Kõrge'; + + @override + String get categories => 'Kategooriad'; + + @override + String get scheduleSection => 'Ajakava'; + + @override + String get dueGroup => 'Tähtaeg'; + + @override + String get startGroup => 'Algus'; + + @override + String get reminderGroup => 'Meeldetuletus'; + + @override + String get organizationSection => 'Korraldus'; + + @override + String get actionsSection => 'Toimingud'; + + @override + String get advancedSection => 'Täpsemad seaded'; + + @override + String get addCategory => 'Lisa kategooria'; + + @override + String get list => 'Loend'; + + @override + String get microsoftMoveUnsupported => + 'Selles versioonis ei toetata Microsoft To Do kontodel ülesannete teisaldamist loendite vahel.'; + + @override + String get createSubtask => 'Loo alamülesanne'; + + @override + String get moveToTop => 'Teisalda kõige üles'; + + @override + String get deleteTask => 'Kustuta ülesanne'; + + @override + String get newSubtask => 'Uus alamülesanne'; + + @override + String deleteTaskConfirmation(String title) { + return 'Kas kustutada „$title” Google Tasksist?'; + } + + @override + String get metadata => 'Metaandmed'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'Uuendatud'; + + @override + String get parent => 'Ülemülesanne'; + + @override + String get position => 'Asukoht'; + + @override + String get webLink => 'Veebilink'; + + @override + String get assignment => 'Määramine'; + + @override + String get localState => 'Kohalik olek'; + + @override + String get pendingSync => 'Sünkroonimise ootel'; + + @override + String get synced => 'Sünkroonitud'; + + @override + String get account => 'Konto'; + + @override + String get sync => 'Sünkroonimine'; + + @override + String get manualFullSync => 'Käsitsi täielik sünkroonimine'; + + @override + String get runInBackgroundWhenClosed => + 'Jätka töötamist, kui aken on suletud'; + + @override + String get showTrayIcon => 'Kuva süsteemisalve ikoon'; + + @override + String get startMinimizedToTray => 'Käivita minimeerituna süsteemisalves'; + + @override + String get requiresTrayIcon => 'Nõuab süsteemisalve ikooni.'; + + @override + String get syncComplete => 'Sünkroonimine on lõpetatud.'; + + @override + String syncFailed(String error) { + return 'Sünkroonimine nurjus: $error'; + } + + @override + String get notifySyncFailures => 'Teavitused sünkroonimise nurjumisel'; + + @override + String get notifyConflicts => 'Teavitused konfliktide korral'; + + @override + String get notifyDueToday => 'Täna tähtuvate ülesannete teavitused'; + + @override + String get eventReminders => 'Sündmuste meeldetuletused'; + + @override + String get taskReminders => 'Ülesannete meeldetuletused'; + + @override + String get notificationDetailLevel => 'Teavituste üksikasjalikkus'; + + @override + String get notificationDetailPrivate => 'Privaatne'; + + @override + String get notificationDetailNormal => 'Tavaline'; + + @override + String get quietHours => 'Vaikne aeg'; + + @override + String get quietHoursDescription => 'Peata teavitused selleks ajavahemikuks.'; + + @override + String get quietHoursStart => 'Vaikse aja algus'; + + @override + String get quietHoursEnd => 'Vaikse aja lõpp'; + + @override + String get notifications => 'Teavitused'; + + @override + String get appearance => 'Välimus'; + + @override + String get theme => 'Kujundus'; + + @override + String get themeSystem => 'Süsteem'; + + @override + String get themeLight => 'Hele'; + + @override + String get themeDark => 'Tume'; + + @override + String get themeFamily => 'Kujunduse perekond'; + + @override + String get themeFamilyYaru => 'Ubuntu algupärane kujundus (Yaru)'; + + @override + String get localization => 'Keel ja piirkond'; + + @override + String get currentLocale => 'Praegune lokaat'; + + @override + String get privacy => 'Privaatsus'; + + @override + String get redactTaskContentInDiagnostics => + 'Peida diagnostikas ülesannete sisu'; + + @override + String get developerDiagnostics => 'Arendaja diagnostika'; + + @override + String get diagnostics => 'Diagnostika'; + + @override + String get apiInspectorDisabled => 'Kuva API-inspektor'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'Discovery versioon: $revision'; + } + + @override + String get implementedMethods => 'Rakendatud meetodid'; + + @override + String get supportsTasksScopes => + 'Toetab õiguse ulatusi tasks ja tasks.readonly'; + + @override + String get requiresTasksScope => 'Nõuab õiguse ulatust tasks'; + + @override + String get blockedPendingOperations => 'Blokeeritud ootel toimingud'; + + @override + String get signInToInspectPendingOperations => + 'Ootel toimingute kontrollimiseks logige sisse.'; + + @override + String get noBlockedPendingOperations => 'Blokeeritud ootel toiminguid pole.'; + + @override + String get operationActions => 'Toimingu tegevused'; + + @override + String pendingOpListId(String id) { + return 'loend=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'ülesanne=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'katseid=$count'; + } + + @override + String get retry => 'Proovi uuesti'; + + @override + String get discard => 'Hülga'; + + @override + String get discardChangesAction => 'Hülga'; + + @override + String get discardChanges => 'Kas hüljata muudatused?'; + + @override + String get discardChangesConfirmation => + 'See hülgab ülesande salvestamata muudatused.'; + + @override + String get retryCompleted => 'Uuesti proovimine lõpetatud.'; + + @override + String get discardPendingOperation => 'Kas hüljata ootel toiming?'; + + @override + String get discardPendingOperationConfirmation => + 'See eemaldab blokeeritud kohaliku toimingu. Järgmine sünkroonimine laadib andmed Google Tasksist uuesti.'; + + @override + String get pendingOperationDiscarded => 'Ootel toiming hüljatud.'; + + @override + String get syncFailureNotificationTitle => 'BusyMaxi sünkroonimine nurjus'; + + @override + String syncFailureNotificationBody(String message) { + return 'Taustal sünkroonimine nurjus. $message'; + } + + @override + String get conflictNotificationTitle => 'BusyMaxi sünkroonimiskonflikt'; + + @override + String conflictNotificationBody(String summary) { + return 'Ootel kohalik muudatus blokeeriti. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'Täna tähtuvad ülesanded'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count ülesannet tähtub täna.', + one: 'Üks ülesanne tähtub täna.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'Sündmuse meeldetuletus'; + + @override + String get taskReminderNotificationTitle => 'Ülesande meeldetuletus'; + + @override + String get eventReminderNotificationBody => 'Sündmus algab varsti.'; + + @override + String get taskReminderNotificationBody => 'Ülesande tähtaeg on varsti.'; + + @override + String get notificationOpenAction => 'Ava'; + + @override + String get notificationDetailsHidden => + 'Üksikasjad on privaatsusseadete tõttu peidetud.'; + + @override + String get previousMonth => 'Eelmine kuu'; + + @override + String get nextMonth => 'Järgmine kuu'; + + @override + String get openMonthView => 'Ava kuuvaade'; + + @override + String get previousYear => 'Eelmine aasta'; + + @override + String get nextYear => 'Järgmine aasta'; + + @override + String get openYearView => 'Ava aastavaade'; + + @override + String weekNumberTooltip(int number) { + return 'Nädal $number'; + } + + @override + String get resizeAllDayPanel => 'Muuda kogu päeva paneeli suurust'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count kirjet', + one: '1 kirje', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'See kalender on kirjutuskaitstud.'; + + @override + String get selectTimeZone => 'Valige ajavöönd'; + + @override + String get searchLocations => 'Otsi asukohti'; + + @override + String get noLocationsFound => 'Asukohti ei leitud'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Kas kustutada „$title”?'; + } +} diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart new file mode 100644 index 0000000..447985d --- /dev/null +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -0,0 +1,1286 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Persian (`fa`). +class AppLocalizationsFa extends AppLocalizations { + AppLocalizationsFa([String locale = 'fa']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'حساب‌های Google و Microsoft را متصل کنید تا تقویم‌ها و کارها همگام شوند.'; + + @override + String get googlePermissionsConsentNotice => + 'در صفحهٔ مجوزهای Google، مجوزهای تقویم و کارها را هر دو انتخاب کنید.'; + + @override + String get googlePermissionsRequiredRetry => + 'مجوزهای Google Calendar و Google Tasks لازم هستند. دوباره تلاش کنید و هر دو کادر را علامت بزنید.'; + + @override + String get finishSetup => 'پایان راه‌اندازی'; + + @override + String get continueSetup => 'ادامه'; + + @override + String get onboardingSetupTitle => 'راه‌اندازی BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'اتصال حساب‌ها'; + + @override + String get onboardingAccountsStepDescription => + 'همهٔ حساب‌های Google و Microsoft موردنظرتان را اضافه کنید. BusyMax تقویم‌ها، رویدادها، فهرست‌های کار و کارهای هر حساب را همگام می‌کند.'; + + @override + String get onboardingPreferencesStepTitle => 'انتخاب تنظیمات سیستم'; + + @override + String get onboardingPreferencesStepDescription => + 'پیش از باز کردن برنامه، رفتار برنامه روی میزکار، یادآورها، سطح جزئیات اعلان‌ها و ظاهر را تنظیم کنید.'; + + @override + String get signInWithGoogle => 'ورود با Google'; + + @override + String get signInWithMicrosoft => 'ورود با Microsoft'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'این سرویس پیکربندی نشده است.'; + + @override + String get waitingForGoogleSignIn => 'در انتظار ورود به Google...'; + + @override + String get waitingForMicrosoftSignIn => 'در انتظار ورود به Microsoft...'; + + @override + String get microsoftSignInNotConfigured => + 'ورود به Microsoft پیکربندی نشده است. MICROSOFT_OAUTH_CLIENT_ID را تنظیم کنید.'; + + @override + String get cancel => 'لغو'; + + @override + String get close => 'بستن'; + + @override + String get exit => 'خروج'; + + @override + String get options => 'گزینه‌ها'; + + @override + String get hide => 'پنهان کردن'; + + @override + String get show => 'نمایش'; + + @override + String get export => 'خروجی گرفتن'; + + @override + String get save => 'ذخیره'; + + @override + String get settings => 'تنظیمات'; + + @override + String get all => 'همه'; + + @override + String get calendarEvents => 'رویدادها'; + + @override + String get calendarTasks => 'کارها'; + + @override + String get calendar => 'تقویم'; + + @override + String get calendars => 'تقویم‌ها'; + + @override + String get newEvent => 'رویداد جدید'; + + @override + String get refreshCalendar => 'تازه‌سازی تقویم'; + + @override + String get openInProvider => 'باز کردن در سرویس'; + + @override + String get hideFromSchedule => 'پنهان کردن از برنامه'; + + @override + String get showInSchedule => 'نمایش در برنامه'; + + @override + String get noCalendarsSynced => 'هنوز هیچ تقویمی همگام نشده است.'; + + @override + String get allDay => 'تمام روز'; + + @override + String moreItems(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+⁨$countString⁩ مورد دیگر'; + } + + @override + String get noEventsOrTasks => 'هیچ رویداد یا کاری وجود ندارد'; + + @override + String get scheduleLoading => 'در حال بارگیری برنامه...'; + + @override + String get scheduleUnavailable => 'برنامه در دسترس نیست'; + + @override + String get scheduleNoSources => + 'هیچ تقویم یا فهرست کار قابل نمایشی وجود ندارد'; + + @override + String get scheduleNoSourcesDescription => + 'در تنظیمات انتخاب کنید چه چیزهایی نمایش داده شوند، سپس برنامه را تازه‌سازی کنید.'; + + @override + String get scheduleSignInRequired => 'اتصال حساب'; + + @override + String get scheduleSignInDescription => + 'برای همگام‌سازی تقویم‌ها و کارها وارد شوید.'; + + @override + String get scheduleNoSearchResults => 'هیچ رویداد یا کار منطبقی وجود ندارد'; + + @override + String get scheduleNoSearchResultsDescription => + 'جست‌وجوی دیگری را امتحان کنید یا پالایه‌های فعلی را پاک کنید.'; + + @override + String get refresh => 'تازه‌سازی'; + + @override + String get trayOpenBusyMax => 'باز کردن BusyMax'; + + @override + String get agendaLoadMoreOverdue => 'بارگیری کارهای عقب‌افتادهٔ بیشتر'; + + @override + String get agendaLoadMoreNoDate => 'بارگیری کارهای بدون تاریخ بیشتر'; + + @override + String get viewDay => 'روز'; + + @override + String get viewWeek => 'هفته'; + + @override + String get viewMonth => 'ماه'; + + @override + String get viewYear => 'سال'; + + @override + String get viewAgenda => 'برنامه'; + + @override + String get scheduleSettings => 'برنامه'; + + @override + String get scheduleDisplaySettings => 'نمایش برنامه'; + + @override + String get scheduleDisplayHoursDescription => + 'نماهای روز و هفته ابتدا این بازهٔ زمانی را نشان می‌دهند. موارد زودتر یا دیرتر در صورت نیاز این بازه را گسترش می‌دهند.'; + + @override + String get scheduleDayStartsAt => 'شروع روز از'; + + @override + String get scheduleDayEndsAt => 'پایان روز در'; + + @override + String get sourceCalendar => 'تقویم'; + + @override + String get sourceTaskList => 'فهرست کار'; + + @override + String get createChoiceTitle => 'ایجاد'; + + @override + String get createEventAtTime => 'رویداد'; + + @override + String get createTaskAtDate => 'کار'; + + @override + String get editEvent => 'ویرایش رویداد'; + + @override + String get eventTitle => 'عنوان رویداد'; + + @override + String get location => 'مکان'; + + @override + String get timeSlot => 'بازهٔ زمانی'; + + @override + String get startDateTime => 'تاریخ/زمان شروع'; + + @override + String get endDateTime => 'تاریخ/زمان پایان'; + + @override + String get doesNotRepeat => 'تکرار نمی‌شود'; + + @override + String get defaultReminder => 'یادآور پیش‌فرض'; + + @override + String get guests => 'مهمانان'; + + @override + String get noGuests => 'بدون مهمان'; + + @override + String get description => 'توضیحات'; + + @override + String get availabilityShowAs => 'وضعیت دسترسی / نمایش به‌عنوان'; + + @override + String get busy => 'مشغول'; + + @override + String get visibility => 'قابلیت مشاهده'; + + @override + String get defaultVisibility => 'قابلیت مشاهدهٔ پیش‌فرض'; + + @override + String get conference => 'جلسه'; + + @override + String get noConference => 'بدون جلسه'; + + @override + String get providerCalendar => 'تقویم سرویس'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => 'پررنگ'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'مورب'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => 'زیرخط'; + + @override + String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '⁨$minutesString⁩ دقیقه قبل', + one: 'یک دقیقه قبل', + zero: 'هنگام شروع', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'هنگام شروع'; + + @override + String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '⁨$hoursString⁩ ساعت قبل', + one: 'یک ساعت قبل', + zero: 'هنگام شروع', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '⁨$daysString⁩ روز قبل', + one: 'یک روز قبل', + zero: 'همان روز', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'آزاد'; + + @override + String get availabilityTentative => 'احتمالی'; + + @override + String get availabilityOutOfOffice => 'خارج از دفتر'; + + @override + String get availabilityWorkingElsewhere => 'مشغول به کار در مکانی دیگر'; + + @override + String get visibilityDefault => 'پیش‌فرض'; + + @override + String get visibilityPublic => 'عمومی'; + + @override + String get visibilityPrivate => 'خصوصی'; + + @override + String get visibilityConfidential => 'محرمانه'; + + @override + String get sensitivityNormal => 'عادی'; + + @override + String get sensitivityPersonal => 'شخصی'; + + @override + String get tasks => 'کارها'; + + @override + String get allTasks => 'همهٔ کارها'; + + @override + String tasksInList(String title) { + return 'کارهای ⁨$title⁩'; + } + + @override + String get taskLists => 'فهرست‌های کار'; + + @override + String get navigation => 'پیمایش'; + + @override + String get mainMenu => 'منوی اصلی'; + + @override + String get keyboardShortcuts => 'میان‌برهای صفحه‌کلید'; + + @override + String get shortcutGroupGeneral => 'عمومی'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'نمایش این راهنمای میان‌برها'; + + @override + String get shortcutGroupNavigation => 'پیمایش'; + + @override + String get shortcutNextPeriod => 'بازهٔ بعدی'; + + @override + String get shortcutNextPeriodDescription => + 'هفتهٔ بعد در نمای هفته، ماه بعد در نمای ماه و به همین ترتیب'; + + @override + String get shortcutPreviousPeriod => 'بازهٔ قبلی'; + + @override + String get shortcutPreviousPeriodDescription => + 'هفتهٔ قبل در نمای هفته، ماه قبل در نمای ماه و به همین ترتیب'; + + @override + String get shortcutJumpToToday => 'رفتن به امروز'; + + @override + String get shortcutGroupView => 'نما'; + + @override + String get shortcutDayView => 'نمای روز'; + + @override + String get shortcutWeekView => 'نمای هفته'; + + @override + String get shortcutMonthView => 'نمای ماه'; + + @override + String get shortcutYearView => 'نمای سال'; + + @override + String get shortcutAgendaView => 'نمای برنامه'; + + @override + String get shortcutGroupCreateAndEdit => 'ایجاد و ویرایش'; + + @override + String get shortcutSaveItem => 'ذخیرهٔ رویداد یا کار'; + + @override + String get shortcutDeleteItem => 'حذف رویداد یا کار'; + + @override + String get shortcutGroupTaskEditing => 'ویرایش کار'; + + @override + String get shortcutCancelEditing => 'لغو ویرایش'; + + @override + String get shortcutCancelEditingDescription => 'بستن ویرایش یا جزئیات کار'; + + @override + String get aboutBusyMax => 'دربارهٔ BusyMax'; + + @override + String get aboutBusyMaxDescription => 'تقویم و کارها'; + + @override + String get license => 'مجوز'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => 'وب‌سایت'; + + @override + String get sourceCode => 'کد منبع'; + + @override + String get reportAnIssue => 'گزارش مشکل'; + + @override + String get sendFeedback => 'ارسال بازخورد'; + + @override + String get feedbackSubmit => 'ارسال'; + + @override + String get feedbackCategory => 'دسته‌بندی'; + + @override + String get feedbackSelectCategory => 'یک دسته‌بندی انتخاب کنید'; + + @override + String get feedbackCategoryProblem => 'مشکل یا اشکال'; + + @override + String get feedbackCategoryFeature => 'درخواست قابلیت'; + + @override + String get feedbackCategoryPrivacySecurity => + 'نگرانی دربارهٔ حریم خصوصی یا امنیت'; + + @override + String get feedbackCategoryUsability => 'نگرانی دربارهٔ کاربردپذیری'; + + @override + String get feedbackCategoryOther => 'سایر'; + + @override + String get feedbackSubject => 'موضوع'; + + @override + String get feedbackDetailedMessage => 'پیام با جزئیات'; + + @override + String get feedbackReplyEmail => 'نشانی ایمیل برای پاسخ (اختیاری)'; + + @override + String get feedbackIncludeTechnicalDetails => 'افزودن جزئیات فنی'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'فقط نسخهٔ سیستم‌عامل Linux و تنظیمات منطقه‌ای برنامه افزوده می‌شود. هیچ گزارش، دادهٔ حساب، نام پرونده یا اطلاعات تشخیصی دیگری افزوده نمی‌شود.'; + + @override + String get feedbackCategoryRequired => 'یک دسته‌بندی انتخاب کنید.'; + + @override + String get feedbackSubjectLengthError => + 'موضوع باید بین ۳ تا ۱۲۰ نویسه باشد.'; + + @override + String get feedbackMessageLengthError => + 'پیام باید بین ۱۰ تا ۵٬۰۰۰ نویسه باشد.'; + + @override + String get feedbackInvalidEmail => 'یک نشانی ایمیل معتبر وارد کنید.'; + + @override + String get feedbackConnectionError => + 'اتصال به BusyStack ممکن نشد. اتصال خود را بررسی و دوباره تلاش کنید.'; + + @override + String get feedbackTimeoutError => + 'مهلت درخواست پایان یافت. بازخورد شما پاک نشده است؛ دوباره تلاش کنید.'; + + @override + String get feedbackRateLimitedError => + 'بازخوردهای بیش از حدی از این شبکه ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.'; + + @override + String get feedbackRejectedError => + 'سرور ارسال را رد کرد. فیلدها را بررسی و دوباره تلاش کنید.'; + + @override + String get feedbackServerError => + 'BusyStack اکنون نمی‌تواند بازخورد شما را بپذیرد. بازخورد شما پاک نشده است؛ دوباره تلاش کنید.'; + + @override + String feedbackSuccess(String id) { + return 'بازخورد ارسال شد. شناسهٔ پیگیری: ⁨$id⁩'; + } + + @override + String get toggleSidebar => 'نمایش یا پنهان کردن نوار کناری'; + + @override + String get showSidebar => 'نمایش پنل کناری'; + + @override + String get hideSidebar => 'پنهان کردن پنل کناری'; + + @override + String get accounts => 'حساب‌ها'; + + @override + String get currentAccount => 'حساب فعلی'; + + @override + String get switchAccount => 'تعویض حساب'; + + @override + String get addGoogleAccount => 'افزودن حساب Google'; + + @override + String get addMicrosoftAccount => 'افزودن حساب Microsoft'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'وارد شده'; + + @override + String get removeAccount => 'حذف حساب…'; + + @override + String get removingAccount => 'در حال حذف حساب…'; + + @override + String get removeAccountDescription => + 'همگام‌سازی را متوقف و داده‌های این حساب را از این دستگاه حذف کنید.'; + + @override + String removeAccountTitle(String account) { + return 'حذف ⁨$account⁩ از BusyMax؟'; + } + + @override + String get removeAccountConfirmation => + 'با این کار، کارها، تقویم‌ها، رویدادها، یادآورها و تغییرات آفلاین در انتظار از حافظهٔ نهان این دستگاه حذف می‌شوند. تغییرات همگام‌نشده از دست می‌روند. هیچ چیزی از Google یا Microsoft حذف نمی‌شود.'; + + @override + String get revokeGoogleAccess => + 'دسترسی BusyMax به این حساب Google نیز لغو شود'; + + @override + String get revokeGoogleAccessDescription => + 'پیش از اتصال دوباره باید دسترسی را دوباره اعطا کنید.'; + + @override + String get removeAccountAction => 'حذف حساب'; + + @override + String get removeAccountFailed => 'حذف حساب کامل نشد. دوباره تلاش کنید.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'حساب از این دستگاه حذف شد، اما BusyMax نتوانست دسترسی Google را لغو کند. می‌توانید آن را از حساب Google خود لغو کنید.'; + + @override + String get newList => 'فهرست جدید'; + + @override + String get signInToViewTaskLists => 'برای دیدن فهرست‌های کار وارد شوید.'; + + @override + String get noTaskListsSynced => 'هنوز هیچ فهرست کاری همگام نشده است.'; + + @override + String get listActions => 'عملیات فهرست'; + + @override + String get rename => 'تغییر نام'; + + @override + String get delete => 'حذف'; + + @override + String get renameList => 'تغییر نام فهرست'; + + @override + String get deleteList => 'حذف فهرست'; + + @override + String get builtInMicrosoftList => 'داخلی'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'فهرست‌های داخلی Microsoft To Do را نمی‌توان تغییر نام داد یا حذف کرد.'; + + @override + String deleteListConfirmation(String title) { + return '«⁨$title⁩» از Google Tasks حذف شود؟'; + } + + @override + String get deleteEvent => 'حذف رویداد'; + + @override + String get title => 'عنوان'; + + @override + String get create => 'ایجاد'; + + @override + String get newTask => 'کار جدید'; + + @override + String get clearCompleted => 'پاک کردن کارهای انجام‌شده'; + + @override + String get refreshList => 'تازه‌سازی فهرست'; + + @override + String get refreshAll => 'تازه‌سازی همه'; + + @override + String get listRefreshed => 'فهرست تازه‌سازی شد.'; + + @override + String get allTasksRefreshed => 'همهٔ حساب‌ها تازه‌سازی شدند.'; + + @override + String exportedFile(String path) { + return 'در ⁨$path⁩ خروجی گرفته شد'; + } + + @override + String exportFailed(String error) { + return 'خروجی گرفتن ناموفق بود: ⁨$error⁩'; + } + + @override + String refreshFailed(String error) { + return 'تازه‌سازی ناموفق بود: ⁨$error⁩'; + } + + @override + String get selectOrCreateTaskList => + 'برای شروع، یک فهرست کار انتخاب یا ایجاد کنید.'; + + @override + String get signInToViewTasks => 'برای دیدن کارها وارد شوید.'; + + @override + String get noTasks => 'هیچ کاری وجود ندارد.'; + + @override + String get noTasksYet => 'هنوز کاری وجود ندارد'; + + @override + String get noTasksYetMessage => + 'برای شروع یک کار ایجاد کنید یا حساب‌هایتان را تازه‌سازی کنید.'; + + @override + String get noTasksInList => 'هیچ کاری در این فهرست وجود ندارد.'; + + @override + String get overdue => 'گذشته از موعد'; + + @override + String get today => 'امروز'; + + @override + String get tomorrow => 'فردا'; + + @override + String get upcoming => 'پیش رو'; + + @override + String get noDate => 'بدون تاریخ'; + + @override + String get completed => 'انجام‌شده'; + + @override + String duePrefix(String date) { + return 'سررسید: ⁨$date⁩'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '⁨$date⁩ · ⁨$time⁩'; + } + + @override + String get taskDetails => 'جزئیات کار'; + + @override + String get editTask => 'ویرایش کار'; + + @override + String get noTaskSelected => 'هیچ کاری انتخاب نشده است.'; + + @override + String get noTaskSelectedHelper => + 'برای دیدن و ویرایش جزئیات، کاری را انتخاب کنید.'; + + @override + String get taskUnavailable => 'کار در دسترس نیست.'; + + @override + String get signInToEditTasks => 'برای ویرایش کارها وارد شوید.'; + + @override + String get refreshTask => 'تازه‌سازی کار'; + + @override + String get primarySection => 'اصلی'; + + @override + String get statusSection => 'وضعیت'; + + @override + String get openStatus => 'باز'; + + @override + String get doneStatus => 'انجام‌شده'; + + @override + String get notes => 'یادداشت‌ها'; + + @override + String get dueDate => 'تاریخ سررسید'; + + @override + String get clearDueDate => 'پاک کردن تاریخ سررسید'; + + @override + String get dueTime => 'زمان سررسید'; + + @override + String get startDate => 'تاریخ شروع'; + + @override + String get startTime => 'زمان شروع'; + + @override + String get endDate => 'تاریخ پایان'; + + @override + String get endTime => 'زمان پایان'; + + @override + String get reminderDate => 'تاریخ یادآور'; + + @override + String get reminderTime => 'زمان یادآور'; + + @override + String get reminder => 'یادآور'; + + @override + String get addReminder => 'افزودن یادآور'; + + @override + String get addGuest => 'افزودن مهمان'; + + @override + String get addGuestEmail => 'افزودن ایمیل مهمان'; + + @override + String get removeReminder => 'حذف یادآور'; + + @override + String get off => 'خاموش'; + + @override + String get repeat => 'تکرار'; + + @override + String get repeatNone => 'بدون تکرار'; + + @override + String get noneValue => 'هیچ‌کدام'; + + @override + String get repeatDaily => 'روزانه'; + + @override + String get repeatWeekly => 'هفتگی'; + + @override + String get repeatMonthly => 'ماهانه'; + + @override + String get repeatYearly => 'سالانه'; + + @override + String get importance => 'اهمیت'; + + @override + String get importanceLow => 'کم'; + + @override + String get importanceNormal => 'عادی'; + + @override + String get importanceHigh => 'زیاد'; + + @override + String get categories => 'دسته‌ها'; + + @override + String get scheduleSection => 'برنامه'; + + @override + String get dueGroup => 'سررسید'; + + @override + String get startGroup => 'شروع'; + + @override + String get reminderGroup => 'یادآور'; + + @override + String get organizationSection => 'سازمان‌دهی'; + + @override + String get actionsSection => 'عملیات'; + + @override + String get advancedSection => 'پیشرفته'; + + @override + String get addCategory => 'افزودن دسته'; + + @override + String get list => 'فهرست'; + + @override + String get microsoftMoveUnsupported => + 'در این نسخه، جابه‌جایی کارها بین فهرست‌های حساب Microsoft To Do پشتیبانی نمی‌شود.'; + + @override + String get createSubtask => 'ایجاد زیرکار'; + + @override + String get moveToTop => 'انتقال به بالاترین جایگاه'; + + @override + String get deleteTask => 'حذف کار'; + + @override + String get newSubtask => 'زیرکار جدید'; + + @override + String deleteTaskConfirmation(String title) { + return '«⁨$title⁩» از Google Tasks حذف شود؟'; + } + + @override + String get metadata => 'فراداده'; + + @override + String get id => 'شناسه'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'به‌روزشده'; + + @override + String get parent => 'کار والد'; + + @override + String get position => 'جایگاه'; + + @override + String get webLink => 'پیوند وب'; + + @override + String get assignment => 'واگذاری'; + + @override + String get localState => 'وضعیت محلی'; + + @override + String get pendingSync => 'در انتظار همگام‌سازی'; + + @override + String get synced => 'همگام‌شده'; + + @override + String get account => 'حساب'; + + @override + String get sync => 'همگام‌سازی'; + + @override + String get manualFullSync => 'همگام‌سازی کامل دستی'; + + @override + String get runInBackgroundWhenClosed => 'ادامهٔ اجرا پس از بسته شدن پنجره'; + + @override + String get showTrayIcon => 'نمایش نماد سینی سیستم'; + + @override + String get startMinimizedToTray => 'شروع به‌صورت کوچک‌شده در سینی سیستم'; + + @override + String get requiresTrayIcon => 'به نماد سینی سیستم نیاز دارد.'; + + @override + String get syncComplete => 'همگام‌سازی کامل شد.'; + + @override + String syncFailed(String error) { + return 'همگام‌سازی ناموفق بود: ⁨$error⁩'; + } + + @override + String get notifySyncFailures => 'اعلان هنگام شکست همگام‌سازی'; + + @override + String get notifyConflicts => 'اعلان هنگام تداخل'; + + @override + String get notifyDueToday => 'اعلان کارهای دارای سررسید امروز'; + + @override + String get eventReminders => 'یادآورهای رویداد'; + + @override + String get taskReminders => 'یادآورهای کار'; + + @override + String get notificationDetailLevel => 'سطح جزئیات اعلان'; + + @override + String get notificationDetailPrivate => 'خصوصی'; + + @override + String get notificationDetailNormal => 'عادی'; + + @override + String get quietHours => 'ساعات سکوت'; + + @override + String get quietHoursDescription => + 'اعلان‌ها را در این بازه موقتاً متوقف کنید.'; + + @override + String get quietHoursStart => 'شروع ساعات سکوت'; + + @override + String get quietHoursEnd => 'پایان ساعات سکوت'; + + @override + String get notifications => 'اعلان‌ها'; + + @override + String get appearance => 'ظاهر'; + + @override + String get theme => 'پوسته'; + + @override + String get themeSystem => 'سیستم'; + + @override + String get themeLight => 'روشن'; + + @override + String get themeDark => 'تیره'; + + @override + String get themeFamily => 'خانوادهٔ پوسته'; + + @override + String get themeFamilyYaru => 'پوستهٔ بومی Ubuntu ‏(Yaru)'; + + @override + String get localization => 'زبان و منطقه'; + + @override + String get currentLocale => 'زبان و منطقهٔ فعلی'; + + @override + String get privacy => 'حریم خصوصی'; + + @override + String get redactTaskContentInDiagnostics => + 'پنهان کردن محتوای کارها در اطلاعات تشخیصی'; + + @override + String get developerDiagnostics => 'تشخیص‌های توسعه‌دهنده'; + + @override + String get diagnostics => 'اطلاعات تشخیصی'; + + @override + String get apiInspectorDisabled => 'نمایش بازرس API'; + + @override + String get googleTasksApi => 'رابط Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'بازبینی Discovery: ⁨$revision⁩'; + } + + @override + String get implementedMethods => 'روش‌های پیاده‌سازی‌شده'; + + @override + String get supportsTasksScopes => + 'از محدوده‌های tasks و tasks.readonly پشتیبانی می‌کند'; + + @override + String get requiresTasksScope => 'به محدودهٔ tasks نیاز دارد'; + + @override + String get blockedPendingOperations => 'عملیات در انتظار مسدودشده'; + + @override + String get signInToInspectPendingOperations => + 'برای بررسی عملیات در انتظار وارد شوید.'; + + @override + String get noBlockedPendingOperations => + 'هیچ عملیات در انتظار مسدودشده‌ای وجود ندارد.'; + + @override + String get operationActions => 'اقدامات عملیات'; + + @override + String pendingOpListId(String id) { + return 'فهرست=⁨$id⁩'; + } + + @override + String pendingOpTaskId(String id) { + return 'کار=⁨$id⁩'; + } + + @override + String pendingOpAttempts(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'تلاش‌ها=⁨$countString⁩'; + } + + @override + String get retry => 'تلاش دوباره'; + + @override + String get discard => 'کنار گذاشتن'; + + @override + String get discardChangesAction => 'ذخیره نشود'; + + @override + String get discardChanges => 'تغییرات کنار گذاشته شوند؟'; + + @override + String get discardChangesConfirmation => + 'با این کار ویرایش‌های ذخیره‌نشدهٔ این کار کنار گذاشته می‌شوند.'; + + @override + String get retryCompleted => 'تلاش دوباره کامل شد.'; + + @override + String get discardPendingOperation => 'عملیات در انتظار کنار گذاشته شود؟'; + + @override + String get discardPendingOperationConfirmation => + 'با این کار عملیات محلی مسدودشده حذف می‌شود. در همگام‌سازی بعدی، داده‌ها از Google Tasks تازه‌سازی می‌شوند.'; + + @override + String get pendingOperationDiscarded => 'عملیات در انتظار کنار گذاشته شد.'; + + @override + String get syncFailureNotificationTitle => 'همگام‌سازی BusyMax ناموفق بود'; + + @override + String syncFailureNotificationBody(String message) { + return 'همگام‌سازی پس‌زمینه ناموفق بود. ⁨$message⁩'; + } + + @override + String get conflictNotificationTitle => 'تداخل همگام‌سازی BusyMax'; + + @override + String conflictNotificationBody(String summary) { + return 'یک تغییر محلی در انتظار مسدود شد. ⁨$summary⁩'; + } + + @override + String get dueTodayNotificationTitle => 'کارهای دارای سررسید امروز'; + + @override + String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'امروز ⁨$countString⁩ کار سررسید دارند.', + one: 'امروز یک کار سررسید دارد.', + zero: 'امروز هیچ کاری سررسید ندارد.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'یادآور رویداد'; + + @override + String get taskReminderNotificationTitle => 'یادآور کار'; + + @override + String get eventReminderNotificationBody => 'رویداد به‌زودی شروع می‌شود.'; + + @override + String get taskReminderNotificationBody => 'سررسید کار نزدیک است.'; + + @override + String get notificationOpenAction => 'باز کردن'; + + @override + String get notificationDetailsHidden => + 'جزئیات به‌دلیل تنظیمات حریم خصوصی پنهان شده‌اند.'; + + @override + String get previousMonth => 'ماه قبل'; + + @override + String get nextMonth => 'ماه بعد'; + + @override + String get openMonthView => 'باز کردن نمای ماه'; + + @override + String get previousYear => 'سال قبل'; + + @override + String get nextYear => 'سال بعد'; + + @override + String get openYearView => 'باز کردن نمای سال'; + + @override + String weekNumberTooltip(int number) { + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'هفتهٔ ⁨$numberString⁩'; + } + + @override + String get resizeAllDayPanel => 'تغییر اندازهٔ پنل تمام‌روز'; + + @override + String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '⁨$countString⁩ مورد', + one: 'یک مورد', + zero: 'هیچ موردی', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'این تقویم فقط‌خواندنی است.'; + + @override + String get selectTimeZone => 'انتخاب منطقهٔ زمانی'; + + @override + String get searchLocations => 'جست‌وجوی مکان‌ها'; + + @override + String get noLocationsFound => 'مکانی پیدا نشد'; + + @override + String deleteCalendarConfirmation(String title) { + return '«⁨$title⁩» حذف شود؟'; + } +} diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart new file mode 100644 index 0000000..5f5a3ee --- /dev/null +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -0,0 +1,1256 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Finnish (`fi`). +class AppLocalizationsFi extends AppLocalizations { + AppLocalizationsFi([String locale = 'fi']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Yhdistä Google- ja Microsoft-tilit kalenterien ja tehtävien synkronointia varten.'; + + @override + String get googlePermissionsConsentNotice => + 'Valitse Googlen käyttöoikeusnäkymässä sekä Kalenteri- että Tehtävät-käyttöoikeudet.'; + + @override + String get googlePermissionsRequiredRetry => + 'Google Kalenterin ja Google Tasksin käyttöoikeudet vaaditaan. Yritä uudelleen ja valitse molemmat valintaruudut.'; + + @override + String get finishSetup => 'Viimeistele määritys'; + + @override + String get continueSetup => 'Jatka'; + + @override + String get onboardingSetupTitle => 'Määritä BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'Yhdistä tilit'; + + @override + String get onboardingAccountsStepDescription => + 'Lisää kaikki haluamasi Google- ja Microsoft-tilit. BusyMax synkronoi kunkin tilin kalenterit, tapahtumat, tehtäväluettelot ja tehtävät.'; + + @override + String get onboardingPreferencesStepTitle => 'Valitse järjestelmäasetukset'; + + @override + String get onboardingPreferencesStepDescription => + 'Määritä sovelluksen toiminta työpöydällä, muistutukset, ilmoitusten yksityiskohtaisuus ja ulkoasu ennen aikataulun avaamista.'; + + @override + String get signInWithGoogle => 'Kirjaudu Google-tilillä'; + + @override + String get signInWithMicrosoft => 'Kirjaudu Microsoft-tilillä'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => + 'Tätä palveluntarjoajaa ei ole määritetty.'; + + @override + String get waitingForGoogleSignIn => 'Odotetaan Google-kirjautumista...'; + + @override + String get waitingForMicrosoftSignIn => + 'Odotetaan Microsoft-kirjautumista...'; + + @override + String get microsoftSignInNotConfigured => + 'Microsoft-kirjautumista ei ole määritetty. Aseta MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'Peruuta'; + + @override + String get close => 'Sulje'; + + @override + String get exit => 'Lopeta'; + + @override + String get options => 'Valinnat'; + + @override + String get hide => 'Piilota'; + + @override + String get show => 'Näytä'; + + @override + String get export => 'Vie'; + + @override + String get save => 'Tallenna'; + + @override + String get settings => 'Asetukset'; + + @override + String get all => 'Kaikki'; + + @override + String get calendarEvents => 'Tapahtumat'; + + @override + String get calendarTasks => 'Tehtävät'; + + @override + String get calendar => 'Kalenteri'; + + @override + String get calendars => 'Kalenterit'; + + @override + String get newEvent => 'Uusi tapahtuma'; + + @override + String get refreshCalendar => 'Päivitä kalenteri'; + + @override + String get openInProvider => 'Avaa palvelussa'; + + @override + String get hideFromSchedule => 'Piilota aikataulusta'; + + @override + String get showInSchedule => 'Näytä aikataulussa'; + + @override + String get noCalendarsSynced => 'Kalentereita ei ole vielä synkronoitu.'; + + @override + String get allDay => 'Koko päivä'; + + @override + String moreItems(int count) { + return '+$count muuta'; + } + + @override + String get noEventsOrTasks => 'Ei tapahtumia tai tehtäviä'; + + @override + String get scheduleLoading => 'Ladataan aikataulua...'; + + @override + String get scheduleUnavailable => 'Aikataulu ei ole käytettävissä'; + + @override + String get scheduleNoSources => + 'Ei näkyviä kalentereita tai tehtäväluetteloita'; + + @override + String get scheduleNoSourcesDescription => + 'Valitse asetuksissa, mitä näytetään, ja päivitä sitten näkymä.'; + + @override + String get scheduleSignInRequired => 'Yhdistä tili'; + + @override + String get scheduleSignInDescription => + 'Kirjaudu sisään synkronoidaksesi kalenterit ja tehtävät.'; + + @override + String get scheduleNoSearchResults => 'Ei vastaavia tapahtumia tai tehtäviä'; + + @override + String get scheduleNoSearchResultsDescription => + 'Kokeile toista hakua tai tyhjennä nykyiset suodattimet.'; + + @override + String get refresh => 'Päivitä'; + + @override + String get trayOpenBusyMax => 'Avaa BusyMax'; + + @override + String get agendaLoadMoreOverdue => 'Lataa lisää myöhässä olevia tehtäviä'; + + @override + String get agendaLoadMoreNoDate => 'Lataa lisää päiväämättömiä tehtäviä'; + + @override + String get viewDay => 'Päivä'; + + @override + String get viewWeek => 'Viikko'; + + @override + String get viewMonth => 'Kuukausi'; + + @override + String get viewYear => 'Vuosi'; + + @override + String get viewAgenda => 'Agenda'; + + @override + String get scheduleSettings => 'Aikataulu'; + + @override + String get scheduleDisplaySettings => 'Aikataulun näyttö'; + + @override + String get scheduleDisplayHoursDescription => + 'Päivä- ja viikkonäkymät näyttävät aluksi tämän aikavälin. Aikaisemmat ja myöhemmät kohteet laajentavat sitä tarvittaessa.'; + + @override + String get scheduleDayStartsAt => 'Päivä alkaa'; + + @override + String get scheduleDayEndsAt => 'Päivä päättyy'; + + @override + String get sourceCalendar => 'Kalenteri'; + + @override + String get sourceTaskList => 'Tehtäväluettelo'; + + @override + String get createChoiceTitle => 'Luo'; + + @override + String get createEventAtTime => 'Tapahtuma'; + + @override + String get createTaskAtDate => 'Tehtävä'; + + @override + String get editEvent => 'Muokkaa tapahtumaa'; + + @override + String get eventTitle => 'Tapahtuman nimi'; + + @override + String get location => 'Sijainti'; + + @override + String get timeSlot => 'Ajankohta'; + + @override + String get startDateTime => 'Alkamispäivä ja -aika'; + + @override + String get endDateTime => 'Päättymispäivä ja -aika'; + + @override + String get doesNotRepeat => 'Ei toistu'; + + @override + String get defaultReminder => 'Oletusmuistutus'; + + @override + String get guests => 'Vieraat'; + + @override + String get noGuests => 'Ei vieraita'; + + @override + String get description => 'Kuvaus'; + + @override + String get availabilityShowAs => 'Saatavuus / Näytä tilana'; + + @override + String get busy => 'Varattu'; + + @override + String get visibility => 'Näkyvyys'; + + @override + String get defaultVisibility => 'Oletusnäkyvyys'; + + @override + String get conference => 'Verkkokokous'; + + @override + String get noConference => 'Ei verkkokokousta'; + + @override + String get providerCalendar => 'Palveluntarjoajan kalenteri'; + + @override + String get formatBoldShortLabel => 'L'; + + @override + String get formatBoldTooltip => 'Lihavointi'; + + @override + String get formatItalicShortLabel => 'K'; + + @override + String get formatItalicTooltip => 'Kursivointi'; + + @override + String get formatUnderlineShortLabel => 'A'; + + @override + String get formatUnderlineTooltip => 'Alleviivaus'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minuuttia ennen', + one: '1 minuutti ennen', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'Alkamishetkellä'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours tuntia ennen', + one: '1 tunti ennen', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days päivää ennen', + one: '1 päivä ennen', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Vapaa'; + + @override + String get availabilityTentative => 'Alustava'; + + @override + String get availabilityOutOfOffice => 'Poissa toimistolta'; + + @override + String get availabilityWorkingElsewhere => 'Työskentelee muualla'; + + @override + String get visibilityDefault => 'Oletus'; + + @override + String get visibilityPublic => 'Julkinen'; + + @override + String get visibilityPrivate => 'Yksityinen'; + + @override + String get visibilityConfidential => 'Luottamuksellinen'; + + @override + String get sensitivityNormal => 'Normaali'; + + @override + String get sensitivityPersonal => 'Henkilökohtainen'; + + @override + String get tasks => 'Tehtävät'; + + @override + String get allTasks => 'Kaikki tehtävät'; + + @override + String tasksInList(String title) { + return 'Luettelon $title tehtävät'; + } + + @override + String get taskLists => 'Tehtäväluettelot'; + + @override + String get navigation => 'Siirtyminen'; + + @override + String get mainMenu => 'Päävalikko'; + + @override + String get keyboardShortcuts => 'Pikanäppäimet'; + + @override + String get shortcutGroupGeneral => 'Yleiset'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'Näytä tämä pikanäppäinluettelo'; + + @override + String get shortcutGroupNavigation => 'Siirtyminen'; + + @override + String get shortcutNextPeriod => 'Seuraava ajanjakso'; + + @override + String get shortcutNextPeriodDescription => + 'Seuraava viikko viikkonäkymässä, seuraava kuukausi kuukausinäkymässä ja niin edelleen'; + + @override + String get shortcutPreviousPeriod => 'Edellinen ajanjakso'; + + @override + String get shortcutPreviousPeriodDescription => + 'Edellinen viikko viikkonäkymässä, edellinen kuukausi kuukausinäkymässä ja niin edelleen'; + + @override + String get shortcutJumpToToday => 'Siirry tähän päivään'; + + @override + String get shortcutGroupView => 'Näkymä'; + + @override + String get shortcutDayView => 'Päivänäkymä'; + + @override + String get shortcutWeekView => 'Viikkonäkymä'; + + @override + String get shortcutMonthView => 'Kuukausinäkymä'; + + @override + String get shortcutYearView => 'Vuosinäkymä'; + + @override + String get shortcutAgendaView => 'Agendanäkymä'; + + @override + String get shortcutGroupCreateAndEdit => 'Luominen ja muokkaaminen'; + + @override + String get shortcutSaveItem => 'Tallenna tapahtuma tai tehtävä'; + + @override + String get shortcutDeleteItem => 'Poista tapahtuma tai tehtävä'; + + @override + String get shortcutGroupTaskEditing => 'Tehtävien muokkaaminen'; + + @override + String get shortcutCancelEditing => 'Peruuta muokkaaminen'; + + @override + String get shortcutCancelEditingDescription => + 'Sulje tehtävän muokkaus tai tehtävän tiedot'; + + @override + String get aboutBusyMax => 'Tietoja BusyMaxista'; + + @override + String get aboutBusyMaxDescription => 'Kalenteri ja tehtävät'; + + @override + String get license => 'Lisenssi'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => 'Verkkosivusto'; + + @override + String get sourceCode => 'Lähdekoodi'; + + @override + String get reportAnIssue => 'Ilmoita ongelmasta'; + + @override + String get sendFeedback => 'Lähetä palautetta'; + + @override + String get feedbackSubmit => 'Lähetä'; + + @override + String get feedbackCategory => 'Luokka'; + + @override + String get feedbackSelectCategory => 'Valitse luokka'; + + @override + String get feedbackCategoryProblem => 'Ongelma tai virhe'; + + @override + String get feedbackCategoryFeature => 'Ominaisuuspyyntö'; + + @override + String get feedbackCategoryPrivacySecurity => + 'Tietosuojaan tai tietoturvaan liittyvä huoli'; + + @override + String get feedbackCategoryUsability => 'Käytettävyyshuoli'; + + @override + String get feedbackCategoryOther => 'Muu'; + + @override + String get feedbackSubject => 'Aihe'; + + @override + String get feedbackDetailedMessage => 'Yksityiskohtainen viesti'; + + @override + String get feedbackReplyEmail => + 'Sähköpostiosoite vastausta varten (valinnainen)'; + + @override + String get feedbackIncludeTechnicalDetails => 'Sisällytä tekniset tiedot'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Lisää vain Linux-käyttöjärjestelmäsi version ja sovelluksen alueasetuksen. Lokeja, tilitietoja, tiedostonimiä tai muita diagnostiikkatietoja ei lisätä.'; + + @override + String get feedbackCategoryRequired => 'Valitse luokka.'; + + @override + String get feedbackSubjectLengthError => + 'Aiheen pituuden on oltava 3–120 merkkiä.'; + + @override + String get feedbackMessageLengthError => + 'Viestin pituuden on oltava 10–5 000 merkkiä.'; + + @override + String get feedbackInvalidEmail => 'Anna kelvollinen sähköpostiosoite.'; + + @override + String get feedbackConnectionError => + 'BusyStackiin ei saatu yhteyttä. Tarkista yhteys ja yritä uudelleen.'; + + @override + String get feedbackTimeoutError => + 'Pyyntö aikakatkaistiin. Palautettasi ei ole tyhjennetty. Yritä uudelleen.'; + + @override + String get feedbackRateLimitedError => + 'Tästä verkosta on lähetetty liian monta palautetta. Odota ja yritä uudelleen.'; + + @override + String get feedbackRejectedError => + 'Palvelin hylkäsi lähetyksen. Tarkista kentät ja yritä uudelleen.'; + + @override + String get feedbackServerError => + 'BusyStack ei voi vastaanottaa palautettasi juuri nyt. Palautettasi ei ole tyhjennetty. Yritä uudelleen.'; + + @override + String feedbackSuccess(String id) { + return 'Palaute lähetetty. Viite: $id'; + } + + @override + String get toggleSidebar => 'Näytä tai piilota sivupalkki'; + + @override + String get showSidebar => 'Näytä sivupaneeli'; + + @override + String get hideSidebar => 'Piilota sivupaneeli'; + + @override + String get accounts => 'Tilit'; + + @override + String get currentAccount => 'Nykyinen tili'; + + @override + String get switchAccount => 'Vaihda tiliä'; + + @override + String get addGoogleAccount => 'Lisää Google-tili'; + + @override + String get addMicrosoftAccount => 'Lisää Microsoft-tili'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'Kirjautunut'; + + @override + String get removeAccount => 'Poista tili…'; + + @override + String get removingAccount => 'Poistetaan tiliä…'; + + @override + String get removeAccountDescription => + 'Lopeta synkronointi ja poista tämän tilin tiedot tältä laitteelta.'; + + @override + String removeAccountTitle(String account) { + return 'Poistetaanko $account BusyMaxista?'; + } + + @override + String get removeAccountConfirmation => + 'Tämä poistaa välimuistissa olevat tehtävät, kalenterit, tapahtumat, muistutukset ja odottavat offline-muutokset tältä laitteelta. Synkronoimattomat muutokset menetetään. Mitään ei poisteta Googlesta tai Microsoftista.'; + + @override + String get revokeGoogleAccess => + 'Peruuta myös BusyMaxin käyttöoikeus tähän Google-tiliin'; + + @override + String get revokeGoogleAccessDescription => + 'Käyttöoikeus on myönnettävä uudelleen ennen tilin yhdistämistä.'; + + @override + String get removeAccountAction => 'Poista tili'; + + @override + String get removeAccountFailed => + 'Tilin poistamista ei voitu viimeistellä. Yritä uudelleen.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'Tili poistettiin tältä laitteelta, mutta BusyMax ei voinut peruuttaa Google-käyttöoikeutta. Voit peruuttaa sen Google-tililtäsi.'; + + @override + String get newList => 'Uusi luettelo'; + + @override + String get signInToViewTaskLists => + 'Kirjaudu sisään nähdäksesi tehtäväluettelot.'; + + @override + String get noTaskListsSynced => + 'Tehtäväluetteloita ei ole vielä synkronoitu.'; + + @override + String get listActions => 'Luettelon toiminnot'; + + @override + String get rename => 'Nimeä uudelleen'; + + @override + String get delete => 'Poista'; + + @override + String get renameList => 'Nimeä luettelo uudelleen'; + + @override + String get deleteList => 'Poista luettelo'; + + @override + String get builtInMicrosoftList => 'Sisäänrakennettu'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Microsoft To Do -sovelluksen sisäänrakennettuja luetteloita ei voi nimetä uudelleen tai poistaa.'; + + @override + String deleteListConfirmation(String title) { + return 'Poistetaanko \"$title\" Google Tasksista?'; + } + + @override + String get deleteEvent => 'Poista tapahtuma'; + + @override + String get title => 'Nimi'; + + @override + String get create => 'Luo'; + + @override + String get newTask => 'Uusi tehtävä'; + + @override + String get clearCompleted => 'Tyhjennä valmiit'; + + @override + String get refreshList => 'Päivitä luettelo'; + + @override + String get refreshAll => 'Päivitä kaikki'; + + @override + String get listRefreshed => 'Luettelo päivitetty.'; + + @override + String get allTasksRefreshed => 'Kaikki tilit päivitetty.'; + + @override + String exportedFile(String path) { + return 'Viety kohteeseen $path'; + } + + @override + String exportFailed(String error) { + return 'Vienti epäonnistui: $error'; + } + + @override + String refreshFailed(String error) { + return 'Päivitys epäonnistui: $error'; + } + + @override + String get selectOrCreateTaskList => + 'Valitse tai luo tehtäväluettelo aloittaaksesi.'; + + @override + String get signInToViewTasks => 'Kirjaudu sisään nähdäksesi tehtävät.'; + + @override + String get noTasks => 'Ei tehtäviä.'; + + @override + String get noTasksYet => 'Ei vielä tehtäviä'; + + @override + String get noTasksYetMessage => + 'Luo tehtävä tai päivitä tilisi aloittaaksesi.'; + + @override + String get noTasksInList => 'Tässä luettelossa ei ole tehtäviä.'; + + @override + String get overdue => 'Myöhässä'; + + @override + String get today => 'Tänään'; + + @override + String get tomorrow => 'Huomenna'; + + @override + String get upcoming => 'Tulossa'; + + @override + String get noDate => 'Ei päivämäärää'; + + @override + String get completed => 'Valmiit'; + + @override + String duePrefix(String date) { + return 'Eräpäivä $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date klo $time'; + } + + @override + String get taskDetails => 'Tehtävän tiedot'; + + @override + String get editTask => 'Muokkaa tehtävää'; + + @override + String get noTaskSelected => 'Tehtävää ei ole valittu.'; + + @override + String get noTaskSelectedHelper => + 'Valitse tehtävä nähdäksesi ja muokataksesi sen tietoja.'; + + @override + String get taskUnavailable => 'Tehtävä ei ole käytettävissä.'; + + @override + String get signInToEditTasks => 'Kirjaudu sisään muokataksesi tehtäviä.'; + + @override + String get refreshTask => 'Päivitä tehtävä'; + + @override + String get primarySection => 'Ensisijaiset tiedot'; + + @override + String get statusSection => 'Tila'; + + @override + String get openStatus => 'Avoin'; + + @override + String get doneStatus => 'Valmis'; + + @override + String get notes => 'Muistiinpanot'; + + @override + String get dueDate => 'Eräpäivä'; + + @override + String get clearDueDate => 'Tyhjennä eräpäivä'; + + @override + String get dueTime => 'Erääntymisaika'; + + @override + String get startDate => 'Alkamispäivä'; + + @override + String get startTime => 'Alkamisaika'; + + @override + String get endDate => 'Päättymispäivä'; + + @override + String get endTime => 'Päättymisaika'; + + @override + String get reminderDate => 'Muistutuspäivä'; + + @override + String get reminderTime => 'Muistutusaika'; + + @override + String get reminder => 'Muistutus'; + + @override + String get addReminder => 'Lisää muistutus'; + + @override + String get addGuest => 'Lisää vieras'; + + @override + String get addGuestEmail => 'Lisää vieraan sähköpostiosoite'; + + @override + String get removeReminder => 'Poista muistutus'; + + @override + String get off => 'Ei käytössä'; + + @override + String get repeat => 'Toisto'; + + @override + String get repeatNone => 'Ei toistoa'; + + @override + String get noneValue => 'Ei mitään'; + + @override + String get repeatDaily => 'Päivittäin'; + + @override + String get repeatWeekly => 'Viikoittain'; + + @override + String get repeatMonthly => 'Kuukausittain'; + + @override + String get repeatYearly => 'Vuosittain'; + + @override + String get importance => 'Tärkeys'; + + @override + String get importanceLow => 'Pieni'; + + @override + String get importanceNormal => 'Normaali'; + + @override + String get importanceHigh => 'Suuri'; + + @override + String get categories => 'Luokat'; + + @override + String get scheduleSection => 'Aikataulu'; + + @override + String get dueGroup => 'Eräpäivä'; + + @override + String get startGroup => 'Alku'; + + @override + String get reminderGroup => 'Muistutus'; + + @override + String get organizationSection => 'Järjestely'; + + @override + String get actionsSection => 'Toiminnot'; + + @override + String get advancedSection => 'Lisäasetukset'; + + @override + String get addCategory => 'Lisää luokka'; + + @override + String get list => 'Luettelo'; + + @override + String get microsoftMoveUnsupported => + 'Luettelosta toiseen siirtämistä ei tueta Microsoft To Do -tileillä tässä versiossa.'; + + @override + String get createSubtask => 'Luo alitehtävä'; + + @override + String get moveToTop => 'Siirrä ylimmäksi'; + + @override + String get deleteTask => 'Poista tehtävä'; + + @override + String get newSubtask => 'Uusi alitehtävä'; + + @override + String deleteTaskConfirmation(String title) { + return 'Poistetaanko \"$title\" Google Tasksista?'; + } + + @override + String get metadata => 'Metatiedot'; + + @override + String get id => 'Tunnus'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'Päivitetty'; + + @override + String get parent => 'Ylätehtävä'; + + @override + String get position => 'Sijainti'; + + @override + String get webLink => 'Verkkolinkki'; + + @override + String get assignment => 'Määritys'; + + @override + String get localState => 'Paikallinen tila'; + + @override + String get pendingSync => 'Odottaa synkronointia'; + + @override + String get synced => 'Synkronoitu'; + + @override + String get account => 'Tili'; + + @override + String get sync => 'Synkronointi'; + + @override + String get manualFullSync => 'Manuaalinen täysi synkronointi'; + + @override + String get runInBackgroundWhenClosed => + 'Jatka toimintaa, kun ikkuna suljetaan'; + + @override + String get showTrayIcon => 'Näytä ilmoitusalueen kuvake'; + + @override + String get startMinimizedToTray => 'Käynnistä pienennettynä ilmoitusalueelle'; + + @override + String get requiresTrayIcon => 'Vaatii ilmoitusalueen kuvakkeen.'; + + @override + String get syncComplete => 'Synkronointi valmis.'; + + @override + String syncFailed(String error) { + return 'Synkronointi epäonnistui: $error'; + } + + @override + String get notifySyncFailures => 'Ilmoita synkronointivirheistä'; + + @override + String get notifyConflicts => 'Ilmoita ristiriidoista'; + + @override + String get notifyDueToday => 'Ilmoita tänään erääntyvistä'; + + @override + String get eventReminders => 'Tapahtumamuistutukset'; + + @override + String get taskReminders => 'Tehtävämuistutukset'; + + @override + String get notificationDetailLevel => 'Ilmoitusten yksityiskohtaisuus'; + + @override + String get notificationDetailPrivate => 'Yksityinen'; + + @override + String get notificationDetailNormal => 'Normaali'; + + @override + String get quietHours => 'Hiljainen aika'; + + @override + String get quietHoursDescription => + 'Keskeytä ilmoitukset tällä ajanjaksolla.'; + + @override + String get quietHoursStart => 'Hiljaisen ajan alku'; + + @override + String get quietHoursEnd => 'Hiljaisen ajan loppu'; + + @override + String get notifications => 'Ilmoitukset'; + + @override + String get appearance => 'Ulkoasu'; + + @override + String get theme => 'Teema'; + + @override + String get themeSystem => 'Järjestelmä'; + + @override + String get themeLight => 'Vaalea'; + + @override + String get themeDark => 'Tumma'; + + @override + String get themeFamily => 'Teemaperhe'; + + @override + String get themeFamilyYaru => 'Ubuntun oma (Yaru)'; + + @override + String get localization => 'Lokalisointi'; + + @override + String get currentLocale => 'Nykyinen alueasetus'; + + @override + String get privacy => 'Tietosuoja'; + + @override + String get redactTaskContentInDiagnostics => + 'Peitä tehtävien sisältö diagnostiikassa'; + + @override + String get developerDiagnostics => 'Kehittäjän diagnostiikka'; + + @override + String get diagnostics => 'Diagnostiikka'; + + @override + String get apiInspectorDisabled => 'Näytä API-tarkastaja'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'Discovery-versio: $revision'; + } + + @override + String get implementedMethods => 'Toteutetut metodit'; + + @override + String get supportsTasksScopes => + 'Tukee tasks- ja tasks.readonly-käyttöoikeusalueita'; + + @override + String get requiresTasksScope => 'Vaatii tasks-käyttöoikeusalueen'; + + @override + String get blockedPendingOperations => 'Estetyt odottavat toiminnot'; + + @override + String get signInToInspectPendingOperations => + 'Kirjaudu sisään tarkastellaksesi odottavia toimintoja.'; + + @override + String get noBlockedPendingOperations => 'Ei estettyjä odottavia toimintoja.'; + + @override + String get operationActions => 'Toiminnot'; + + @override + String pendingOpListId(String id) { + return 'luettelo=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'tehtävä=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'yritykset=$count'; + } + + @override + String get retry => 'Yritä uudelleen'; + + @override + String get discard => 'Hylkää'; + + @override + String get discardChangesAction => 'Hylkää'; + + @override + String get discardChanges => 'Hylätäänkö muutokset?'; + + @override + String get discardChangesConfirmation => + 'Tämä hylkää tehtävän tallentamattomat muutokset.'; + + @override + String get retryCompleted => 'Uudelleenyritys suoritettu.'; + + @override + String get discardPendingOperation => 'Hylätäänkö odottava toiminto?'; + + @override + String get discardPendingOperationConfirmation => + 'Tämä poistaa estetyn paikallisen toiminnon. Seuraava synkronointi päivittää tiedot Google Tasksista.'; + + @override + String get pendingOperationDiscarded => 'Odottava toiminto hylätty.'; + + @override + String get syncFailureNotificationTitle => + 'BusyMaxin synkronointi epäonnistui'; + + @override + String syncFailureNotificationBody(String message) { + return 'Taustasynkronointi epäonnistui. $message'; + } + + @override + String get conflictNotificationTitle => 'BusyMaxin synkronointiristiriita'; + + @override + String conflictNotificationBody(String summary) { + return 'Odottava paikallinen muutos estettiin. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'Tänään erääntyvät tehtävät'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tehtävää erääntyy tänään.', + one: 'Yksi tehtävä erääntyy tänään.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'Tapahtumamuistutus'; + + @override + String get taskReminderNotificationTitle => 'Tehtävämuistutus'; + + @override + String get eventReminderNotificationBody => 'Tapahtuma alkaa pian.'; + + @override + String get taskReminderNotificationBody => 'Tehtävän määräaika lähestyy.'; + + @override + String get notificationOpenAction => 'Avaa'; + + @override + String get notificationDetailsHidden => + 'Yksityiskohdat on piilotettu tietosuoja-asetusten vuoksi.'; + + @override + String get previousMonth => 'Edellinen kuukausi'; + + @override + String get nextMonth => 'Seuraava kuukausi'; + + @override + String get openMonthView => 'Avaa kuukausinäkymä'; + + @override + String get previousYear => 'Edellinen vuosi'; + + @override + String get nextYear => 'Seuraava vuosi'; + + @override + String get openYearView => 'Avaa vuosinäkymä'; + + @override + String weekNumberTooltip(int number) { + return 'Viikko $number'; + } + + @override + String get resizeAllDayPanel => 'Muuta koko päivän paneelin kokoa'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count kohdetta', + one: '1 kohde', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Tämä kalenteri on kirjoitussuojattu.'; + + @override + String get selectTimeZone => 'Valitse aikavyöhyke'; + + @override + String get searchLocations => 'Hae sijainteja'; + + @override + String get noLocationsFound => 'Sijainteja ei löytynyt'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Poistetaanko \"$title\"?'; + } +} diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 302b989..17b96a0 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1,3 +1,7 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; @@ -44,7 +48,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get onboardingPreferencesStepDescription => - 'Réglez le comportement du bureau, les rappels, le détail des notifications et l’apparence avant d’ouvrir votre planning.'; + 'Réglez le comportement de l’application sur le bureau, les rappels, le niveau de détail des notifications et l’apparence avant d’ouvrir votre planning.'; @override String get signInWithGoogle => 'Se connecter avec Google'; @@ -121,7 +125,7 @@ class AppLocalizationsFr extends AppLocalizations { String get refreshCalendar => 'Actualiser le calendrier'; @override - String get openInProvider => 'Ouvrir chez le fournisseur'; + String get openInProvider => 'Ouvrir dans le service'; @override String get hideFromSchedule => 'Masquer du planning'; @@ -144,68 +148,38 @@ class AppLocalizationsFr extends AppLocalizations { String get noEventsOrTasks => 'Aucun événement ni tâche'; @override - String get trayAgendaLoading => 'Chargement de l’agenda...'; - - @override - String get trayAgendaSignInRequired => - 'Connectez-vous pour afficher l’agenda.'; - - @override - String get trayAgendaNoSources => - 'Aucun calendrier ni liste de tâches visible.'; - - @override - String get trayAgendaOpenBusyMax => 'Ouvrir l’app'; - - @override - String get trayAgendaRefresh => 'Actualiser'; + String get scheduleLoading => 'Chargement du planning...'; @override - String get trayAgendaError => 'Agenda indisponible'; + String get scheduleUnavailable => 'Planning indisponible'; @override - String get compactAgendaTitle => 'Agenda'; + String get scheduleNoSources => 'Aucun calendrier ni liste de tâches visible'; @override - String get compactAgendaSubtitle => 'À venir'; + String get scheduleNoSourcesDescription => + 'Choisissez les éléments à afficher dans les paramètres, puis actualisez.'; @override - String get compactAgendaOverdue => 'En retard'; + String get scheduleSignInRequired => 'Connecter un compte'; @override - String get compactAgendaClear => 'Libre pour le moment'; + String get scheduleSignInDescription => + 'Connectez-vous pour synchroniser vos calendriers et vos tâches.'; @override - String get compactAgendaOpenBusyMax => 'Ouvrir BusyMax'; + String get scheduleNoSearchResults => + 'Aucun événement ni aucune tâche ne correspond'; @override - String get compactAgendaHide => 'Masquer'; + String get scheduleNoSearchResultsDescription => + 'Essayez une autre recherche ou effacez les filtres actuels.'; @override - String get compactAgendaNewTask => 'Nouvelle tâche'; - - @override - String get compactAgendaRetry => 'Réessayer'; - - @override - String get compactAgendaRefresh => 'Actualiser'; - - @override - String get compactAgendaAllDay => 'Toute la journée'; - - @override - String get compactAgendaDueToday => 'Échéance aujourd’hui'; - - @override - String get compactAgendaDueTomorrow => 'Échéance demain'; - - @override - String compactAgendaDueOn(String date) { - return 'Échéance $date'; - } + String get refresh => 'Actualiser'; @override - String get compactAgendaMoreOverdue => 'Charger plus de tâches en retard'; + String get trayOpenBusyMax => 'Ouvrir BusyMax'; @override String get agendaLoadMoreOverdue => 'Charger plus de tâches en retard'; @@ -236,7 +210,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get scheduleDisplayHoursDescription => - 'Les vues Jour et Semaine s’ouvrent dans cette plage horaire. Les éléments tôt ou tardifs l’élargissent si nécessaire.'; + 'Les vues Jour et Semaine s’ouvrent dans cette plage horaire. Les éléments situés avant ou après cette plage l’étendent si nécessaire.'; @override String get scheduleDayStartsAt => 'La journée commence à'; @@ -313,6 +287,90 @@ class AppLocalizationsFr extends AppLocalizations { @override String get providerCalendar => 'Calendrier du fournisseur'; + @override + String get formatBoldShortLabel => 'G'; + + @override + String get formatBoldTooltip => 'Gras'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'Italique'; + + @override + String get formatUnderlineShortLabel => 'S'; + + @override + String get formatUnderlineTooltip => 'Souligné'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutes avant', + one: '1 minute avant', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'À l’heure de début'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours heures avant', + one: '1 heure avant', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days jours avant', + one: '1 jour avant', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Disponible'; + + @override + String get availabilityTentative => 'Provisoire'; + + @override + String get availabilityOutOfOffice => 'Absent du bureau'; + + @override + String get availabilityWorkingElsewhere => 'Travail ailleurs'; + + @override + String get visibilityDefault => 'Par défaut'; + + @override + String get visibilityPublic => 'Publique'; + + @override + String get visibilityPrivate => 'Privée'; + + @override + String get visibilityConfidential => 'Confidentielle'; + + @override + String get sensitivityNormal => 'Normale'; + + @override + String get sensitivityPersonal => 'Personnelle'; + @override String get tasks => 'Tâches'; @@ -361,7 +419,7 @@ class AppLocalizationsFr extends AppLocalizations { 'Semaine précédente en vue semaine, mois précédent en vue mois, etc.'; @override - String get shortcutJumpToToday => 'Aller à aujourd\'hui'; + String get shortcutJumpToToday => 'Aller à la date du jour'; @override String get shortcutGroupView => 'Affichage'; @@ -401,24 +459,22 @@ class AppLocalizationsFr extends AppLocalizations { 'Fermer la modification ou les détails de la tâche'; @override - String get shortcutGroupCompactAgenda => 'Agenda compact'; + String get aboutBusyMax => 'À propos de BusyMax'; @override - String get shortcutRefreshCompactAgendaDescription => - 'Actualiser la fenêtre d\'agenda compact'; + String get aboutBusyMaxDescription => 'Calendrier et tâches'; @override - String get shortcutHideCompactAgendaDescription => - 'Masquer la fenêtre d\'agenda compact'; + String get license => 'Licence'; @override - String get aboutBusyMax => 'À propos de BusyMax'; + String get apacheLicenseName => 'Apache License 2.0'; @override - String get aboutBusyMaxDescription => 'ToDo et calendrier'; + String get website => 'Site web'; @override - String get website => 'Site web'; + String get sourceCode => 'Code source'; @override String get reportAnIssue => 'Signaler un problème'; @@ -510,6 +566,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get toggleSidebar => 'Afficher/masquer la barre latérale'; + @override + String get showSidebar => 'Afficher le panneau latéral'; + + @override + String get hideSidebar => 'Masquer le panneau latéral'; + @override String get accounts => 'Comptes'; @@ -535,17 +597,42 @@ class AppLocalizationsFr extends AppLocalizations { String get signedInAccount => 'Connecté'; @override - String get signOutThisAccount => 'Déconnecter ce compte'; + String get removeAccount => 'Supprimer le compte…'; @override - String get revokeThisAccount => 'Révoquer ce compte'; + String get removingAccount => 'Suppression du compte…'; + + @override + String get removeAccountDescription => + 'Arrêter la synchronisation et supprimer les données de ce compte de cet appareil.'; + + @override + String removeAccountTitle(String account) { + return 'Supprimer $account de BusyMax ?'; + } @override - String get disconnectThisAccount => 'Dissocier ce compte'; + String get removeAccountConfirmation => + 'Cette action supprime de cet appareil les tâches, calendriers, événements, rappels et modifications hors ligne en attente mis en cache. Les modifications non synchronisées seront perdues. Aucune donnée ne sera supprimée de Google ou Microsoft.'; @override - String get deleteLocalDataForThisAccount => - 'Supprimer les données locales de ce compte'; + String get revokeGoogleAccess => + 'Révoquer également l’accès de BusyMax à ce compte Google'; + + @override + String get revokeGoogleAccessDescription => + 'Vous devrez accorder à nouveau l’accès avant de reconnecter le compte.'; + + @override + String get removeAccountAction => 'Supprimer le compte'; + + @override + String get removeAccountFailed => + 'Impossible de terminer la suppression du compte. Réessayez.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'Le compte a été supprimé de cet appareil, mais BusyMax n’a pas pu révoquer son accès à votre compte Google. Vous pouvez révoquer cet accès depuis votre compte Google.'; @override String get newList => 'Nouvelle liste'; @@ -597,7 +684,7 @@ class AppLocalizationsFr extends AppLocalizations { String get newTask => 'Nouvelle tâche'; @override - String get clearCompleted => 'Effacer les terminées'; + String get clearCompleted => 'Effacer les tâches terminées'; @override String get refreshList => 'Actualiser la liste'; @@ -628,7 +715,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get selectOrCreateTaskList => - 'Sélectionnez ou créez une liste de tâches.'; + 'Sélectionnez ou créez une liste de tâches pour commencer.'; @override String get signInToViewTasks => 'Connectez-vous pour voir les tâches.'; @@ -674,21 +761,6 @@ class AppLocalizationsFr extends AppLocalizations { return '$date à $time'; } - @override - String get searchTasks => 'Rechercher des tâches'; - - @override - String get advancedFilters => 'Filtres avancés'; - - @override - String get showCompleted => 'Afficher les terminées'; - - @override - String get showHidden => 'Afficher les masquées'; - - @override - String get showAssigned => 'Afficher les assignées'; - @override String get taskDetails => 'Détails de la tâche'; @@ -842,7 +914,7 @@ class AppLocalizationsFr extends AppLocalizations { String get createSubtask => 'Créer une sous-tâche'; @override - String get moveToTop => 'Déplacer en haut'; + String get moveToTop => 'Déplacer tout en haut'; @override String get deleteTask => 'Supprimer la tâche'; @@ -868,7 +940,7 @@ class AppLocalizationsFr extends AppLocalizations { String get updated => 'Mis à jour'; @override - String get parent => 'Parent'; + String get parent => 'Tâche parente'; @override String get position => 'Position'; @@ -877,7 +949,7 @@ class AppLocalizationsFr extends AppLocalizations { String get webLink => 'Lien web'; @override - String get assignment => 'Assignation'; + String get assignment => 'Attribution'; @override String get localState => 'État local'; @@ -892,23 +964,25 @@ class AppLocalizationsFr extends AppLocalizations { String get account => 'Compte'; @override - String get signOut => 'Se déconnecter'; + String get sync => 'Synchronisation'; @override - String get revokeGoogleAuthorization => 'Révoquer l’autorisation Google'; + String get manualFullSync => 'Synchronisation complète manuelle'; @override - String get deleteLocalData => 'Supprimer les données locales'; + String get runInBackgroundWhenClosed => + 'Continuer à s’exécuter après la fermeture de la fenêtre'; @override - String get deleteLocalDataConfirmation => - 'Cela supprime de cet appareil le compte local, les tâches synchronisées et les changements hors ligne en attente.'; + String get showTrayIcon => 'Afficher l’icône dans la zone de notification'; @override - String get sync => 'Synchronisation'; + String get startMinimizedToTray => + 'Démarrer réduit dans la zone de notification'; @override - String get manualFullSync => 'Synchronisation complète manuelle'; + String get requiresTrayIcon => + 'Nécessite l’icône de la zone de notification.'; @override String get syncComplete => 'Synchronisation terminée.'; @@ -927,6 +1001,34 @@ class AppLocalizationsFr extends AppLocalizations { @override String get notifyDueToday => 'Notifications des tâches dues aujourd’hui'; + @override + String get eventReminders => 'Rappels d’événements'; + + @override + String get taskReminders => 'Rappels de tâches'; + + @override + String get notificationDetailLevel => 'Niveau de détail des notifications'; + + @override + String get notificationDetailPrivate => 'Privé'; + + @override + String get notificationDetailNormal => 'Normal'; + + @override + String get quietHours => 'Période de silence'; + + @override + String get quietHoursDescription => + 'Mettre les notifications en pause pendant cette période.'; + + @override + String get quietHoursStart => 'Début de la période de silence'; + + @override + String get quietHoursEnd => 'Fin de la période de silence'; + @override String get notifications => 'Notifications'; @@ -946,10 +1048,10 @@ class AppLocalizationsFr extends AppLocalizations { String get themeDark => 'Sombre'; @override - String get themeFamily => 'Famille de thème'; + String get themeFamily => 'Famille de thèmes'; @override - String get themeFamilyYaru => 'Ubuntu natif (Yaru)'; + String get themeFamilyYaru => 'Thème natif d’Ubuntu (Yaru)'; @override String get localization => 'Localisation'; @@ -964,9 +1066,6 @@ class AppLocalizationsFr extends AppLocalizations { String get redactTaskContentInDiagnostics => 'Masquer le contenu des tâches dans les diagnostics'; - @override - String get detailedNotifications => 'Texte de notification détaillé'; - @override String get developerDiagnostics => 'Diagnostics développeur'; @@ -1027,27 +1126,30 @@ class AppLocalizationsFr extends AppLocalizations { String get retry => 'Réessayer'; @override - String get discard => 'Ignorer'; + String get discard => 'Abandonner'; @override - String get discardChanges => 'Ignorer les modifications ?'; + String get discardChangesAction => 'Abandonner'; + + @override + String get discardChanges => 'Abandonner les modifications ?'; @override String get discardChangesConfirmation => - 'Cela ignore les modifications non enregistrées de cette tâche.'; + 'Les modifications non enregistrées apportées à cette tâche seront perdues.'; @override String get retryCompleted => 'Nouvelle tentative terminée.'; @override - String get discardPendingOperation => 'Ignorer l’opération en attente ?'; + String get discardPendingOperation => 'Abandonner l’opération en attente ?'; @override String get discardPendingOperationConfirmation => - 'Cela supprime l’opération locale bloquée. La prochaine synchronisation actualisera depuis Google Tasks.'; + 'Cette action supprime l’opération locale bloquée. Lors de la prochaine synchronisation, les données seront rechargées depuis Google Tasks.'; @override - String get pendingOperationDiscarded => 'Opération en attente ignorée.'; + String get pendingOperationDiscarded => 'Opération en attente abandonnée.'; @override String get syncFailureNotificationTitle => @@ -1080,7 +1182,78 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } + @override + String get eventReminderNotificationTitle => 'Rappel d’événement'; + + @override + String get taskReminderNotificationTitle => 'Rappel de tâche'; + + @override + String get eventReminderNotificationBody => 'L’événement commence bientôt.'; + + @override + String get taskReminderNotificationBody => + 'La tâche arrive bientôt à échéance.'; + + @override + String get notificationOpenAction => 'Ouvrir'; + @override String get notificationDetailsHidden => 'Les détails sont masqués par les paramètres de confidentialité.'; + + @override + String get previousMonth => 'Mois précédent'; + + @override + String get nextMonth => 'Mois suivant'; + + @override + String get openMonthView => 'Ouvrir la vue mensuelle'; + + @override + String get previousYear => 'Année précédente'; + + @override + String get nextYear => 'Année suivante'; + + @override + String get openYearView => 'Ouvrir la vue annuelle'; + + @override + String weekNumberTooltip(int number) { + return 'Semaine $number'; + } + + @override + String get resizeAllDayPanel => + 'Redimensionner le volet des événements sur toute la journée'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count éléments', + one: '1 élément', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Ce calendrier est en lecture seule.'; + + @override + String get selectTimeZone => 'Sélectionner le fuseau horaire'; + + @override + String get searchLocations => 'Rechercher des lieux'; + + @override + String get noLocationsFound => 'Aucun lieu trouvé'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Supprimer « $title » ?'; + } } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart new file mode 100644 index 0000000..71cb781 --- /dev/null +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -0,0 +1,1250 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Hindi (`hi`). +class AppLocalizationsHi extends AppLocalizations { + AppLocalizationsHi([String locale = 'hi']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'कैलेंडर और कार्य सिंक करने के लिए Google और Microsoft खाते कनेक्ट करें।'; + + @override + String get googlePermissionsConsentNotice => + 'Google अनुमति स्क्रीन पर, कैलेंडर और कार्य दोनों अनुमतियाँ चुनें।'; + + @override + String get googlePermissionsRequiredRetry => + 'Google Calendar और Google Tasks की अनुमतियाँ आवश्यक हैं। फिर से कोशिश करें और दोनों चेकबॉक्स चुनें।'; + + @override + String get finishSetup => 'सेटअप पूरा करें'; + + @override + String get continueSetup => 'जारी रखें'; + + @override + String get onboardingSetupTitle => 'BusyMax सेट अप करें'; + + @override + String get onboardingAccountsStepTitle => 'खाते कनेक्ट करें'; + + @override + String get onboardingAccountsStepDescription => + 'वे सभी Google और Microsoft खाते जोड़ें जिन्हें आप उपयोग करना चाहते हैं। BusyMax प्रत्येक खाते के कैलेंडर, ईवेंट, कार्य सूचियाँ और कार्य सिंक करता है।'; + + @override + String get onboardingPreferencesStepTitle => 'सिस्टम सेटिंग्स चुनें'; + + @override + String get onboardingPreferencesStepDescription => + 'अपना शेड्यूल खोलने से पहले डेस्कटॉप व्यवहार, रिमाइंडर, सूचनाओं के विवरण का स्तर और दिखावट सेट करें।'; + + @override + String get signInWithGoogle => 'Google से साइन इन करें'; + + @override + String get signInWithMicrosoft => 'Microsoft से साइन इन करें'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'यह प्रदाता कॉन्फ़िगर नहीं किया गया है।'; + + @override + String get waitingForGoogleSignIn => + 'Google साइन-इन की प्रतीक्षा हो रही है...'; + + @override + String get waitingForMicrosoftSignIn => + 'Microsoft साइन-इन की प्रतीक्षा हो रही है...'; + + @override + String get microsoftSignInNotConfigured => + 'Microsoft साइन-इन कॉन्फ़िगर नहीं है। MICROSOFT_OAUTH_CLIENT_ID सेट करें।'; + + @override + String get cancel => 'रद्द करें'; + + @override + String get close => 'बंद करें'; + + @override + String get exit => 'बाहर निकलें'; + + @override + String get options => 'विकल्प'; + + @override + String get hide => 'छिपाएँ'; + + @override + String get show => 'दिखाएँ'; + + @override + String get export => 'निर्यात करें'; + + @override + String get save => 'सहेजें'; + + @override + String get settings => 'सेटिंग्स'; + + @override + String get all => 'सभी'; + + @override + String get calendarEvents => 'ईवेंट'; + + @override + String get calendarTasks => 'कार्य'; + + @override + String get calendar => 'कैलेंडर'; + + @override + String get calendars => 'कैलेंडर'; + + @override + String get newEvent => 'नया ईवेंट'; + + @override + String get refreshCalendar => 'कैलेंडर रीफ़्रेश करें'; + + @override + String get openInProvider => 'सेवा में खोलें'; + + @override + String get hideFromSchedule => 'शेड्यूल से छिपाएँ'; + + @override + String get showInSchedule => 'शेड्यूल में दिखाएँ'; + + @override + String get noCalendarsSynced => 'अभी तक कोई कैलेंडर सिंक नहीं हुआ है।'; + + @override + String get allDay => 'पूरे दिन'; + + @override + String moreItems(int count) { + return '+$count और'; + } + + @override + String get noEventsOrTasks => 'कोई ईवेंट या कार्य नहीं'; + + @override + String get scheduleLoading => 'शेड्यूल लोड हो रहा है...'; + + @override + String get scheduleUnavailable => 'शेड्यूल उपलब्ध नहीं है'; + + @override + String get scheduleNoSources => + 'कोई दिखाई देने वाला कैलेंडर या कार्य सूची नहीं'; + + @override + String get scheduleNoSourcesDescription => + 'सेटिंग्स में चुनें कि क्या दिखाना है, फिर रीफ़्रेश करें।'; + + @override + String get scheduleSignInRequired => 'खाता कनेक्ट करें'; + + @override + String get scheduleSignInDescription => + 'कैलेंडर और कार्य सिंक करने के लिए साइन इन करें।'; + + @override + String get scheduleNoSearchResults => 'कोई मिलता-जुलता ईवेंट या कार्य नहीं'; + + @override + String get scheduleNoSearchResultsDescription => + 'कोई दूसरी खोज आज़माएँ या मौजूदा फ़िल्टर हटाएँ।'; + + @override + String get refresh => 'रीफ़्रेश करें'; + + @override + String get trayOpenBusyMax => 'BusyMax खोलें'; + + @override + String get agendaLoadMoreOverdue => + 'समय-सीमा पार कर चुके अतिरिक्त कार्य लोड करें'; + + @override + String get agendaLoadMoreNoDate => 'बिना तारीख वाले और कार्य लोड करें'; + + @override + String get viewDay => 'दिन'; + + @override + String get viewWeek => 'सप्ताह'; + + @override + String get viewMonth => 'महीना'; + + @override + String get viewYear => 'वर्ष'; + + @override + String get viewAgenda => 'कार्यसूची'; + + @override + String get scheduleSettings => 'शेड्यूल'; + + @override + String get scheduleDisplaySettings => 'शेड्यूल प्रदर्शन'; + + @override + String get scheduleDisplayHoursDescription => + 'दिन और सप्ताह दृश्य शुरू में यह समयावधि दिखाते हैं। आवश्यकता होने पर पहले या बाद के आइटम इस सीमा को बढ़ाते हैं।'; + + @override + String get scheduleDayStartsAt => 'दिन शुरू होता है'; + + @override + String get scheduleDayEndsAt => 'दिन समाप्त होता है'; + + @override + String get sourceCalendar => 'कैलेंडर'; + + @override + String get sourceTaskList => 'कार्य सूची'; + + @override + String get createChoiceTitle => 'बनाएँ'; + + @override + String get createEventAtTime => 'ईवेंट'; + + @override + String get createTaskAtDate => 'कार्य'; + + @override + String get editEvent => 'ईवेंट संपादित करें'; + + @override + String get eventTitle => 'ईवेंट का शीर्षक'; + + @override + String get location => 'स्थान'; + + @override + String get timeSlot => 'समयावधि'; + + @override + String get startDateTime => 'शुरू होने की तारीख/समय'; + + @override + String get endDateTime => 'समाप्त होने की तारीख/समय'; + + @override + String get doesNotRepeat => 'दोहराया नहीं जाता'; + + @override + String get defaultReminder => 'डिफ़ॉल्ट रिमाइंडर'; + + @override + String get guests => 'अतिथि'; + + @override + String get noGuests => 'कोई अतिथि नहीं'; + + @override + String get description => 'विवरण'; + + @override + String get availabilityShowAs => 'उपलब्धता / इस रूप में दिखाएँ'; + + @override + String get busy => 'व्यस्त'; + + @override + String get visibility => 'दृश्यता'; + + @override + String get defaultVisibility => 'डिफ़ॉल्ट दृश्यता'; + + @override + String get conference => 'कॉन्फ़्रेंस'; + + @override + String get noConference => 'कोई कॉन्फ़्रेंस नहीं'; + + @override + String get providerCalendar => 'प्रदाता कैलेंडर'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => 'बोल्ड'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'इटैलिक'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => 'रेखांकित'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes मिनट पहले', + one: '1 मिनट पहले', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'शुरू होने पर'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours घंटे पहले', + one: '1 घंटा पहले', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days दिन पहले', + one: '1 दिन पहले', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'खाली'; + + @override + String get availabilityTentative => 'अस्थायी'; + + @override + String get availabilityOutOfOffice => 'कार्यालय से बाहर'; + + @override + String get availabilityWorkingElsewhere => 'किसी अन्य स्थान पर कार्यरत'; + + @override + String get visibilityDefault => 'डिफ़ॉल्ट'; + + @override + String get visibilityPublic => 'सार्वजनिक'; + + @override + String get visibilityPrivate => 'निजी'; + + @override + String get visibilityConfidential => 'गोपनीय'; + + @override + String get sensitivityNormal => 'सामान्य'; + + @override + String get sensitivityPersonal => 'व्यक्तिगत'; + + @override + String get tasks => 'कार्य'; + + @override + String get allTasks => 'सभी कार्य'; + + @override + String tasksInList(String title) { + return '$title में कार्य'; + } + + @override + String get taskLists => 'कार्य सूचियाँ'; + + @override + String get navigation => 'नेविगेशन'; + + @override + String get mainMenu => 'मुख्य मेन्यू'; + + @override + String get keyboardShortcuts => 'कीबोर्ड शॉर्टकट'; + + @override + String get shortcutGroupGeneral => 'सामान्य'; + + @override + String get shortcutKeyboardShortcutsDescription => 'यह शॉर्टकट संदर्भ दिखाएँ'; + + @override + String get shortcutGroupNavigation => 'नेविगेशन'; + + @override + String get shortcutNextPeriod => 'अगली अवधि'; + + @override + String get shortcutNextPeriodDescription => + 'सप्ताह दृश्य में अगला सप्ताह, महीने के दृश्य में अगला महीना, इत्यादि'; + + @override + String get shortcutPreviousPeriod => 'पिछली अवधि'; + + @override + String get shortcutPreviousPeriodDescription => + 'सप्ताह दृश्य में पिछला सप्ताह, महीने के दृश्य में पिछला महीना, इत्यादि'; + + @override + String get shortcutJumpToToday => 'आज की तारीख पर जाएँ'; + + @override + String get shortcutGroupView => 'दृश्य'; + + @override + String get shortcutDayView => 'दिन का दृश्य'; + + @override + String get shortcutWeekView => 'सप्ताह का दृश्य'; + + @override + String get shortcutMonthView => 'महीने का दृश्य'; + + @override + String get shortcutYearView => 'वर्ष का दृश्य'; + + @override + String get shortcutAgendaView => 'कार्यसूची दृश्य'; + + @override + String get shortcutGroupCreateAndEdit => 'बनाएँ और संपादित करें'; + + @override + String get shortcutSaveItem => 'ईवेंट या कार्य सहेजें'; + + @override + String get shortcutDeleteItem => 'ईवेंट या कार्य मिटाएँ'; + + @override + String get shortcutGroupTaskEditing => 'कार्य संपादन'; + + @override + String get shortcutCancelEditing => 'संपादन रद्द करें'; + + @override + String get shortcutCancelEditingDescription => + 'कार्य संपादन या कार्य विवरण बंद करें'; + + @override + String get aboutBusyMax => 'BusyMax के बारे में'; + + @override + String get aboutBusyMaxDescription => 'कैलेंडर और कार्य'; + + @override + String get license => 'लाइसेंस'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => 'वेबसाइट'; + + @override + String get sourceCode => 'स्रोत कोड'; + + @override + String get reportAnIssue => 'समस्या की रिपोर्ट करें'; + + @override + String get sendFeedback => 'प्रतिक्रिया भेजें'; + + @override + String get feedbackSubmit => 'सबमिट करें'; + + @override + String get feedbackCategory => 'श्रेणी'; + + @override + String get feedbackSelectCategory => 'श्रेणी चुनें'; + + @override + String get feedbackCategoryProblem => 'समस्या या बग'; + + @override + String get feedbackCategoryFeature => 'सुविधा का अनुरोध'; + + @override + String get feedbackCategoryPrivacySecurity => + 'गोपनीयता या सुरक्षा संबंधी चिंता'; + + @override + String get feedbackCategoryUsability => 'उपयोगिता संबंधी चिंता'; + + @override + String get feedbackCategoryOther => 'अन्य'; + + @override + String get feedbackSubject => 'विषय'; + + @override + String get feedbackDetailedMessage => 'विस्तृत संदेश'; + + @override + String get feedbackReplyEmail => 'जवाब के लिए ईमेल (वैकल्पिक)'; + + @override + String get feedbackIncludeTechnicalDetails => 'तकनीकी विवरण शामिल करें'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'केवल आपके Linux ऑपरेटिंग सिस्टम का संस्करण और ऐप की भाषा व क्षेत्रीय सेटिंग जोड़ी जाती है। लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान जानकारी शामिल नहीं की जाती।'; + + @override + String get feedbackCategoryRequired => 'श्रेणी चुनें।'; + + @override + String get feedbackSubjectLengthError => + 'विषय 3 से 120 वर्णों के बीच होना चाहिए।'; + + @override + String get feedbackMessageLengthError => + 'संदेश 10 से 5,000 वर्णों के बीच होना चाहिए।'; + + @override + String get feedbackInvalidEmail => 'मान्य ईमेल पता दर्ज करें।'; + + @override + String get feedbackConnectionError => + 'BusyStack से कनेक्ट नहीं हो सका। अपना कनेक्शन जाँचें और फिर कोशिश करें।'; + + @override + String get feedbackTimeoutError => + 'अनुरोध का समय समाप्त हो गया। आपकी प्रतिक्रिया हटाई नहीं गई है; फिर से कोशिश करें।'; + + @override + String get feedbackRateLimitedError => + 'इस नेटवर्क से बहुत अधिक प्रतिक्रियाएँ भेजी गई हैं। प्रतीक्षा करें और फिर कोशिश करें।'; + + @override + String get feedbackRejectedError => + 'सर्वर ने सबमिशन अस्वीकार कर दिया। फ़ील्ड की समीक्षा करें और फिर कोशिश करें।'; + + @override + String get feedbackServerError => + 'BusyStack अभी आपकी प्रतिक्रिया स्वीकार नहीं कर सका। आपकी प्रतिक्रिया हटाई नहीं गई है; फिर से कोशिश करें।'; + + @override + String feedbackSuccess(String id) { + return 'प्रतिक्रिया भेज दी गई। संदर्भ: $id'; + } + + @override + String get toggleSidebar => 'साइडबार दिखाएँ या छिपाएँ'; + + @override + String get showSidebar => 'साइडबार पैनल दिखाएँ'; + + @override + String get hideSidebar => 'साइडबार पैनल छिपाएँ'; + + @override + String get accounts => 'खाते'; + + @override + String get currentAccount => 'मौजूदा खाता'; + + @override + String get switchAccount => 'खाता बदलें'; + + @override + String get addGoogleAccount => 'Google खाता जोड़ें'; + + @override + String get addMicrosoftAccount => 'Microsoft खाता जोड़ें'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'साइन इन है'; + + @override + String get removeAccount => 'खाता हटाएँ…'; + + @override + String get removingAccount => 'खाता हटाया जा रहा है…'; + + @override + String get removeAccountDescription => + 'सिंक करना बंद करें और इस डिवाइस से इस खाते का डेटा हटाएँ।'; + + @override + String removeAccountTitle(String account) { + return 'BusyMax से $account हटाएँ?'; + } + + @override + String get removeAccountConfirmation => + 'इससे कैश किए गए कार्य, कैलेंडर, ईवेंट, रिमाइंडर और लंबित ऑफ़लाइन बदलाव इस डिवाइस से मिट जाएँगे। सिंक न किए गए बदलाव खो जाएँगे। Google या Microsoft से कुछ भी नहीं मिटेगा।'; + + @override + String get revokeGoogleAccess => + 'इस Google खाते से BusyMax की पहुँच भी रद्द करें'; + + @override + String get revokeGoogleAccessDescription => + 'दोबारा कनेक्ट करने से पहले आपको फिर से पहुँच देनी होगी।'; + + @override + String get removeAccountAction => 'खाता हटाएँ'; + + @override + String get removeAccountFailed => + 'खाता हटाना पूरा नहीं हो सका। फिर से कोशिश करें।'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'खाता इस डिवाइस से हटा दिया गया, लेकिन BusyMax की Google खाते तक पहुँच रद्द नहीं की जा सकी। आप यह पहुँच अपने Google खाते से रद्द कर सकते हैं।'; + + @override + String get newList => 'नई सूची'; + + @override + String get signInToViewTaskLists => + 'कार्य सूचियाँ देखने के लिए साइन इन करें।'; + + @override + String get noTaskListsSynced => 'अभी तक कोई कार्य सूची सिंक नहीं हुई है।'; + + @override + String get listActions => 'सूची की कार्रवाइयाँ'; + + @override + String get rename => 'नाम बदलें'; + + @override + String get delete => 'मिटाएँ'; + + @override + String get renameList => 'सूची का नाम बदलें'; + + @override + String get deleteList => 'सूची मिटाएँ'; + + @override + String get builtInMicrosoftList => 'अंतर्निहित'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Microsoft To Do की अंतर्निहित सूचियों का नाम बदला या उन्हें मिटाया नहीं जा सकता।'; + + @override + String deleteListConfirmation(String title) { + return 'Google Tasks से “$title” मिटाएँ?'; + } + + @override + String get deleteEvent => 'ईवेंट मिटाएँ'; + + @override + String get title => 'शीर्षक'; + + @override + String get create => 'बनाएँ'; + + @override + String get newTask => 'नया कार्य'; + + @override + String get clearCompleted => 'पूरे हुए कार्य हटाएँ'; + + @override + String get refreshList => 'सूची रीफ़्रेश करें'; + + @override + String get refreshAll => 'सभी रीफ़्रेश करें'; + + @override + String get listRefreshed => 'सूची रीफ़्रेश हो गई।'; + + @override + String get allTasksRefreshed => 'सभी खाते रीफ़्रेश हो गए।'; + + @override + String exportedFile(String path) { + return '$path में निर्यात किया गया'; + } + + @override + String exportFailed(String error) { + return 'निर्यात विफल: $error'; + } + + @override + String refreshFailed(String error) { + return 'रीफ़्रेश विफल: $error'; + } + + @override + String get selectOrCreateTaskList => + 'शुरू करने के लिए कार्य सूची चुनें या बनाएँ।'; + + @override + String get signInToViewTasks => 'कार्य देखने के लिए साइन इन करें।'; + + @override + String get noTasks => 'कोई कार्य नहीं।'; + + @override + String get noTasksYet => 'अभी तक कोई कार्य नहीं'; + + @override + String get noTasksYetMessage => + 'शुरू करने के लिए कार्य बनाएँ या अपने खाते रीफ़्रेश करें।'; + + @override + String get noTasksInList => 'इस सूची में कोई कार्य नहीं है।'; + + @override + String get overdue => 'समय सीमा बीत चुकी'; + + @override + String get today => 'आज'; + + @override + String get tomorrow => 'कल'; + + @override + String get upcoming => 'आगामी'; + + @override + String get noDate => 'कोई तारीख नहीं'; + + @override + String get completed => 'पूर्ण'; + + @override + String duePrefix(String date) { + return '$date को देय'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => 'कार्य का विवरण'; + + @override + String get editTask => 'कार्य संपादित करें'; + + @override + String get noTaskSelected => 'कोई कार्य नहीं चुना गया।'; + + @override + String get noTaskSelectedHelper => + 'विवरण देखने और संपादित करने के लिए कोई कार्य चुनें।'; + + @override + String get taskUnavailable => 'कार्य उपलब्ध नहीं है।'; + + @override + String get signInToEditTasks => 'कार्य संपादित करने के लिए साइन इन करें।'; + + @override + String get refreshTask => 'कार्य रीफ़्रेश करें'; + + @override + String get primarySection => 'मुख्य'; + + @override + String get statusSection => 'स्थिति'; + + @override + String get openStatus => 'खुला'; + + @override + String get doneStatus => 'पूर्ण'; + + @override + String get notes => 'नोट्स'; + + @override + String get dueDate => 'देय तारीख'; + + @override + String get clearDueDate => 'देय तारीख हटाएँ'; + + @override + String get dueTime => 'देय समय'; + + @override + String get startDate => 'शुरू होने की तारीख'; + + @override + String get startTime => 'शुरू होने का समय'; + + @override + String get endDate => 'समाप्ति तारीख'; + + @override + String get endTime => 'समाप्ति समय'; + + @override + String get reminderDate => 'रिमाइंडर की तारीख'; + + @override + String get reminderTime => 'रिमाइंडर का समय'; + + @override + String get reminder => 'रिमाइंडर'; + + @override + String get addReminder => 'रिमाइंडर जोड़ें'; + + @override + String get addGuest => 'अतिथि जोड़ें'; + + @override + String get addGuestEmail => 'अतिथि का ईमेल जोड़ें'; + + @override + String get removeReminder => 'रिमाइंडर हटाएँ'; + + @override + String get off => 'बंद'; + + @override + String get repeat => 'दोहराएँ'; + + @override + String get repeatNone => 'कभी नहीं'; + + @override + String get noneValue => 'कोई नहीं'; + + @override + String get repeatDaily => 'प्रतिदिन'; + + @override + String get repeatWeekly => 'हर सप्ताह'; + + @override + String get repeatMonthly => 'हर महीने'; + + @override + String get repeatYearly => 'हर वर्ष'; + + @override + String get importance => 'महत्त्व'; + + @override + String get importanceLow => 'कम'; + + @override + String get importanceNormal => 'सामान्य'; + + @override + String get importanceHigh => 'उच्च'; + + @override + String get categories => 'श्रेणियाँ'; + + @override + String get scheduleSection => 'शेड्यूल'; + + @override + String get dueGroup => 'देय'; + + @override + String get startGroup => 'शुरुआत'; + + @override + String get reminderGroup => 'रिमाइंडर'; + + @override + String get organizationSection => 'व्यवस्था'; + + @override + String get actionsSection => 'कार्रवाइयाँ'; + + @override + String get advancedSection => 'उन्नत'; + + @override + String get addCategory => 'श्रेणी जोड़ें'; + + @override + String get list => 'सूची'; + + @override + String get microsoftMoveUnsupported => + 'इस संस्करण में Microsoft To Do खातों के लिए सूचियों के बीच कार्य ले जाना समर्थित नहीं है।'; + + @override + String get createSubtask => 'उपकार्य बनाएँ'; + + @override + String get moveToTop => 'सबसे ऊपर ले जाएँ'; + + @override + String get deleteTask => 'कार्य मिटाएँ'; + + @override + String get newSubtask => 'नया उपकार्य'; + + @override + String deleteTaskConfirmation(String title) { + return 'Google Tasks से “$title” मिटाएँ?'; + } + + @override + String get metadata => 'मेटाडेटा'; + + @override + String get id => 'आईडी'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'अपडेट किया गया'; + + @override + String get parent => 'मूल कार्य'; + + @override + String get position => 'स्थान'; + + @override + String get webLink => 'वेब लिंक'; + + @override + String get assignment => 'असाइनमेंट'; + + @override + String get localState => 'स्थानीय स्थिति'; + + @override + String get pendingSync => 'सिंक लंबित'; + + @override + String get synced => 'सिंक किया गया'; + + @override + String get account => 'खाता'; + + @override + String get sync => 'सिंक'; + + @override + String get manualFullSync => 'मैन्युअल पूर्ण सिंक'; + + @override + String get runInBackgroundWhenClosed => 'विंडो बंद होने पर भी चलते रहें'; + + @override + String get showTrayIcon => 'ट्रे आइकन दिखाएँ'; + + @override + String get startMinimizedToTray => 'ट्रे में मिनिमाइज़ होकर शुरू करें'; + + @override + String get requiresTrayIcon => 'ट्रे आइकन आवश्यक है।'; + + @override + String get syncComplete => 'सिंक पूरा हुआ।'; + + @override + String syncFailed(String error) { + return 'सिंक विफल: $error'; + } + + @override + String get notifySyncFailures => 'सिंक विफल होने की सूचनाएँ'; + + @override + String get notifyConflicts => 'टकराव की सूचनाएँ'; + + @override + String get notifyDueToday => 'आज देय कार्यों की सूचनाएँ'; + + @override + String get eventReminders => 'ईवेंट रिमाइंडर'; + + @override + String get taskReminders => 'कार्य रिमाइंडर'; + + @override + String get notificationDetailLevel => 'सूचनाओं के विवरण का स्तर'; + + @override + String get notificationDetailPrivate => 'निजी'; + + @override + String get notificationDetailNormal => 'सामान्य'; + + @override + String get quietHours => 'शांत समय'; + + @override + String get quietHoursDescription => 'इस अवधि के दौरान सूचनाएँ रोकें।'; + + @override + String get quietHoursStart => 'शांत समय की शुरुआत'; + + @override + String get quietHoursEnd => 'शांत समय की समाप्ति'; + + @override + String get notifications => 'सूचनाएँ'; + + @override + String get appearance => 'दिखावट'; + + @override + String get theme => 'थीम'; + + @override + String get themeSystem => 'सिस्टम'; + + @override + String get themeLight => 'हल्की'; + + @override + String get themeDark => 'गहरी'; + + @override + String get themeFamily => 'थीम परिवार'; + + @override + String get themeFamilyYaru => 'Ubuntu की मूल थीम (Yaru)'; + + @override + String get localization => 'स्थानीयकरण'; + + @override + String get currentLocale => 'मौजूदा भाषा और क्षेत्रीय सेटिंग'; + + @override + String get privacy => 'गोपनीयता'; + + @override + String get redactTaskContentInDiagnostics => 'निदान में कार्य सामग्री छिपाएँ'; + + @override + String get developerDiagnostics => 'डेवलपर निदान'; + + @override + String get diagnostics => 'निदान'; + + @override + String get apiInspectorDisabled => 'API इंस्पेक्टर दिखाएँ'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'डिस्कवरी संशोधन: $revision'; + } + + @override + String get implementedMethods => 'लागू की गई विधियाँ'; + + @override + String get supportsTasksScopes => 'tasks और tasks.readonly स्कोप समर्थित हैं'; + + @override + String get requiresTasksScope => 'tasks स्कोप आवश्यक है'; + + @override + String get blockedPendingOperations => 'अवरुद्ध लंबित कार्रवाइयाँ'; + + @override + String get signInToInspectPendingOperations => + 'लंबित कार्रवाइयाँ देखने के लिए साइन इन करें।'; + + @override + String get noBlockedPendingOperations => + 'कोई अवरुद्ध लंबित कार्रवाई नहीं है।'; + + @override + String get operationActions => 'कार्रवाई के विकल्प'; + + @override + String pendingOpListId(String id) { + return 'सूची=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'कार्य=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'प्रयास=$count'; + } + + @override + String get retry => 'फिर से कोशिश करें'; + + @override + String get discard => 'खारिज करें'; + + @override + String get discardChangesAction => 'बदलाव छोड़ें'; + + @override + String get discardChanges => 'बदलाव खारिज करें?'; + + @override + String get discardChangesConfirmation => + 'इससे इस कार्य में किए गए सहेजे न गए बदलाव खारिज हो जाएँगे।'; + + @override + String get retryCompleted => 'दोबारा प्रयास पूरा हुआ।'; + + @override + String get discardPendingOperation => 'लंबित कार्रवाई खारिज करें?'; + + @override + String get discardPendingOperationConfirmation => + 'इससे अवरुद्ध स्थानीय कार्रवाई हट जाती है। अगला सिंक Google Tasks से डेटा रीफ़्रेश करेगा।'; + + @override + String get pendingOperationDiscarded => 'लंबित कार्रवाई खारिज कर दी गई।'; + + @override + String get syncFailureNotificationTitle => 'BusyMax सिंक विफल'; + + @override + String syncFailureNotificationBody(String message) { + return 'बैकग्राउंड सिंक विफल हुआ। $message'; + } + + @override + String get conflictNotificationTitle => 'BusyMax सिंक में टकराव'; + + @override + String conflictNotificationBody(String summary) { + return 'एक लंबित स्थानीय बदलाव अवरुद्ध हो गया। $summary'; + } + + @override + String get dueTodayNotificationTitle => 'आज देय कार्य'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'आज $count कार्य देय हैं।', + one: 'आज एक कार्य देय है।', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'ईवेंट रिमाइंडर'; + + @override + String get taskReminderNotificationTitle => 'कार्य रिमाइंडर'; + + @override + String get eventReminderNotificationBody => 'ईवेंट जल्द शुरू होगा।'; + + @override + String get taskReminderNotificationBody => 'कार्य जल्द देय है।'; + + @override + String get notificationOpenAction => 'खोलें'; + + @override + String get notificationDetailsHidden => + 'गोपनीयता सेटिंग्स के कारण विवरण छिपे हुए हैं।'; + + @override + String get previousMonth => 'पिछला महीना'; + + @override + String get nextMonth => 'अगला महीना'; + + @override + String get openMonthView => 'महीने का दृश्य खोलें'; + + @override + String get previousYear => 'पिछला वर्ष'; + + @override + String get nextYear => 'अगला वर्ष'; + + @override + String get openYearView => 'वर्ष का दृश्य खोलें'; + + @override + String weekNumberTooltip(int number) { + return 'सप्ताह $number'; + } + + @override + String get resizeAllDayPanel => 'पूरे दिन वाले पैनल का आकार बदलें'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count आइटम', + one: '1 आइटम', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'यह कैलेंडर केवल पढ़ने योग्य है।'; + + @override + String get selectTimeZone => 'समय क्षेत्र चुनें'; + + @override + String get searchLocations => 'स्थान खोजें'; + + @override + String get noLocationsFound => 'कोई स्थान नहीं मिला'; + + @override + String deleteCalendarConfirmation(String title) { + return '“$title” मिटाएँ?'; + } +} diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart new file mode 100644 index 0000000..426438b --- /dev/null +++ b/lib/l10n/generated/app_localizations_it.dart @@ -0,0 +1,1262 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Italian (`it`). +class AppLocalizationsIt extends AppLocalizations { + AppLocalizationsIt([String locale = 'it']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Collega gli account Google e Microsoft per sincronizzare calendari e attività.'; + + @override + String get googlePermissionsConsentNotice => + 'Nella schermata delle autorizzazioni di Google, seleziona sia l’autorizzazione per il calendario sia quella per le attività.'; + + @override + String get googlePermissionsRequiredRetry => + 'Sono necessarie le autorizzazioni per Google Calendar e Google Tasks. Riprova e seleziona entrambe le caselle.'; + + @override + String get finishSetup => 'Completa la configurazione'; + + @override + String get continueSetup => 'Continua'; + + @override + String get onboardingSetupTitle => 'Configura BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'Collega gli account'; + + @override + String get onboardingAccountsStepDescription => + 'Aggiungi tutti gli account Google e Microsoft che vuoi utilizzare. BusyMax sincronizza calendari, eventi, elenchi di attività e attività di ogni account.'; + + @override + String get onboardingPreferencesStepTitle => + 'Scegli le impostazioni di sistema'; + + @override + String get onboardingPreferencesStepDescription => + 'Prima di aprire l’agenda, configura il comportamento sul desktop, i promemoria, il livello di dettaglio delle notifiche e l’aspetto.'; + + @override + String get signInWithGoogle => 'Accedi con Google'; + + @override + String get signInWithMicrosoft => 'Accedi con Microsoft'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'Questo servizio non è configurato.'; + + @override + String get waitingForGoogleSignIn => 'In attesa dell’accesso a Google...'; + + @override + String get waitingForMicrosoftSignIn => + 'In attesa dell’accesso a Microsoft...'; + + @override + String get microsoftSignInNotConfigured => + 'L’accesso a Microsoft non è configurato. Imposta MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'Annulla'; + + @override + String get close => 'Chiudi'; + + @override + String get exit => 'Esci'; + + @override + String get options => 'Opzioni'; + + @override + String get hide => 'Nascondi'; + + @override + String get show => 'Mostra'; + + @override + String get export => 'Esporta'; + + @override + String get save => 'Salva'; + + @override + String get settings => 'Impostazioni'; + + @override + String get all => 'Tutto'; + + @override + String get calendarEvents => 'Eventi'; + + @override + String get calendarTasks => 'Attività'; + + @override + String get calendar => 'Calendario'; + + @override + String get calendars => 'Calendari'; + + @override + String get newEvent => 'Nuovo evento'; + + @override + String get refreshCalendar => 'Aggiorna calendario'; + + @override + String get openInProvider => 'Apri nel servizio'; + + @override + String get hideFromSchedule => 'Nascondi dall’agenda'; + + @override + String get showInSchedule => 'Mostra nell’agenda'; + + @override + String get noCalendarsSynced => 'Nessun calendario ancora sincronizzato.'; + + @override + String get allDay => 'Tutto il giorno'; + + @override + String moreItems(int count) { + return '+$count in più'; + } + + @override + String get noEventsOrTasks => 'Nessun evento o attività'; + + @override + String get scheduleLoading => 'Caricamento agenda...'; + + @override + String get scheduleUnavailable => 'Agenda non disponibile'; + + @override + String get scheduleNoSources => + 'Nessun calendario o elenco di attività visibile'; + + @override + String get scheduleNoSourcesDescription => + 'Scegli cosa mostrare nelle Impostazioni, quindi aggiorna l’agenda.'; + + @override + String get scheduleSignInRequired => 'Collega un account'; + + @override + String get scheduleSignInDescription => + 'Accedi per sincronizzare calendari e attività.'; + + @override + String get scheduleNoSearchResults => + 'Nessun evento o attività corrispondente'; + + @override + String get scheduleNoSearchResultsDescription => + 'Prova una ricerca diversa o cancella i filtri attuali.'; + + @override + String get refresh => 'Aggiorna'; + + @override + String get trayOpenBusyMax => 'Apri BusyMax'; + + @override + String get agendaLoadMoreOverdue => 'Carica altre attività scadute'; + + @override + String get agendaLoadMoreNoDate => 'Carica altre attività senza data'; + + @override + String get viewDay => 'Giorno'; + + @override + String get viewWeek => 'Settimana'; + + @override + String get viewMonth => 'Mese'; + + @override + String get viewYear => 'Anno'; + + @override + String get viewAgenda => 'Agenda'; + + @override + String get scheduleSettings => 'Agenda'; + + @override + String get scheduleDisplaySettings => 'Visualizzazione agenda'; + + @override + String get scheduleDisplayHoursDescription => + 'Le viste Giorno e Settimana mostrano inizialmente questo intervallo orario. Gli elementi precedenti o successivi lo estendono quando necessario.'; + + @override + String get scheduleDayStartsAt => 'Inizio giornata'; + + @override + String get scheduleDayEndsAt => 'Fine giornata'; + + @override + String get sourceCalendar => 'Calendario'; + + @override + String get sourceTaskList => 'Elenco di attività'; + + @override + String get createChoiceTitle => 'Crea'; + + @override + String get createEventAtTime => 'Evento'; + + @override + String get createTaskAtDate => 'Attività'; + + @override + String get editEvent => 'Modifica evento'; + + @override + String get eventTitle => 'Titolo dell’evento'; + + @override + String get location => 'Luogo'; + + @override + String get timeSlot => 'Fascia oraria'; + + @override + String get startDateTime => 'Data/ora di inizio'; + + @override + String get endDateTime => 'Data/ora di fine'; + + @override + String get doesNotRepeat => 'Non si ripete'; + + @override + String get defaultReminder => 'Promemoria predefinito'; + + @override + String get guests => 'Invitati'; + + @override + String get noGuests => 'Nessun invitato'; + + @override + String get description => 'Descrizione'; + + @override + String get availabilityShowAs => 'Disponibilità / Mostra come'; + + @override + String get busy => 'Occupato'; + + @override + String get visibility => 'Visibilità'; + + @override + String get defaultVisibility => 'Visibilità predefinita'; + + @override + String get conference => 'Conferenza'; + + @override + String get noConference => 'Nessuna conferenza'; + + @override + String get providerCalendar => 'Calendario del servizio'; + + @override + String get formatBoldShortLabel => 'G'; + + @override + String get formatBoldTooltip => 'Grassetto'; + + @override + String get formatItalicShortLabel => 'C'; + + @override + String get formatItalicTooltip => 'Corsivo'; + + @override + String get formatUnderlineShortLabel => 'S'; + + @override + String get formatUnderlineTooltip => 'Sottolineato'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minuti prima', + one: '1 minuto prima', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'All’inizio'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours ore prima', + one: '1 ora prima', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days giorni prima', + one: '1 giorno prima', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Libero'; + + @override + String get availabilityTentative => 'Provvisorio'; + + @override + String get availabilityOutOfOffice => 'Fuori sede'; + + @override + String get availabilityWorkingElsewhere => 'Lavora altrove'; + + @override + String get visibilityDefault => 'Predefinita'; + + @override + String get visibilityPublic => 'Pubblica'; + + @override + String get visibilityPrivate => 'Privata'; + + @override + String get visibilityConfidential => 'Riservata'; + + @override + String get sensitivityNormal => 'Normale'; + + @override + String get sensitivityPersonal => 'Personale'; + + @override + String get tasks => 'Attività'; + + @override + String get allTasks => 'Tutte le attività'; + + @override + String tasksInList(String title) { + return 'Attività in $title'; + } + + @override + String get taskLists => 'Elenchi di attività'; + + @override + String get navigation => 'Navigazione'; + + @override + String get mainMenu => 'Menu principale'; + + @override + String get keyboardShortcuts => 'Scorciatoie da tastiera'; + + @override + String get shortcutGroupGeneral => 'Generali'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'Mostra questo elenco di scorciatoie'; + + @override + String get shortcutGroupNavigation => 'Navigazione'; + + @override + String get shortcutNextPeriod => 'Periodo successivo'; + + @override + String get shortcutNextPeriodDescription => + 'Settimana successiva nella vista settimanale, mese successivo nella vista mensile e così via'; + + @override + String get shortcutPreviousPeriod => 'Periodo precedente'; + + @override + String get shortcutPreviousPeriodDescription => + 'Settimana precedente nella vista settimanale, mese precedente nella vista mensile e così via'; + + @override + String get shortcutJumpToToday => 'Vai alla data odierna'; + + @override + String get shortcutGroupView => 'Vista'; + + @override + String get shortcutDayView => 'Vista giornaliera'; + + @override + String get shortcutWeekView => 'Vista settimanale'; + + @override + String get shortcutMonthView => 'Vista mensile'; + + @override + String get shortcutYearView => 'Vista annuale'; + + @override + String get shortcutAgendaView => 'Vista agenda'; + + @override + String get shortcutGroupCreateAndEdit => 'Creazione e modifica'; + + @override + String get shortcutSaveItem => 'Salva evento o attività'; + + @override + String get shortcutDeleteItem => 'Elimina evento o attività'; + + @override + String get shortcutGroupTaskEditing => 'Modifica delle attività'; + + @override + String get shortcutCancelEditing => 'Annulla modifica'; + + @override + String get shortcutCancelEditingDescription => + 'Chiudi la modifica o i dettagli dell’attività'; + + @override + String get aboutBusyMax => 'Informazioni su BusyMax'; + + @override + String get aboutBusyMaxDescription => 'Calendario e attività'; + + @override + String get license => 'Licenza'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => 'Sito web'; + + @override + String get sourceCode => 'Codice sorgente'; + + @override + String get reportAnIssue => 'Segnala un problema'; + + @override + String get sendFeedback => 'Invia feedback'; + + @override + String get feedbackSubmit => 'Invia'; + + @override + String get feedbackCategory => 'Categoria'; + + @override + String get feedbackSelectCategory => 'Seleziona una categoria'; + + @override + String get feedbackCategoryProblem => 'Problema o errore'; + + @override + String get feedbackCategoryFeature => 'Richiesta di funzionalità'; + + @override + String get feedbackCategoryPrivacySecurity => + 'Problema di privacy o sicurezza'; + + @override + String get feedbackCategoryUsability => 'Problema di usabilità'; + + @override + String get feedbackCategoryOther => 'Altro'; + + @override + String get feedbackSubject => 'Oggetto'; + + @override + String get feedbackDetailedMessage => 'Messaggio dettagliato'; + + @override + String get feedbackReplyEmail => + 'Indirizzo email per la risposta (facoltativo)'; + + @override + String get feedbackIncludeTechnicalDetails => 'Includi dettagli tecnici'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Aggiunge soltanto la versione del sistema operativo Linux e le impostazioni locali dell’applicazione. Non vengono inclusi log, dati degli account, nomi di file o altre informazioni diagnostiche.'; + + @override + String get feedbackCategoryRequired => 'Seleziona una categoria.'; + + @override + String get feedbackSubjectLengthError => + 'L’oggetto deve contenere da 3 a 120 caratteri.'; + + @override + String get feedbackMessageLengthError => + 'Il messaggio deve contenere da 10 a 5.000 caratteri.'; + + @override + String get feedbackInvalidEmail => 'Inserisci un indirizzo email valido.'; + + @override + String get feedbackConnectionError => + 'Impossibile connettersi a BusyStack. Controlla la connessione e riprova.'; + + @override + String get feedbackTimeoutError => + 'La richiesta ha superato il tempo limite. Il feedback non è stato cancellato; riprova.'; + + @override + String get feedbackRateLimitedError => + 'Sono stati inviati troppi feedback da questa rete. Attendi e riprova.'; + + @override + String get feedbackRejectedError => + 'Il server ha rifiutato l’invio. Controlla i campi e riprova.'; + + @override + String get feedbackServerError => + 'BusyStack non può accettare il feedback in questo momento. Il feedback non è stato cancellato; riprova.'; + + @override + String feedbackSuccess(String id) { + return 'Feedback inviato. Riferimento: $id'; + } + + @override + String get toggleSidebar => 'Mostra o nascondi la barra laterale'; + + @override + String get showSidebar => 'Mostra il pannello laterale'; + + @override + String get hideSidebar => 'Nascondi il pannello laterale'; + + @override + String get accounts => 'Account'; + + @override + String get currentAccount => 'Account attuale'; + + @override + String get switchAccount => 'Cambia account'; + + @override + String get addGoogleAccount => 'Aggiungi account Google'; + + @override + String get addMicrosoftAccount => 'Aggiungi account Microsoft'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'Accesso effettuato'; + + @override + String get removeAccount => 'Rimuovi account…'; + + @override + String get removingAccount => 'Rimozione account…'; + + @override + String get removeAccountDescription => + 'Interrompi la sincronizzazione e rimuovi i dati di questo account dal dispositivo.'; + + @override + String removeAccountTitle(String account) { + return 'Rimuovere $account da BusyMax?'; + } + + @override + String get removeAccountConfirmation => + 'Questa azione elimina dal dispositivo attività, calendari, eventi e promemoria memorizzati nella cache, oltre alle modifiche offline in sospeso. Le modifiche non sincronizzate andranno perse. Non verrà eliminato nulla da Google o Microsoft.'; + + @override + String get revokeGoogleAccess => + 'Revoca anche l’accesso di BusyMax a questo account Google'; + + @override + String get revokeGoogleAccessDescription => + 'Dovrai concedere nuovamente l’accesso prima di riconnetterti.'; + + @override + String get removeAccountAction => 'Rimuovi account'; + + @override + String get removeAccountFailed => + 'Impossibile completare la rimozione dell’account. Riprova.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'L’account è stato rimosso da questo dispositivo, ma BusyMax non è riuscito a revocare il proprio accesso a Google. Puoi revocarlo dal tuo account Google.'; + + @override + String get newList => 'Nuovo elenco'; + + @override + String get signInToViewTaskLists => + 'Accedi per visualizzare gli elenchi di attività.'; + + @override + String get noTaskListsSynced => + 'Nessun elenco di attività ancora sincronizzato.'; + + @override + String get listActions => 'Azioni dell’elenco'; + + @override + String get rename => 'Rinomina'; + + @override + String get delete => 'Elimina'; + + @override + String get renameList => 'Rinomina elenco'; + + @override + String get deleteList => 'Elimina elenco'; + + @override + String get builtInMicrosoftList => 'Integrato'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Gli elenchi integrati di Microsoft To Do non possono essere rinominati o eliminati.'; + + @override + String deleteListConfirmation(String title) { + return 'Eliminare «$title» da Google Tasks?'; + } + + @override + String get deleteEvent => 'Elimina evento'; + + @override + String get title => 'Titolo'; + + @override + String get create => 'Crea'; + + @override + String get newTask => 'Nuova attività'; + + @override + String get clearCompleted => 'Cancella attività completate'; + + @override + String get refreshList => 'Aggiorna elenco'; + + @override + String get refreshAll => 'Aggiorna tutto'; + + @override + String get listRefreshed => 'Elenco aggiornato.'; + + @override + String get allTasksRefreshed => 'Tutti gli account sono stati aggiornati.'; + + @override + String exportedFile(String path) { + return 'Esportato in $path'; + } + + @override + String exportFailed(String error) { + return 'Esportazione non riuscita: $error'; + } + + @override + String refreshFailed(String error) { + return 'Aggiornamento non riuscito: $error'; + } + + @override + String get selectOrCreateTaskList => + 'Seleziona o crea un elenco di attività per iniziare.'; + + @override + String get signInToViewTasks => 'Accedi per visualizzare le attività.'; + + @override + String get noTasks => 'Nessuna attività.'; + + @override + String get noTasksYet => 'Ancora nessuna attività'; + + @override + String get noTasksYetMessage => + 'Crea un’attività o aggiorna gli account per iniziare.'; + + @override + String get noTasksInList => 'Nessuna attività in questo elenco.'; + + @override + String get overdue => 'Scadute'; + + @override + String get today => 'Oggi'; + + @override + String get tomorrow => 'Domani'; + + @override + String get upcoming => 'In arrivo'; + + @override + String get noDate => 'Senza data'; + + @override + String get completed => 'Completate'; + + @override + String duePrefix(String date) { + return 'Scadenza: $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => 'Dettagli attività'; + + @override + String get editTask => 'Modifica attività'; + + @override + String get noTaskSelected => 'Nessuna attività selezionata.'; + + @override + String get noTaskSelectedHelper => + 'Seleziona un’attività per visualizzarne e modificarne i dettagli.'; + + @override + String get taskUnavailable => 'Attività non disponibile.'; + + @override + String get signInToEditTasks => 'Accedi per modificare le attività.'; + + @override + String get refreshTask => 'Aggiorna attività'; + + @override + String get primarySection => 'Principale'; + + @override + String get statusSection => 'Stato'; + + @override + String get openStatus => 'Aperta'; + + @override + String get doneStatus => 'Completata'; + + @override + String get notes => 'Note'; + + @override + String get dueDate => 'Data di scadenza'; + + @override + String get clearDueDate => 'Cancella data di scadenza'; + + @override + String get dueTime => 'Ora di scadenza'; + + @override + String get startDate => 'Data di inizio'; + + @override + String get startTime => 'Ora di inizio'; + + @override + String get endDate => 'Data di fine'; + + @override + String get endTime => 'Ora di fine'; + + @override + String get reminderDate => 'Data del promemoria'; + + @override + String get reminderTime => 'Ora del promemoria'; + + @override + String get reminder => 'Promemoria'; + + @override + String get addReminder => 'Aggiungi promemoria'; + + @override + String get addGuest => 'Aggiungi invitato'; + + @override + String get addGuestEmail => 'Aggiungi email dell’invitato'; + + @override + String get removeReminder => 'Rimuovi promemoria'; + + @override + String get off => 'Disattivato'; + + @override + String get repeat => 'Ripeti'; + + @override + String get repeatNone => 'Nessuna ripetizione'; + + @override + String get noneValue => 'Nessuno'; + + @override + String get repeatDaily => 'Ogni giorno'; + + @override + String get repeatWeekly => 'Ogni settimana'; + + @override + String get repeatMonthly => 'Ogni mese'; + + @override + String get repeatYearly => 'Ogni anno'; + + @override + String get importance => 'Importanza'; + + @override + String get importanceLow => 'Bassa'; + + @override + String get importanceNormal => 'Normale'; + + @override + String get importanceHigh => 'Alta'; + + @override + String get categories => 'Categorie'; + + @override + String get scheduleSection => 'Programmazione'; + + @override + String get dueGroup => 'Scadenza'; + + @override + String get startGroup => 'Inizio'; + + @override + String get reminderGroup => 'Promemoria'; + + @override + String get organizationSection => 'Organizzazione'; + + @override + String get actionsSection => 'Azioni'; + + @override + String get advancedSection => 'Avanzate'; + + @override + String get addCategory => 'Aggiungi categoria'; + + @override + String get list => 'Elenco'; + + @override + String get microsoftMoveUnsupported => + 'In questa versione non è possibile spostare attività tra elenchi negli account Microsoft To Do.'; + + @override + String get createSubtask => 'Crea sottoattività'; + + @override + String get moveToTop => 'Sposta in cima'; + + @override + String get deleteTask => 'Elimina attività'; + + @override + String get newSubtask => 'Nuova sottoattività'; + + @override + String deleteTaskConfirmation(String title) { + return 'Eliminare «$title» da Google Tasks?'; + } + + @override + String get metadata => 'Metadati'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'Aggiornato'; + + @override + String get parent => 'Attività principale'; + + @override + String get position => 'Posizione'; + + @override + String get webLink => 'Collegamento web'; + + @override + String get assignment => 'Assegnazione'; + + @override + String get localState => 'Stato locale'; + + @override + String get pendingSync => 'Sincronizzazione in sospeso'; + + @override + String get synced => 'Sincronizzato'; + + @override + String get account => 'Account'; + + @override + String get sync => 'Sincronizzazione'; + + @override + String get manualFullSync => 'Sincronizzazione completa manuale'; + + @override + String get runInBackgroundWhenClosed => + 'Continua a funzionare quando la finestra è chiusa'; + + @override + String get showTrayIcon => 'Mostra icona nell’area di notifica'; + + @override + String get startMinimizedToTray => + 'Avvia ridotto a icona nell’area di notifica'; + + @override + String get requiresTrayIcon => 'Richiede l’icona nell’area di notifica.'; + + @override + String get syncComplete => 'Sincronizzazione completata.'; + + @override + String syncFailed(String error) { + return 'Sincronizzazione non riuscita: $error'; + } + + @override + String get notifySyncFailures => + 'Notifiche in caso di errore di sincronizzazione'; + + @override + String get notifyConflicts => 'Notifiche in caso di conflitto'; + + @override + String get notifyDueToday => 'Notifiche per attività in scadenza oggi'; + + @override + String get eventReminders => 'Promemoria degli eventi'; + + @override + String get taskReminders => 'Promemoria delle attività'; + + @override + String get notificationDetailLevel => 'Livello di dettaglio delle notifiche'; + + @override + String get notificationDetailPrivate => 'Privato'; + + @override + String get notificationDetailNormal => 'Normale'; + + @override + String get quietHours => 'Ore di silenzio'; + + @override + String get quietHoursDescription => + 'Sospendi le notifiche durante questo periodo.'; + + @override + String get quietHoursStart => 'Inizio delle ore di silenzio'; + + @override + String get quietHoursEnd => 'Fine delle ore di silenzio'; + + @override + String get notifications => 'Notifiche'; + + @override + String get appearance => 'Aspetto'; + + @override + String get theme => 'Tema'; + + @override + String get themeSystem => 'Sistema'; + + @override + String get themeLight => 'Chiaro'; + + @override + String get themeDark => 'Scuro'; + + @override + String get themeFamily => 'Famiglia di temi'; + + @override + String get themeFamilyYaru => 'Tema nativo di Ubuntu (Yaru)'; + + @override + String get localization => 'Lingua e area geografica'; + + @override + String get currentLocale => 'Impostazioni locali correnti'; + + @override + String get privacy => 'Privacy'; + + @override + String get redactTaskContentInDiagnostics => + 'Nascondi il contenuto delle attività nelle informazioni diagnostiche'; + + @override + String get developerDiagnostics => 'Diagnostica per sviluppatori'; + + @override + String get diagnostics => 'Diagnostica'; + + @override + String get apiInspectorDisabled => 'Mostra l’ispettore API'; + + @override + String get googleTasksApi => 'API Google Tasks'; + + @override + String discoveryRevision(String revision) { + return 'Revisione Discovery: $revision'; + } + + @override + String get implementedMethods => 'Metodi implementati'; + + @override + String get supportsTasksScopes => + 'Supporta gli ambiti tasks e tasks.readonly'; + + @override + String get requiresTasksScope => 'Richiede l’ambito tasks'; + + @override + String get blockedPendingOperations => 'Operazioni in sospeso bloccate'; + + @override + String get signInToInspectPendingOperations => + 'Accedi per esaminare le operazioni in sospeso.'; + + @override + String get noBlockedPendingOperations => + 'Nessuna operazione in sospeso bloccata.'; + + @override + String get operationActions => 'Azioni dell’operazione'; + + @override + String pendingOpListId(String id) { + return 'elenco=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'attività=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'tentativi=$count'; + } + + @override + String get retry => 'Riprova'; + + @override + String get discard => 'Scarta'; + + @override + String get discardChangesAction => 'Scarta'; + + @override + String get discardChanges => 'Scartare le modifiche?'; + + @override + String get discardChangesConfirmation => + 'Questa azione scarta le modifiche non salvate dell’attività.'; + + @override + String get retryCompleted => 'Nuovo tentativo completato.'; + + @override + String get discardPendingOperation => 'Scartare l’operazione in sospeso?'; + + @override + String get discardPendingOperationConfirmation => + 'Questa azione rimuove l’operazione locale bloccata. Alla prossima sincronizzazione, i dati verranno ricaricati da Google Tasks.'; + + @override + String get pendingOperationDiscarded => 'Operazione in sospeso scartata.'; + + @override + String get syncFailureNotificationTitle => + 'Sincronizzazione di BusyMax non riuscita'; + + @override + String syncFailureNotificationBody(String message) { + return 'Sincronizzazione in background non riuscita. $message'; + } + + @override + String get conflictNotificationTitle => + 'Conflitto di sincronizzazione di BusyMax'; + + @override + String conflictNotificationBody(String summary) { + return 'Una modifica locale in sospeso è stata bloccata. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'Attività in scadenza oggi'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count attività scadono oggi.', + one: 'Un’attività scade oggi.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'Promemoria evento'; + + @override + String get taskReminderNotificationTitle => 'Promemoria attività'; + + @override + String get eventReminderNotificationBody => 'L’evento inizierà a breve.'; + + @override + String get taskReminderNotificationBody => 'L’attività scadrà a breve.'; + + @override + String get notificationOpenAction => 'Apri'; + + @override + String get notificationDetailsHidden => + 'I dettagli sono nascosti dalle impostazioni sulla privacy.'; + + @override + String get previousMonth => 'Mese precedente'; + + @override + String get nextMonth => 'Mese successivo'; + + @override + String get openMonthView => 'Apri vista mensile'; + + @override + String get previousYear => 'Anno precedente'; + + @override + String get nextYear => 'Anno successivo'; + + @override + String get openYearView => 'Apri vista annuale'; + + @override + String weekNumberTooltip(int number) { + return 'Settimana $number'; + } + + @override + String get resizeAllDayPanel => + 'Ridimensiona il pannello per l’intera giornata'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count elementi', + one: '1 elemento', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Questo calendario è di sola lettura.'; + + @override + String get selectTimeZone => 'Seleziona fuso orario'; + + @override + String get searchLocations => 'Cerca luoghi'; + + @override + String get noLocationsFound => 'Nessun luogo trovato'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Eliminare «$title»?'; + } +} diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart new file mode 100644 index 0000000..1da583e --- /dev/null +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -0,0 +1,1226 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Japanese (`ja`). +class AppLocalizationsJa extends AppLocalizations { + AppLocalizationsJa([String locale = 'ja']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Google と Microsoft のアカウントを接続して、カレンダーとタスクを同期します。'; + + @override + String get googlePermissionsConsentNotice => + 'Google の権限画面で、カレンダーとタスクの両方の権限を選択してください。'; + + @override + String get googlePermissionsRequiredRetry => + 'Google カレンダーと Google Tasks の権限が必要です。もう一度試して、両方のチェックボックスを選択してください。'; + + @override + String get finishSetup => 'セットアップを完了'; + + @override + String get continueSetup => '続行'; + + @override + String get onboardingSetupTitle => 'BusyMax をセットアップ'; + + @override + String get onboardingAccountsStepTitle => 'アカウントを接続'; + + @override + String get onboardingAccountsStepDescription => + '使用するすべての Google アカウントと Microsoft アカウントを追加してください。BusyMax は各アカウントのカレンダー、予定、タスクリスト、タスクを同期します。'; + + @override + String get onboardingPreferencesStepTitle => 'システム設定を選択'; + + @override + String get onboardingPreferencesStepDescription => + 'スケジュールを開く前に、デスクトップでの動作、リマインダー、通知の詳細度、外観を設定します。'; + + @override + String get signInWithGoogle => 'Google でサインイン'; + + @override + String get signInWithMicrosoft => 'Microsoft でサインイン'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'このプロバイダーは設定されていません。'; + + @override + String get waitingForGoogleSignIn => 'Google のサインインを待機しています...'; + + @override + String get waitingForMicrosoftSignIn => 'Microsoft のサインインを待機しています...'; + + @override + String get microsoftSignInNotConfigured => + 'Microsoft のサインインが設定されていません。MICROSOFT_OAUTH_CLIENT_ID を設定してください。'; + + @override + String get cancel => 'キャンセル'; + + @override + String get close => '閉じる'; + + @override + String get exit => '終了'; + + @override + String get options => 'オプション'; + + @override + String get hide => '非表示'; + + @override + String get show => '表示'; + + @override + String get export => 'エクスポート'; + + @override + String get save => '保存'; + + @override + String get settings => '設定'; + + @override + String get all => 'すべて'; + + @override + String get calendarEvents => '予定'; + + @override + String get calendarTasks => 'タスク'; + + @override + String get calendar => 'カレンダー'; + + @override + String get calendars => 'カレンダー'; + + @override + String get newEvent => '新しい予定'; + + @override + String get refreshCalendar => 'カレンダーを更新'; + + @override + String get openInProvider => 'サービスで開く'; + + @override + String get hideFromSchedule => 'スケジュールから非表示'; + + @override + String get showInSchedule => 'スケジュールに表示'; + + @override + String get noCalendarsSynced => '同期済みのカレンダーはまだありません。'; + + @override + String get allDay => '終日'; + + @override + String moreItems(int count) { + return '他 $count 件'; + } + + @override + String get noEventsOrTasks => '予定またはタスクはありません'; + + @override + String get scheduleLoading => 'スケジュールを読み込んでいます...'; + + @override + String get scheduleUnavailable => 'スケジュールを利用できません'; + + @override + String get scheduleNoSources => '表示できるカレンダーまたはタスクリストがありません'; + + @override + String get scheduleNoSourcesDescription => '設定で表示する項目を選択してから、更新してください。'; + + @override + String get scheduleSignInRequired => 'アカウントを接続'; + + @override + String get scheduleSignInDescription => 'カレンダーとタスクを同期するにはサインインしてください。'; + + @override + String get scheduleNoSearchResults => '一致する予定またはタスクはありません'; + + @override + String get scheduleNoSearchResultsDescription => + '別の条件で検索するか、現在のフィルターを解除してください。'; + + @override + String get refresh => '更新'; + + @override + String get trayOpenBusyMax => 'BusyMax を開く'; + + @override + String get agendaLoadMoreOverdue => '期限切れのタスクをさらに読み込む'; + + @override + String get agendaLoadMoreNoDate => '日付のないタスクをさらに読み込む'; + + @override + String get viewDay => '日'; + + @override + String get viewWeek => '週'; + + @override + String get viewMonth => '月'; + + @override + String get viewYear => '年'; + + @override + String get viewAgenda => '予定一覧'; + + @override + String get scheduleSettings => 'スケジュール'; + + @override + String get scheduleDisplaySettings => 'スケジュール表示'; + + @override + String get scheduleDisplayHoursDescription => + '日表示と週表示では、最初にこの時間範囲が表示されます。必要に応じて、この範囲より前または後の項目も表示されるように範囲が広がります。'; + + @override + String get scheduleDayStartsAt => '一日の開始時刻'; + + @override + String get scheduleDayEndsAt => '一日の終了時刻'; + + @override + String get sourceCalendar => 'カレンダー'; + + @override + String get sourceTaskList => 'タスクリスト'; + + @override + String get createChoiceTitle => '作成'; + + @override + String get createEventAtTime => '予定'; + + @override + String get createTaskAtDate => 'タスク'; + + @override + String get editEvent => '予定を編集'; + + @override + String get eventTitle => '予定のタイトル'; + + @override + String get location => '場所'; + + @override + String get timeSlot => '時間帯'; + + @override + String get startDateTime => '開始日時'; + + @override + String get endDateTime => '終了日時'; + + @override + String get doesNotRepeat => '繰り返さない'; + + @override + String get defaultReminder => 'デフォルトのリマインダー'; + + @override + String get guests => 'ゲスト'; + + @override + String get noGuests => 'ゲストなし'; + + @override + String get description => '説明'; + + @override + String get availabilityShowAs => '空き時間情報 / 表示方法'; + + @override + String get busy => '予定あり'; + + @override + String get visibility => '公開設定'; + + @override + String get defaultVisibility => 'デフォルトの公開設定'; + + @override + String get conference => '会議'; + + @override + String get noConference => '会議なし'; + + @override + String get providerCalendar => 'プロバイダーのカレンダー'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => '太字'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => '斜体'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => '下線'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes分前', + one: '1分前', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => '開始時'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours時間前', + one: '1時間前', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days日前', + one: '1日前', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => '空き時間'; + + @override + String get availabilityTentative => '仮の予定'; + + @override + String get availabilityOutOfOffice => '外出中'; + + @override + String get availabilityWorkingElsewhere => '別の場所で勤務'; + + @override + String get visibilityDefault => 'デフォルト'; + + @override + String get visibilityPublic => '一般公開'; + + @override + String get visibilityPrivate => '非公開'; + + @override + String get visibilityConfidential => '機密'; + + @override + String get sensitivityNormal => '標準'; + + @override + String get sensitivityPersonal => '個人用'; + + @override + String get tasks => 'タスク'; + + @override + String get allTasks => 'すべてのタスク'; + + @override + String tasksInList(String title) { + return '$title のタスク'; + } + + @override + String get taskLists => 'タスクリスト'; + + @override + String get navigation => 'ナビゲーション'; + + @override + String get mainMenu => 'メインメニュー'; + + @override + String get keyboardShortcuts => 'キーボードショートカット'; + + @override + String get shortcutGroupGeneral => '全般'; + + @override + String get shortcutKeyboardShortcutsDescription => 'このショートカット一覧を表示'; + + @override + String get shortcutGroupNavigation => 'ナビゲーション'; + + @override + String get shortcutNextPeriod => '次の期間'; + + @override + String get shortcutNextPeriodDescription => '週表示では次の週、月表示では次の月というように移動します'; + + @override + String get shortcutPreviousPeriod => '前の期間'; + + @override + String get shortcutPreviousPeriodDescription => + '週表示では前の週、月表示では前の月というように移動します'; + + @override + String get shortcutJumpToToday => '今日に移動'; + + @override + String get shortcutGroupView => '表示'; + + @override + String get shortcutDayView => '日表示'; + + @override + String get shortcutWeekView => '週表示'; + + @override + String get shortcutMonthView => '月表示'; + + @override + String get shortcutYearView => '年表示'; + + @override + String get shortcutAgendaView => '予定一覧表示'; + + @override + String get shortcutGroupCreateAndEdit => '作成と編集'; + + @override + String get shortcutSaveItem => '予定またはタスクを保存'; + + @override + String get shortcutDeleteItem => '予定またはタスクを削除'; + + @override + String get shortcutGroupTaskEditing => 'タスクの編集'; + + @override + String get shortcutCancelEditing => '編集をキャンセル'; + + @override + String get shortcutCancelEditingDescription => 'タスクの編集または詳細を閉じる'; + + @override + String get aboutBusyMax => 'BusyMax について'; + + @override + String get aboutBusyMaxDescription => 'カレンダーとタスク'; + + @override + String get license => 'ライセンス'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => 'ウェブサイト'; + + @override + String get sourceCode => 'ソースコード'; + + @override + String get reportAnIssue => '問題を報告'; + + @override + String get sendFeedback => 'フィードバックを送信'; + + @override + String get feedbackSubmit => '送信'; + + @override + String get feedbackCategory => 'カテゴリー'; + + @override + String get feedbackSelectCategory => 'カテゴリーを選択'; + + @override + String get feedbackCategoryProblem => '問題またはバグ'; + + @override + String get feedbackCategoryFeature => '機能のリクエスト'; + + @override + String get feedbackCategoryPrivacySecurity => 'プライバシーまたはセキュリティに関する懸念'; + + @override + String get feedbackCategoryUsability => '使いやすさに関する懸念'; + + @override + String get feedbackCategoryOther => 'その他'; + + @override + String get feedbackSubject => '件名'; + + @override + String get feedbackDetailedMessage => '詳しい内容'; + + @override + String get feedbackReplyEmail => '返信先メールアドレス(任意)'; + + @override + String get feedbackIncludeTechnicalDetails => '技術情報を含める'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Linux オペレーティングシステムのバージョンとアプリのロケールのみが追加されます。ログ、アカウントデータ、ファイル名、その他の診断情報は含まれません。'; + + @override + String get feedbackCategoryRequired => 'カテゴリーを選択してください。'; + + @override + String get feedbackSubjectLengthError => '件名は3文字以上120文字以下にしてください。'; + + @override + String get feedbackMessageLengthError => 'メッセージは10文字以上5,000文字以下にしてください。'; + + @override + String get feedbackInvalidEmail => '有効なメールアドレスを入力してください。'; + + @override + String get feedbackConnectionError => + 'BusyStack に接続できませんでした。接続を確認して、もう一度お試しください。'; + + @override + String get feedbackTimeoutError => + 'リクエストがタイムアウトしました。フィードバックは消去されていません。もう一度お試しください。'; + + @override + String get feedbackRateLimitedError => + 'このネットワークから送信されたフィードバックが多すぎます。しばらく待ってから、もう一度お試しください。'; + + @override + String get feedbackRejectedError => 'サーバーが送信を拒否しました。入力内容を確認して、もう一度お試しください。'; + + @override + String get feedbackServerError => + '現在、BusyStack はフィードバックを受け付けられません。フィードバックは消去されていません。もう一度お試しください。'; + + @override + String feedbackSuccess(String id) { + return 'フィードバックを送信しました。参照番号: $id'; + } + + @override + String get toggleSidebar => 'サイドバーの表示を切り替え'; + + @override + String get showSidebar => 'サイドバーパネルを表示'; + + @override + String get hideSidebar => 'サイドバーパネルを非表示'; + + @override + String get accounts => 'アカウント'; + + @override + String get currentAccount => '現在のアカウント'; + + @override + String get switchAccount => 'アカウントを切り替え'; + + @override + String get addGoogleAccount => 'Google アカウントを追加'; + + @override + String get addMicrosoftAccount => 'Microsoft アカウントを追加'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'サインイン済み'; + + @override + String get removeAccount => 'アカウントを削除…'; + + @override + String get removingAccount => 'アカウントを削除しています…'; + + @override + String get removeAccountDescription => '同期を停止し、このアカウントのデータをこのデバイスから削除します。'; + + @override + String removeAccountTitle(String account) { + return 'BusyMax から $account を削除しますか?'; + } + + @override + String get removeAccountConfirmation => + 'このデバイスにキャッシュされたタスク、カレンダー、予定、リマインダー、保留中のオフライン変更が削除されます。同期されていない変更は失われます。Google または Microsoft から削除されるデータはありません。'; + + @override + String get revokeGoogleAccess => 'この Google アカウントへの BusyMax のアクセス権も取り消す'; + + @override + String get revokeGoogleAccessDescription => '再接続する前に、もう一度アクセスを許可する必要があります。'; + + @override + String get removeAccountAction => 'アカウントを削除'; + + @override + String get removeAccountFailed => 'アカウントの削除を完了できませんでした。もう一度お試しください。'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'アカウントはこのデバイスから削除されましたが、BusyMax は Google アカウントへのアクセス権を取り消せませんでした。Google アカウントの設定から取り消すことができます。'; + + @override + String get newList => '新しいリスト'; + + @override + String get signInToViewTaskLists => 'タスクリストを表示するにはサインインしてください。'; + + @override + String get noTaskListsSynced => '同期済みのタスクリストはまだありません。'; + + @override + String get listActions => 'リストの操作'; + + @override + String get rename => '名前を変更'; + + @override + String get delete => '削除'; + + @override + String get renameList => 'リスト名を変更'; + + @override + String get deleteList => 'リストを削除'; + + @override + String get builtInMicrosoftList => '組み込み'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Microsoft To Do の組み込みリストは、名前の変更や削除ができません。'; + + @override + String deleteListConfirmation(String title) { + return 'Google Tasks から「$title」を削除しますか?'; + } + + @override + String get deleteEvent => '予定を削除'; + + @override + String get title => 'タイトル'; + + @override + String get create => '作成'; + + @override + String get newTask => '新しいタスク'; + + @override + String get clearCompleted => '完了済みを消去'; + + @override + String get refreshList => 'リストを更新'; + + @override + String get refreshAll => 'すべて更新'; + + @override + String get listRefreshed => 'リストを更新しました。'; + + @override + String get allTasksRefreshed => 'すべてのアカウントを更新しました。'; + + @override + String exportedFile(String path) { + return '$path にエクスポートしました'; + } + + @override + String exportFailed(String error) { + return 'エクスポートに失敗しました: $error'; + } + + @override + String refreshFailed(String error) { + return '更新に失敗しました: $error'; + } + + @override + String get selectOrCreateTaskList => '開始するには、タスクリストを選択または作成してください。'; + + @override + String get signInToViewTasks => 'タスクを表示するにはサインインしてください。'; + + @override + String get noTasks => 'タスクはありません。'; + + @override + String get noTasksYet => 'タスクはまだありません'; + + @override + String get noTasksYetMessage => 'タスクを作成するか、アカウントを更新して始めましょう。'; + + @override + String get noTasksInList => 'このリストにタスクはありません。'; + + @override + String get overdue => '期限超過'; + + @override + String get today => '今日'; + + @override + String get tomorrow => '明日'; + + @override + String get upcoming => '今後'; + + @override + String get noDate => '日付なし'; + + @override + String get completed => '完了'; + + @override + String duePrefix(String date) { + return '期限: $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => 'タスクの詳細'; + + @override + String get editTask => 'タスクを編集'; + + @override + String get noTaskSelected => 'タスクが選択されていません。'; + + @override + String get noTaskSelectedHelper => '詳細を表示して編集するタスクを選択してください。'; + + @override + String get taskUnavailable => 'タスクを利用できません。'; + + @override + String get signInToEditTasks => 'タスクを編集するにはサインインしてください。'; + + @override + String get refreshTask => 'タスクを更新'; + + @override + String get primarySection => '基本情報'; + + @override + String get statusSection => 'ステータス'; + + @override + String get openStatus => '未完了'; + + @override + String get doneStatus => '完了'; + + @override + String get notes => 'メモ'; + + @override + String get dueDate => '期限日'; + + @override + String get clearDueDate => '期限日を消去'; + + @override + String get dueTime => '期限時刻'; + + @override + String get startDate => '開始日'; + + @override + String get startTime => '開始時刻'; + + @override + String get endDate => '終了日'; + + @override + String get endTime => '終了時刻'; + + @override + String get reminderDate => 'リマインダーの日付'; + + @override + String get reminderTime => 'リマインダーの時刻'; + + @override + String get reminder => 'リマインダー'; + + @override + String get addReminder => 'リマインダーを追加'; + + @override + String get addGuest => 'ゲストを追加'; + + @override + String get addGuestEmail => 'ゲストのメールアドレスを追加'; + + @override + String get removeReminder => 'リマインダーを削除'; + + @override + String get off => 'オフ'; + + @override + String get repeat => '繰り返し'; + + @override + String get repeatNone => 'なし'; + + @override + String get noneValue => 'なし'; + + @override + String get repeatDaily => '毎日'; + + @override + String get repeatWeekly => '毎週'; + + @override + String get repeatMonthly => '毎月'; + + @override + String get repeatYearly => '毎年'; + + @override + String get importance => '重要度'; + + @override + String get importanceLow => '低'; + + @override + String get importanceNormal => '標準'; + + @override + String get importanceHigh => '高'; + + @override + String get categories => 'カテゴリー'; + + @override + String get scheduleSection => 'スケジュール'; + + @override + String get dueGroup => '期限'; + + @override + String get startGroup => '開始'; + + @override + String get reminderGroup => 'リマインダー'; + + @override + String get organizationSection => '整理'; + + @override + String get actionsSection => '操作'; + + @override + String get advancedSection => '詳細設定'; + + @override + String get addCategory => 'カテゴリーを追加'; + + @override + String get list => 'リスト'; + + @override + String get microsoftMoveUnsupported => + 'このバージョンでは、Microsoft To Do アカウントのリスト間でタスクを移動できません。'; + + @override + String get createSubtask => 'サブタスクを作成'; + + @override + String get moveToTop => '一番上に移動'; + + @override + String get deleteTask => 'タスクを削除'; + + @override + String get newSubtask => '新しいサブタスク'; + + @override + String deleteTaskConfirmation(String title) { + return 'Google Tasks から「$title」を削除しますか?'; + } + + @override + String get metadata => 'メタデータ'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => '更新日時'; + + @override + String get parent => '親タスク'; + + @override + String get position => '位置'; + + @override + String get webLink => 'ウェブリンク'; + + @override + String get assignment => '割り当て'; + + @override + String get localState => 'ローカル状態'; + + @override + String get pendingSync => '同期待ち'; + + @override + String get synced => '同期済み'; + + @override + String get account => 'アカウント'; + + @override + String get sync => '同期'; + + @override + String get manualFullSync => '手動ですべて同期'; + + @override + String get runInBackgroundWhenClosed => 'ウィンドウを閉じてもバックグラウンドで実行を続ける'; + + @override + String get showTrayIcon => 'トレイアイコンを表示'; + + @override + String get startMinimizedToTray => 'トレイに最小化して起動'; + + @override + String get requiresTrayIcon => 'トレイアイコンが必要です。'; + + @override + String get syncComplete => '同期が完了しました。'; + + @override + String syncFailed(String error) { + return '同期に失敗しました: $error'; + } + + @override + String get notifySyncFailures => '同期失敗時に通知'; + + @override + String get notifyConflicts => '競合時に通知'; + + @override + String get notifyDueToday => '今日が期限のタスクを通知'; + + @override + String get eventReminders => '予定のリマインダー'; + + @override + String get taskReminders => 'タスクのリマインダー'; + + @override + String get notificationDetailLevel => '通知の詳細度'; + + @override + String get notificationDetailPrivate => '非公開'; + + @override + String get notificationDetailNormal => '標準'; + + @override + String get quietHours => '通知を停止する時間'; + + @override + String get quietHoursDescription => 'この時間帯は通知を一時停止します。'; + + @override + String get quietHoursStart => '通知停止の開始時刻'; + + @override + String get quietHoursEnd => '通知停止の終了時刻'; + + @override + String get notifications => '通知'; + + @override + String get appearance => '外観'; + + @override + String get theme => 'テーマ'; + + @override + String get themeSystem => 'システム'; + + @override + String get themeLight => 'ライト'; + + @override + String get themeDark => 'ダーク'; + + @override + String get themeFamily => 'テーマファミリー'; + + @override + String get themeFamilyYaru => 'Ubuntu ネイティブ(Yaru)'; + + @override + String get localization => '言語と地域'; + + @override + String get currentLocale => '現在のロケール'; + + @override + String get privacy => 'プライバシー'; + + @override + String get redactTaskContentInDiagnostics => '診断情報でタスクの内容を伏せる'; + + @override + String get developerDiagnostics => '開発者向け診断'; + + @override + String get diagnostics => '診断'; + + @override + String get apiInspectorDisabled => 'API インスペクターを表示'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'Discovery リビジョン: $revision'; + } + + @override + String get implementedMethods => '実装済みメソッド'; + + @override + String get supportsTasksScopes => 'tasks および tasks.readonly スコープをサポート'; + + @override + String get requiresTasksScope => 'tasks スコープが必要'; + + @override + String get blockedPendingOperations => 'ブロックされた保留中の操作'; + + @override + String get signInToInspectPendingOperations => '保留中の操作を確認するにはサインインしてください。'; + + @override + String get noBlockedPendingOperations => 'ブロックされた保留中の操作はありません。'; + + @override + String get operationActions => '操作への対応'; + + @override + String pendingOpListId(String id) { + return 'リスト=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'タスク=$id'; + } + + @override + String pendingOpAttempts(int count) { + return '試行回数=$count'; + } + + @override + String get retry => '再試行'; + + @override + String get discard => '破棄'; + + @override + String get discardChangesAction => '破棄'; + + @override + String get discardChanges => '変更を破棄しますか?'; + + @override + String get discardChangesConfirmation => 'このタスクの未保存の編集内容を破棄します。'; + + @override + String get retryCompleted => '再試行が完了しました。'; + + @override + String get discardPendingOperation => '保留中の操作を破棄しますか?'; + + @override + String get discardPendingOperationConfirmation => + 'ブロックされたローカル操作を削除します。次回の同期時に Google Tasks からデータが再取得されます。'; + + @override + String get pendingOperationDiscarded => '保留中の操作を破棄しました。'; + + @override + String get syncFailureNotificationTitle => 'BusyMax の同期に失敗'; + + @override + String syncFailureNotificationBody(String message) { + return 'バックグラウンド同期に失敗しました。$message'; + } + + @override + String get conflictNotificationTitle => 'BusyMax の同期競合'; + + @override + String conflictNotificationBody(String summary) { + return '保留中のローカル変更がブロックされました。$summary'; + } + + @override + String get dueTodayNotificationTitle => '今日が期限のタスク'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '今日が期限のタスクが$count件あります。', + one: '今日が期限のタスクが1件あります。', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => '予定のリマインダー'; + + @override + String get taskReminderNotificationTitle => 'タスクのリマインダー'; + + @override + String get eventReminderNotificationBody => '予定がまもなく始まります。'; + + @override + String get taskReminderNotificationBody => 'タスクの期限が近づいています。'; + + @override + String get notificationOpenAction => '開く'; + + @override + String get notificationDetailsHidden => 'プライバシー設定により詳細は非表示です。'; + + @override + String get previousMonth => '前の月'; + + @override + String get nextMonth => '次の月'; + + @override + String get openMonthView => '月表示を開く'; + + @override + String get previousYear => '前の年'; + + @override + String get nextYear => '次の年'; + + @override + String get openYearView => '年表示を開く'; + + @override + String weekNumberTooltip(int number) { + return '第$number週'; + } + + @override + String get resizeAllDayPanel => '終日パネルのサイズを変更'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count件', + one: '1件', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'このカレンダーは読み取り専用です。'; + + @override + String get selectTimeZone => 'タイムゾーンを選択'; + + @override + String get searchLocations => '場所を検索'; + + @override + String get noLocationsFound => '場所が見つかりません'; + + @override + String deleteCalendarConfirmation(String title) { + return '「$title」を削除しますか?'; + } +} diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart new file mode 100644 index 0000000..dee60a8 --- /dev/null +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -0,0 +1,1226 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Korean (`ko`). +class AppLocalizationsKo extends AppLocalizations { + AppLocalizationsKo([String locale = 'ko']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Google 및 Microsoft 계정을 연결하여 캘린더와 할 일을 동기화하세요.'; + + @override + String get googlePermissionsConsentNotice => + 'Google 권한 화면에서 캘린더와 할 일 권한을 모두 선택하세요.'; + + @override + String get googlePermissionsRequiredRetry => + 'Google Calendar 및 Google Tasks 권한이 필요합니다. 다시 시도하여 두 체크박스를 모두 선택하세요.'; + + @override + String get finishSetup => '설정 완료'; + + @override + String get continueSetup => '계속'; + + @override + String get onboardingSetupTitle => 'BusyMax 설정'; + + @override + String get onboardingAccountsStepTitle => '계정 연결'; + + @override + String get onboardingAccountsStepDescription => + '사용할 Google 및 Microsoft 계정을 모두 추가하세요. BusyMax는 각 계정의 캘린더, 일정, 할 일 목록 및 할 일을 동기화합니다.'; + + @override + String get onboardingPreferencesStepTitle => '시스템 설정 선택'; + + @override + String get onboardingPreferencesStepDescription => + '일정을 열기 전에 데스크톱 동작, 미리 알림, 알림 세부 수준 및 화면 모양을 설정하세요.'; + + @override + String get signInWithGoogle => 'Google로 로그인'; + + @override + String get signInWithMicrosoft => 'Microsoft로 로그인'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => '이 서비스는 설정되어 있지 않습니다.'; + + @override + String get waitingForGoogleSignIn => 'Google 로그인을 기다리는 중...'; + + @override + String get waitingForMicrosoftSignIn => 'Microsoft 로그인을 기다리는 중...'; + + @override + String get microsoftSignInNotConfigured => + 'Microsoft 로그인이 구성되지 않았습니다. MICROSOFT_OAUTH_CLIENT_ID를 설정하세요.'; + + @override + String get cancel => '취소'; + + @override + String get close => '닫기'; + + @override + String get exit => '종료'; + + @override + String get options => '옵션'; + + @override + String get hide => '숨기기'; + + @override + String get show => '표시'; + + @override + String get export => '내보내기'; + + @override + String get save => '저장'; + + @override + String get settings => '설정'; + + @override + String get all => '모두'; + + @override + String get calendarEvents => '일정'; + + @override + String get calendarTasks => '할 일'; + + @override + String get calendar => '캘린더'; + + @override + String get calendars => '캘린더'; + + @override + String get newEvent => '새 일정'; + + @override + String get refreshCalendar => '캘린더 새로 고침'; + + @override + String get openInProvider => '서비스에서 열기'; + + @override + String get hideFromSchedule => '일정에서 숨기기'; + + @override + String get showInSchedule => '일정에 표시'; + + @override + String get noCalendarsSynced => '아직 동기화된 캘린더가 없습니다.'; + + @override + String get allDay => '하루 종일'; + + @override + String moreItems(int count) { + return '+$count개 더 보기'; + } + + @override + String get noEventsOrTasks => '일정 또는 할 일이 없습니다'; + + @override + String get scheduleLoading => '일정을 불러오는 중...'; + + @override + String get scheduleUnavailable => '일정을 사용할 수 없습니다'; + + @override + String get scheduleNoSources => '표시할 캘린더 또는 할 일 목록이 없습니다'; + + @override + String get scheduleNoSourcesDescription => '설정에서 표시할 항목을 선택한 다음 새로 고침하세요.'; + + @override + String get scheduleSignInRequired => '계정 연결'; + + @override + String get scheduleSignInDescription => '캘린더와 할 일을 동기화하려면 로그인하세요.'; + + @override + String get scheduleNoSearchResults => '일치하는 일정 또는 할 일이 없습니다'; + + @override + String get scheduleNoSearchResultsDescription => '다른 검색어를 사용하거나 현재 필터를 지우세요.'; + + @override + String get refresh => '새로 고침'; + + @override + String get trayOpenBusyMax => 'BusyMax 열기'; + + @override + String get agendaLoadMoreOverdue => '기한이 지난 할 일 더 불러오기'; + + @override + String get agendaLoadMoreNoDate => '날짜 없는 할 일 더 불러오기'; + + @override + String get viewDay => '일'; + + @override + String get viewWeek => '주'; + + @override + String get viewMonth => '월'; + + @override + String get viewYear => '년'; + + @override + String get viewAgenda => '일정 목록'; + + @override + String get scheduleSettings => '일정'; + + @override + String get scheduleDisplaySettings => '일정 표시'; + + @override + String get scheduleDisplayHoursDescription => + '일간 및 주간 보기는 처음에 이 시간 범위를 표시합니다. 필요한 경우 더 이르거나 늦은 항목에 맞춰 범위가 확장됩니다.'; + + @override + String get scheduleDayStartsAt => '하루 시작 시간'; + + @override + String get scheduleDayEndsAt => '하루 종료 시간'; + + @override + String get sourceCalendar => '캘린더'; + + @override + String get sourceTaskList => '할 일 목록'; + + @override + String get createChoiceTitle => '만들기'; + + @override + String get createEventAtTime => '일정'; + + @override + String get createTaskAtDate => '할 일'; + + @override + String get editEvent => '일정 편집'; + + @override + String get eventTitle => '일정 제목'; + + @override + String get location => '위치'; + + @override + String get timeSlot => '시간대'; + + @override + String get startDateTime => '시작 날짜/시간'; + + @override + String get endDateTime => '종료 날짜/시간'; + + @override + String get doesNotRepeat => '반복 안 함'; + + @override + String get defaultReminder => '기본 미리 알림'; + + @override + String get guests => '참석자'; + + @override + String get noGuests => '참석자 없음'; + + @override + String get description => '설명'; + + @override + String get availabilityShowAs => '일정 상태 / 표시 방식'; + + @override + String get busy => '바쁨'; + + @override + String get visibility => '공개 범위'; + + @override + String get defaultVisibility => '기본 공개 범위'; + + @override + String get conference => '회의'; + + @override + String get noConference => '회의 없음'; + + @override + String get providerCalendar => '서비스 캘린더'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => '굵게'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => '기울임꼴'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => '밑줄'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes분 전', + one: '1분 전', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => '시작 시'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours시간 전', + one: '1시간 전', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days일 전', + one: '1일 전', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => '한가함'; + + @override + String get availabilityTentative => '미정'; + + @override + String get availabilityOutOfOffice => '부재중'; + + @override + String get availabilityWorkingElsewhere => '다른 장소에서 근무'; + + @override + String get visibilityDefault => '기본값'; + + @override + String get visibilityPublic => '공개'; + + @override + String get visibilityPrivate => '비공개'; + + @override + String get visibilityConfidential => '기밀'; + + @override + String get sensitivityNormal => '일반'; + + @override + String get sensitivityPersonal => '개인'; + + @override + String get tasks => '할 일'; + + @override + String get allTasks => '모든 할 일'; + + @override + String tasksInList(String title) { + return '$title의 할 일'; + } + + @override + String get taskLists => '할 일 목록'; + + @override + String get navigation => '탐색'; + + @override + String get mainMenu => '주 메뉴'; + + @override + String get keyboardShortcuts => '키보드 단축키'; + + @override + String get shortcutGroupGeneral => '일반'; + + @override + String get shortcutKeyboardShortcutsDescription => '이 단축키 도움말 표시'; + + @override + String get shortcutGroupNavigation => '탐색'; + + @override + String get shortcutNextPeriod => '다음 기간'; + + @override + String get shortcutNextPeriodDescription => + '주간 보기에서는 다음 주, 월간 보기에서는 다음 달로 이동하는 식입니다'; + + @override + String get shortcutPreviousPeriod => '이전 기간'; + + @override + String get shortcutPreviousPeriodDescription => + '주간 보기에서는 이전 주, 월간 보기에서는 이전 달로 이동하는 식입니다'; + + @override + String get shortcutJumpToToday => '오늘로 이동'; + + @override + String get shortcutGroupView => '보기'; + + @override + String get shortcutDayView => '일간 보기'; + + @override + String get shortcutWeekView => '주간 보기'; + + @override + String get shortcutMonthView => '월간 보기'; + + @override + String get shortcutYearView => '연간 보기'; + + @override + String get shortcutAgendaView => '일정 목록 보기'; + + @override + String get shortcutGroupCreateAndEdit => '만들기 및 편집'; + + @override + String get shortcutSaveItem => '일정 또는 할 일 저장'; + + @override + String get shortcutDeleteItem => '일정 또는 할 일 삭제'; + + @override + String get shortcutGroupTaskEditing => '할 일 편집'; + + @override + String get shortcutCancelEditing => '편집 취소'; + + @override + String get shortcutCancelEditingDescription => '할 일 편집 또는 할 일 세부 정보 닫기'; + + @override + String get aboutBusyMax => 'BusyMax 정보'; + + @override + String get aboutBusyMaxDescription => '캘린더와 할 일'; + + @override + String get license => '라이선스'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => '웹사이트'; + + @override + String get sourceCode => '소스 코드'; + + @override + String get reportAnIssue => '문제 신고'; + + @override + String get sendFeedback => '의견 보내기'; + + @override + String get feedbackSubmit => '제출'; + + @override + String get feedbackCategory => '범주'; + + @override + String get feedbackSelectCategory => '범주 선택'; + + @override + String get feedbackCategoryProblem => '문제 또는 버그'; + + @override + String get feedbackCategoryFeature => '기능 요청'; + + @override + String get feedbackCategoryPrivacySecurity => '개인정보 보호 또는 보안 우려'; + + @override + String get feedbackCategoryUsability => '사용 편의성 문제'; + + @override + String get feedbackCategoryOther => '기타'; + + @override + String get feedbackSubject => '제목'; + + @override + String get feedbackDetailedMessage => '자세한 내용'; + + @override + String get feedbackReplyEmail => '답변 받을 이메일 주소(선택 사항)'; + + @override + String get feedbackIncludeTechnicalDetails => '기술 세부 정보 포함'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Linux 운영 체제 버전과 앱 로캘만 추가됩니다. 로그, 계정 데이터, 파일 이름 또는 기타 진단 정보는 포함되지 않습니다.'; + + @override + String get feedbackCategoryRequired => '범주를 선택하세요.'; + + @override + String get feedbackSubjectLengthError => '제목은 3~120자여야 합니다.'; + + @override + String get feedbackMessageLengthError => '메시지는 10~5,000자여야 합니다.'; + + @override + String get feedbackInvalidEmail => '올바른 이메일 주소를 입력하세요.'; + + @override + String get feedbackConnectionError => + 'BusyStack에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요.'; + + @override + String get feedbackTimeoutError => + '요청 시간이 초과되었습니다. 의견은 지워지지 않았습니다. 다시 시도하세요.'; + + @override + String get feedbackRateLimitedError => + '이 네트워크에서 너무 많은 의견이 제출되었습니다. 잠시 기다린 후 다시 시도하세요.'; + + @override + String get feedbackRejectedError => '서버가 제출을 거부했습니다. 입력란을 검토하고 다시 시도하세요.'; + + @override + String get feedbackServerError => + '현재 BusyStack에서 의견을 받을 수 없습니다. 의견은 지워지지 않았습니다. 다시 시도하세요.'; + + @override + String feedbackSuccess(String id) { + return '의견을 보냈습니다. 참조: $id'; + } + + @override + String get toggleSidebar => '사이드바 표시 전환'; + + @override + String get showSidebar => '사이드바 패널 표시'; + + @override + String get hideSidebar => '사이드바 패널 숨기기'; + + @override + String get accounts => '계정'; + + @override + String get currentAccount => '현재 계정'; + + @override + String get switchAccount => '계정 전환'; + + @override + String get addGoogleAccount => 'Google 계정 추가'; + + @override + String get addMicrosoftAccount => 'Microsoft 계정 추가'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => '로그인됨'; + + @override + String get removeAccount => '계정 삭제…'; + + @override + String get removingAccount => '계정 삭제 중…'; + + @override + String get removeAccountDescription => '동기화를 중지하고 이 기기에서 이 계정의 데이터를 삭제합니다.'; + + @override + String removeAccountTitle(String account) { + return 'BusyMax에서 $account 계정을 삭제할까요?'; + } + + @override + String get removeAccountConfirmation => + '이 기기에서 캐시된 할 일, 캘린더, 일정, 미리 알림 및 보류 중인 오프라인 변경 사항이 삭제됩니다. 동기화되지 않은 변경 사항은 사라집니다. Google 또는 Microsoft에서는 아무것도 삭제되지 않습니다.'; + + @override + String get revokeGoogleAccess => '이 Google 계정에 대한 BusyMax의 액세스 권한도 취소'; + + @override + String get revokeGoogleAccessDescription => '다시 연결하기 전에 액세스 권한을 다시 부여해야 합니다.'; + + @override + String get removeAccountAction => '계정 삭제'; + + @override + String get removeAccountFailed => '계정 삭제를 완료할 수 없습니다. 다시 시도하세요.'; + + @override + String get accountRemovedGoogleRevokeFailed => + '계정은 이 기기에서 삭제되었지만 BusyMax가 Google 계정 액세스 권한을 취소하지 못했습니다. Google 계정에서 직접 취소할 수 있습니다.'; + + @override + String get newList => '새 목록'; + + @override + String get signInToViewTaskLists => '할 일 목록을 보려면 로그인하세요.'; + + @override + String get noTaskListsSynced => '아직 동기화된 할 일 목록이 없습니다.'; + + @override + String get listActions => '목록 작업'; + + @override + String get rename => '이름 바꾸기'; + + @override + String get delete => '삭제'; + + @override + String get renameList => '목록 이름 바꾸기'; + + @override + String get deleteList => '목록 삭제'; + + @override + String get builtInMicrosoftList => '기본 제공'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Microsoft To Do의 기본 제공 목록은 이름을 바꾸거나 삭제할 수 없습니다.'; + + @override + String deleteListConfirmation(String title) { + return 'Google Tasks에서 “$title” 목록을 삭제할까요?'; + } + + @override + String get deleteEvent => '일정 삭제'; + + @override + String get title => '제목'; + + @override + String get create => '만들기'; + + @override + String get newTask => '새 할 일'; + + @override + String get clearCompleted => '완료된 항목 지우기'; + + @override + String get refreshList => '목록 새로 고침'; + + @override + String get refreshAll => '모두 새로 고침'; + + @override + String get listRefreshed => '목록을 새로 고쳤습니다.'; + + @override + String get allTasksRefreshed => '모든 계정을 새로 고쳤습니다.'; + + @override + String exportedFile(String path) { + return '$path에 내보냈습니다'; + } + + @override + String exportFailed(String error) { + return '내보내기 실패: $error'; + } + + @override + String refreshFailed(String error) { + return '새로 고침 실패: $error'; + } + + @override + String get selectOrCreateTaskList => '시작하려면 할 일 목록을 선택하거나 만드세요.'; + + @override + String get signInToViewTasks => '할 일을 보려면 로그인하세요.'; + + @override + String get noTasks => '할 일이 없습니다.'; + + @override + String get noTasksYet => '아직 할 일이 없습니다'; + + @override + String get noTasksYetMessage => '할 일을 만들거나 계정을 새로 고쳐 시작하세요.'; + + @override + String get noTasksInList => '이 목록에 할 일이 없습니다.'; + + @override + String get overdue => '기한 지남'; + + @override + String get today => '오늘'; + + @override + String get tomorrow => '내일'; + + @override + String get upcoming => '예정'; + + @override + String get noDate => '날짜 없음'; + + @override + String get completed => '완료'; + + @override + String duePrefix(String date) { + return '$date 마감'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => '할 일 세부 정보'; + + @override + String get editTask => '할 일 편집'; + + @override + String get noTaskSelected => '선택된 할 일이 없습니다.'; + + @override + String get noTaskSelectedHelper => '세부 정보를 보고 편집할 할 일을 선택하세요.'; + + @override + String get taskUnavailable => '할 일을 사용할 수 없습니다.'; + + @override + String get signInToEditTasks => '할 일을 편집하려면 로그인하세요.'; + + @override + String get refreshTask => '할 일 새로 고침'; + + @override + String get primarySection => '기본'; + + @override + String get statusSection => '상태'; + + @override + String get openStatus => '미완료'; + + @override + String get doneStatus => '완료'; + + @override + String get notes => '메모'; + + @override + String get dueDate => '마감일'; + + @override + String get clearDueDate => '마감일 지우기'; + + @override + String get dueTime => '마감 시간'; + + @override + String get startDate => '시작일'; + + @override + String get startTime => '시작 시간'; + + @override + String get endDate => '종료일'; + + @override + String get endTime => '종료 시간'; + + @override + String get reminderDate => '미리 알림 날짜'; + + @override + String get reminderTime => '미리 알림 시간'; + + @override + String get reminder => '미리 알림'; + + @override + String get addReminder => '미리 알림 추가'; + + @override + String get addGuest => '참석자 추가'; + + @override + String get addGuestEmail => '참석자 이메일 추가'; + + @override + String get removeReminder => '미리 알림 삭제'; + + @override + String get off => '끔'; + + @override + String get repeat => '반복'; + + @override + String get repeatNone => '없음'; + + @override + String get noneValue => '없음'; + + @override + String get repeatDaily => '매일'; + + @override + String get repeatWeekly => '매주'; + + @override + String get repeatMonthly => '매월'; + + @override + String get repeatYearly => '매년'; + + @override + String get importance => '중요도'; + + @override + String get importanceLow => '낮음'; + + @override + String get importanceNormal => '보통'; + + @override + String get importanceHigh => '높음'; + + @override + String get categories => '범주'; + + @override + String get scheduleSection => '일정'; + + @override + String get dueGroup => '마감'; + + @override + String get startGroup => '시작'; + + @override + String get reminderGroup => '미리 알림'; + + @override + String get organizationSection => '구성'; + + @override + String get actionsSection => '작업'; + + @override + String get advancedSection => '고급'; + + @override + String get addCategory => '범주 추가'; + + @override + String get list => '목록'; + + @override + String get microsoftMoveUnsupported => + '이 버전에서는 Microsoft To Do 계정의 목록 간에 할 일을 이동할 수 없습니다.'; + + @override + String get createSubtask => '하위 할 일 만들기'; + + @override + String get moveToTop => '맨 위로 이동'; + + @override + String get deleteTask => '할 일 삭제'; + + @override + String get newSubtask => '새 하위 할 일'; + + @override + String deleteTaskConfirmation(String title) { + return 'Google Tasks에서 “$title” 항목을 삭제할까요?'; + } + + @override + String get metadata => '메타데이터'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => '업데이트됨'; + + @override + String get parent => '상위 할 일'; + + @override + String get position => '위치'; + + @override + String get webLink => '웹 링크'; + + @override + String get assignment => '할당'; + + @override + String get localState => '로컬 상태'; + + @override + String get pendingSync => '동기화 보류 중'; + + @override + String get synced => '동기화됨'; + + @override + String get account => '계정'; + + @override + String get sync => '동기화'; + + @override + String get manualFullSync => '수동 전체 동기화'; + + @override + String get runInBackgroundWhenClosed => '창을 닫아도 계속 실행'; + + @override + String get showTrayIcon => '트레이 아이콘 표시'; + + @override + String get startMinimizedToTray => '트레이에 최소화하여 시작'; + + @override + String get requiresTrayIcon => '트레이 아이콘이 필요합니다.'; + + @override + String get syncComplete => '동기화가 완료되었습니다.'; + + @override + String syncFailed(String error) { + return '동기화 실패: $error'; + } + + @override + String get notifySyncFailures => '동기화 실패 알림'; + + @override + String get notifyConflicts => '충돌 알림'; + + @override + String get notifyDueToday => '오늘 마감인 할 일 알림'; + + @override + String get eventReminders => '일정 미리 알림'; + + @override + String get taskReminders => '할 일 미리 알림'; + + @override + String get notificationDetailLevel => '알림 세부 수준'; + + @override + String get notificationDetailPrivate => '비공개'; + + @override + String get notificationDetailNormal => '일반'; + + @override + String get quietHours => '방해 금지 시간'; + + @override + String get quietHoursDescription => '이 시간 동안 알림을 일시 중지합니다.'; + + @override + String get quietHoursStart => '방해 금지 시작 시간'; + + @override + String get quietHoursEnd => '방해 금지 종료 시간'; + + @override + String get notifications => '알림'; + + @override + String get appearance => '화면 모양'; + + @override + String get theme => '테마'; + + @override + String get themeSystem => '시스템'; + + @override + String get themeLight => '라이트'; + + @override + String get themeDark => '다크'; + + @override + String get themeFamily => '테마 계열'; + + @override + String get themeFamilyYaru => 'Ubuntu 기본 테마(Yaru)'; + + @override + String get localization => '언어 및 지역'; + + @override + String get currentLocale => '현재 로캘'; + + @override + String get privacy => '개인정보 보호'; + + @override + String get redactTaskContentInDiagnostics => '진단 정보에서 할 일 내용 숨기기'; + + @override + String get developerDiagnostics => '개발자 진단'; + + @override + String get diagnostics => '진단'; + + @override + String get apiInspectorDisabled => 'API 검사기 표시'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'Discovery 리비전: $revision'; + } + + @override + String get implementedMethods => '구현된 메서드'; + + @override + String get supportsTasksScopes => 'tasks 및 tasks.readonly 범위 지원'; + + @override + String get requiresTasksScope => 'tasks 범위 필요'; + + @override + String get blockedPendingOperations => '차단된 보류 작업'; + + @override + String get signInToInspectPendingOperations => '보류 작업을 확인하려면 로그인하세요.'; + + @override + String get noBlockedPendingOperations => '차단된 보류 작업이 없습니다.'; + + @override + String get operationActions => '작업별 조치'; + + @override + String pendingOpListId(String id) { + return '목록=$id'; + } + + @override + String pendingOpTaskId(String id) { + return '할 일=$id'; + } + + @override + String pendingOpAttempts(int count) { + return '시도=$count'; + } + + @override + String get retry => '다시 시도'; + + @override + String get discard => '버리기'; + + @override + String get discardChangesAction => '변경 사항 버리기'; + + @override + String get discardChanges => '변경 사항을 버릴까요?'; + + @override + String get discardChangesConfirmation => '이 할 일에서 저장하지 않은 편집 내용을 버립니다.'; + + @override + String get retryCompleted => '다시 시도했습니다.'; + + @override + String get discardPendingOperation => '보류 작업을 버릴까요?'; + + @override + String get discardPendingOperationConfirmation => + '차단된 로컬 작업을 삭제합니다. 다음 동기화에서 Google Tasks의 데이터를 새로 불러옵니다.'; + + @override + String get pendingOperationDiscarded => '보류 작업을 버렸습니다.'; + + @override + String get syncFailureNotificationTitle => 'BusyMax 동기화 실패'; + + @override + String syncFailureNotificationBody(String message) { + return '백그라운드 동기화에 실패했습니다. $message'; + } + + @override + String get conflictNotificationTitle => 'BusyMax 동기화 충돌'; + + @override + String conflictNotificationBody(String summary) { + return '보류 중인 로컬 변경 사항이 차단되었습니다. $summary'; + } + + @override + String get dueTodayNotificationTitle => '오늘 마감인 할 일'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '오늘 마감인 할 일이 $count개 있습니다.', + one: '오늘 마감인 할 일이 1개 있습니다.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => '일정 미리 알림'; + + @override + String get taskReminderNotificationTitle => '할 일 미리 알림'; + + @override + String get eventReminderNotificationBody => '일정이 곧 시작됩니다.'; + + @override + String get taskReminderNotificationBody => '할 일 마감이 얼마 남지 않았습니다.'; + + @override + String get notificationOpenAction => '열기'; + + @override + String get notificationDetailsHidden => '개인정보 보호 설정에 따라 세부 정보가 숨겨졌습니다.'; + + @override + String get previousMonth => '이전 달'; + + @override + String get nextMonth => '다음 달'; + + @override + String get openMonthView => '월간 보기 열기'; + + @override + String get previousYear => '이전 해'; + + @override + String get nextYear => '다음 해'; + + @override + String get openYearView => '연간 보기 열기'; + + @override + String weekNumberTooltip(int number) { + return '$number주차'; + } + + @override + String get resizeAllDayPanel => '종일 패널 크기 조절'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '항목 $count개', + one: '항목 1개', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => '이 캘린더는 읽기 전용입니다.'; + + @override + String get selectTimeZone => '시간대 선택'; + + @override + String get searchLocations => '위치 검색'; + + @override + String get noLocationsFound => '위치를 찾을 수 없습니다'; + + @override + String deleteCalendarConfirmation(String title) { + return '“$title” 캘린더를 삭제할까요?'; + } +} diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart new file mode 100644 index 0000000..14bbdea --- /dev/null +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -0,0 +1,1260 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Portuguese (`pt`). +class AppLocalizationsPt extends AppLocalizations { + AppLocalizationsPt([String locale = 'pt']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Ligue contas Google e Microsoft para sincronizar calendários e tarefas.'; + + @override + String get googlePermissionsConsentNotice => + 'No ecrã de autorizações da Google, selecione as autorizações do Calendário e das Tarefas.'; + + @override + String get googlePermissionsRequiredRetry => + 'As autorizações do Calendário Google e do Google Tasks são necessárias. Tente novamente e selecione ambas as caixas.'; + + @override + String get finishSetup => 'Concluir configuração'; + + @override + String get continueSetup => 'Continuar'; + + @override + String get onboardingSetupTitle => 'Configurar o BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'Ligar contas'; + + @override + String get onboardingAccountsStepDescription => + 'Adicione todas as contas Google e Microsoft que pretende utilizar. O BusyMax sincroniza calendários, eventos, listas de tarefas e tarefas de cada conta.'; + + @override + String get onboardingPreferencesStepTitle => 'Escolher definições do sistema'; + + @override + String get onboardingPreferencesStepDescription => + 'Configure o comportamento da aplicação no ambiente de trabalho, os lembretes, o nível de detalhe das notificações e o aspeto antes de abrir a agenda.'; + + @override + String get signInWithGoogle => 'Iniciar sessão com a Google'; + + @override + String get signInWithMicrosoft => 'Iniciar sessão com a Microsoft'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'Este fornecedor não está configurado.'; + + @override + String get waitingForGoogleSignIn => + 'A aguardar o início de sessão da Google...'; + + @override + String get waitingForMicrosoftSignIn => + 'A aguardar o início de sessão da Microsoft...'; + + @override + String get microsoftSignInNotConfigured => + 'O início de sessão da Microsoft não está configurado. Defina MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'Cancelar'; + + @override + String get close => 'Fechar'; + + @override + String get exit => 'Sair'; + + @override + String get options => 'Opções'; + + @override + String get hide => 'Ocultar'; + + @override + String get show => 'Mostrar'; + + @override + String get export => 'Exportar'; + + @override + String get save => 'Guardar'; + + @override + String get settings => 'Definições'; + + @override + String get all => 'Tudo'; + + @override + String get calendarEvents => 'Eventos'; + + @override + String get calendarTasks => 'Tarefas'; + + @override + String get calendar => 'Calendário'; + + @override + String get calendars => 'Calendários'; + + @override + String get newEvent => 'Novo evento'; + + @override + String get refreshCalendar => 'Atualizar calendário'; + + @override + String get openInProvider => 'Abrir no serviço'; + + @override + String get hideFromSchedule => 'Ocultar da agenda'; + + @override + String get showInSchedule => 'Mostrar na agenda'; + + @override + String get noCalendarsSynced => 'Ainda não há calendários sincronizados.'; + + @override + String get allDay => 'Todo o dia'; + + @override + String moreItems(int count) { + return '+$count mais'; + } + + @override + String get noEventsOrTasks => 'Sem eventos ou tarefas'; + + @override + String get scheduleLoading => 'A carregar agenda...'; + + @override + String get scheduleUnavailable => 'Agenda indisponível'; + + @override + String get scheduleNoSources => + 'Sem calendários ou listas de tarefas visíveis'; + + @override + String get scheduleNoSourcesDescription => + 'Escolha o que pretende mostrar nas Definições e atualize a agenda.'; + + @override + String get scheduleSignInRequired => 'Ligar uma conta'; + + @override + String get scheduleSignInDescription => + 'Inicie sessão para sincronizar calendários e tarefas.'; + + @override + String get scheduleNoSearchResults => + 'Nenhum evento ou tarefa correspondente'; + + @override + String get scheduleNoSearchResultsDescription => + 'Experimente outra pesquisa ou limpe os filtros atuais.'; + + @override + String get refresh => 'Atualizar'; + + @override + String get trayOpenBusyMax => 'Abrir o BusyMax'; + + @override + String get agendaLoadMoreOverdue => 'Carregar mais tarefas em atraso'; + + @override + String get agendaLoadMoreNoDate => 'Carregar mais tarefas sem data'; + + @override + String get viewDay => 'Dia'; + + @override + String get viewWeek => 'Semana'; + + @override + String get viewMonth => 'Mês'; + + @override + String get viewYear => 'Ano'; + + @override + String get viewAgenda => 'Agenda'; + + @override + String get scheduleSettings => 'Agenda'; + + @override + String get scheduleDisplaySettings => 'Apresentação da agenda'; + + @override + String get scheduleDisplayHoursDescription => + 'As vistas de dia e semana mostram inicialmente este intervalo horário. Os itens anteriores ou posteriores alargam-no quando necessário.'; + + @override + String get scheduleDayStartsAt => 'O dia começa às'; + + @override + String get scheduleDayEndsAt => 'O dia termina às'; + + @override + String get sourceCalendar => 'Calendário'; + + @override + String get sourceTaskList => 'Lista de tarefas'; + + @override + String get createChoiceTitle => 'Criar'; + + @override + String get createEventAtTime => 'Evento'; + + @override + String get createTaskAtDate => 'Tarefa'; + + @override + String get editEvent => 'Editar evento'; + + @override + String get eventTitle => 'Título do evento'; + + @override + String get location => 'Local'; + + @override + String get timeSlot => 'Intervalo de tempo'; + + @override + String get startDateTime => 'Data e hora de início'; + + @override + String get endDateTime => 'Data e hora de fim'; + + @override + String get doesNotRepeat => 'Não se repete'; + + @override + String get defaultReminder => 'Lembrete predefinido'; + + @override + String get guests => 'Convidados'; + + @override + String get noGuests => 'Sem convidados'; + + @override + String get description => 'Descrição'; + + @override + String get availabilityShowAs => 'Disponibilidade / Mostrar como'; + + @override + String get busy => 'Ocupado'; + + @override + String get visibility => 'Visibilidade'; + + @override + String get defaultVisibility => 'Visibilidade predefinida'; + + @override + String get conference => 'Conferência'; + + @override + String get noConference => 'Sem conferência'; + + @override + String get providerCalendar => 'Calendário do fornecedor'; + + @override + String get formatBoldShortLabel => 'N'; + + @override + String get formatBoldTooltip => 'Negrito'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'Itálico'; + + @override + String get formatUnderlineShortLabel => 'S'; + + @override + String get formatUnderlineTooltip => 'Sublinhado'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutos antes', + one: '1 minuto antes', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'À hora de início'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours horas antes', + one: '1 hora antes', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days dias antes', + one: '1 dia antes', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Livre'; + + @override + String get availabilityTentative => 'Provisório'; + + @override + String get availabilityOutOfOffice => 'Fora do escritório'; + + @override + String get availabilityWorkingElsewhere => 'A trabalhar noutro local'; + + @override + String get visibilityDefault => 'Predefinida'; + + @override + String get visibilityPublic => 'Pública'; + + @override + String get visibilityPrivate => 'Privada'; + + @override + String get visibilityConfidential => 'Confidencial'; + + @override + String get sensitivityNormal => 'Normal'; + + @override + String get sensitivityPersonal => 'Pessoal'; + + @override + String get tasks => 'Tarefas'; + + @override + String get allTasks => 'Todas as tarefas'; + + @override + String tasksInList(String title) { + return 'Tarefas em $title'; + } + + @override + String get taskLists => 'Listas de tarefas'; + + @override + String get navigation => 'Navegação'; + + @override + String get mainMenu => 'Menu principal'; + + @override + String get keyboardShortcuts => 'Atalhos de teclado'; + + @override + String get shortcutGroupGeneral => 'Geral'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'Mostrar esta referência de atalhos'; + + @override + String get shortcutGroupNavigation => 'Navegação'; + + @override + String get shortcutNextPeriod => 'Período seguinte'; + + @override + String get shortcutNextPeriodDescription => + 'Semana seguinte na vista semanal, mês seguinte na vista mensal e assim por diante'; + + @override + String get shortcutPreviousPeriod => 'Período anterior'; + + @override + String get shortcutPreviousPeriodDescription => + 'Semana anterior na vista semanal, mês anterior na vista mensal e assim por diante'; + + @override + String get shortcutJumpToToday => 'Ir para hoje'; + + @override + String get shortcutGroupView => 'Vista'; + + @override + String get shortcutDayView => 'Vista diária'; + + @override + String get shortcutWeekView => 'Vista semanal'; + + @override + String get shortcutMonthView => 'Vista mensal'; + + @override + String get shortcutYearView => 'Vista anual'; + + @override + String get shortcutAgendaView => 'Vista de agenda'; + + @override + String get shortcutGroupCreateAndEdit => 'Criar e editar'; + + @override + String get shortcutSaveItem => 'Guardar evento ou tarefa'; + + @override + String get shortcutDeleteItem => 'Eliminar evento ou tarefa'; + + @override + String get shortcutGroupTaskEditing => 'Edição de tarefas'; + + @override + String get shortcutCancelEditing => 'Cancelar edição'; + + @override + String get shortcutCancelEditingDescription => + 'Fechar a edição ou os detalhes da tarefa'; + + @override + String get aboutBusyMax => 'Acerca do BusyMax'; + + @override + String get aboutBusyMaxDescription => 'Calendário e tarefas'; + + @override + String get license => 'Licença'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => 'Site'; + + @override + String get sourceCode => 'Código-fonte'; + + @override + String get reportAnIssue => 'Comunicar um problema'; + + @override + String get sendFeedback => 'Enviar comentários'; + + @override + String get feedbackSubmit => 'Enviar'; + + @override + String get feedbackCategory => 'Categoria'; + + @override + String get feedbackSelectCategory => 'Selecione uma categoria'; + + @override + String get feedbackCategoryProblem => 'Problema ou erro'; + + @override + String get feedbackCategoryFeature => 'Pedido de funcionalidade'; + + @override + String get feedbackCategoryPrivacySecurity => + 'Questão de privacidade ou segurança'; + + @override + String get feedbackCategoryUsability => 'Problema de utilização'; + + @override + String get feedbackCategoryOther => 'Outro'; + + @override + String get feedbackSubject => 'Assunto'; + + @override + String get feedbackDetailedMessage => 'Mensagem detalhada'; + + @override + String get feedbackReplyEmail => + 'Endereço de e-mail para resposta (opcional)'; + + @override + String get feedbackIncludeTechnicalDetails => 'Incluir detalhes técnicos'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Adiciona apenas a versão do sistema operativo Linux e a configuração regional da aplicação. Não são incluídos registos, dados de contas, nomes de ficheiros nem outros diagnósticos.'; + + @override + String get feedbackCategoryRequired => 'Selecione uma categoria.'; + + @override + String get feedbackSubjectLengthError => + 'O assunto deve ter entre 3 e 120 carateres.'; + + @override + String get feedbackMessageLengthError => + 'A mensagem deve ter entre 10 e 5 000 carateres.'; + + @override + String get feedbackInvalidEmail => 'Introduza um endereço de e-mail válido.'; + + @override + String get feedbackConnectionError => + 'Não foi possível ligar ao BusyStack. Verifique a ligação e tente novamente.'; + + @override + String get feedbackTimeoutError => + 'O pedido excedeu o tempo limite. Os seus comentários não foram apagados; tente novamente.'; + + @override + String get feedbackRateLimitedError => + 'Foram enviados demasiados comentários a partir desta rede. Aguarde e tente novamente.'; + + @override + String get feedbackRejectedError => + 'O servidor rejeitou o envio. Reveja os campos e tente novamente.'; + + @override + String get feedbackServerError => + 'O BusyStack não pode aceitar os seus comentários neste momento. Os seus comentários não foram apagados; tente novamente.'; + + @override + String feedbackSuccess(String id) { + return 'Comentários enviados. Referência: $id'; + } + + @override + String get toggleSidebar => 'Mostrar ou ocultar a barra lateral'; + + @override + String get showSidebar => 'Mostrar painel lateral'; + + @override + String get hideSidebar => 'Ocultar painel lateral'; + + @override + String get accounts => 'Contas'; + + @override + String get currentAccount => 'Conta atual'; + + @override + String get switchAccount => 'Mudar de conta'; + + @override + String get addGoogleAccount => 'Adicionar conta Google'; + + @override + String get addMicrosoftAccount => 'Adicionar conta Microsoft'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'Sessão iniciada'; + + @override + String get removeAccount => 'Remover conta…'; + + @override + String get removingAccount => 'A remover conta…'; + + @override + String get removeAccountDescription => + 'Parar a sincronização e remover os dados desta conta deste dispositivo.'; + + @override + String removeAccountTitle(String account) { + return 'Remover $account do BusyMax?'; + } + + @override + String get removeAccountConfirmation => + 'Esta ação elimina deste dispositivo as tarefas, os calendários, os eventos, os lembretes e as alterações offline pendentes em cache. As alterações não sincronizadas serão perdidas. Nada será eliminado da Google ou da Microsoft.'; + + @override + String get revokeGoogleAccess => + 'Revogar também o acesso do BusyMax a esta conta Google'; + + @override + String get revokeGoogleAccessDescription => + 'Terá de conceder acesso novamente antes de voltar a ligar a conta.'; + + @override + String get removeAccountAction => 'Remover conta'; + + @override + String get removeAccountFailed => + 'Não foi possível concluir a remoção da conta. Tente novamente.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'A conta foi removida deste dispositivo, mas não foi possível revogar o acesso do BusyMax à sua conta Google. Pode revogar esse acesso na sua conta Google.'; + + @override + String get newList => 'Nova lista'; + + @override + String get signInToViewTaskLists => + 'Inicie sessão para ver as listas de tarefas.'; + + @override + String get noTaskListsSynced => + 'Ainda não há listas de tarefas sincronizadas.'; + + @override + String get listActions => 'Ações da lista'; + + @override + String get rename => 'Mudar o nome'; + + @override + String get delete => 'Eliminar'; + + @override + String get renameList => 'Mudar o nome da lista'; + + @override + String get deleteList => 'Eliminar lista'; + + @override + String get builtInMicrosoftList => 'Incorporada'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'As listas incorporadas do Microsoft To Do não podem ser renomeadas nem eliminadas.'; + + @override + String deleteListConfirmation(String title) { + return 'Eliminar «$title» do Google Tasks?'; + } + + @override + String get deleteEvent => 'Eliminar evento'; + + @override + String get title => 'Título'; + + @override + String get create => 'Criar'; + + @override + String get newTask => 'Nova tarefa'; + + @override + String get clearCompleted => 'Limpar tarefas concluídas'; + + @override + String get refreshList => 'Atualizar lista'; + + @override + String get refreshAll => 'Atualizar tudo'; + + @override + String get listRefreshed => 'Lista atualizada.'; + + @override + String get allTasksRefreshed => 'Todas as contas foram atualizadas.'; + + @override + String exportedFile(String path) { + return 'Exportado para $path'; + } + + @override + String exportFailed(String error) { + return 'Falha ao exportar: $error'; + } + + @override + String refreshFailed(String error) { + return 'Falha ao atualizar: $error'; + } + + @override + String get selectOrCreateTaskList => + 'Selecione ou crie uma lista de tarefas para começar.'; + + @override + String get signInToViewTasks => 'Inicie sessão para ver as tarefas.'; + + @override + String get noTasks => 'Sem tarefas.'; + + @override + String get noTasksYet => 'Ainda não há tarefas'; + + @override + String get noTasksYetMessage => + 'Crie uma tarefa ou atualize as suas contas para começar.'; + + @override + String get noTasksInList => 'Não há tarefas nesta lista.'; + + @override + String get overdue => 'Em atraso'; + + @override + String get today => 'Hoje'; + + @override + String get tomorrow => 'Amanhã'; + + @override + String get upcoming => 'Próximas'; + + @override + String get noDate => 'Sem data'; + + @override + String get completed => 'Concluídas'; + + @override + String duePrefix(String date) { + return 'Prazo: $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date, $time'; + } + + @override + String get taskDetails => 'Detalhes da tarefa'; + + @override + String get editTask => 'Editar tarefa'; + + @override + String get noTaskSelected => 'Nenhuma tarefa selecionada.'; + + @override + String get noTaskSelectedHelper => + 'Selecione uma tarefa para ver e editar os detalhes.'; + + @override + String get taskUnavailable => 'Tarefa indisponível.'; + + @override + String get signInToEditTasks => 'Inicie sessão para editar tarefas.'; + + @override + String get refreshTask => 'Atualizar tarefa'; + + @override + String get primarySection => 'Principal'; + + @override + String get statusSection => 'Estado'; + + @override + String get openStatus => 'Aberta'; + + @override + String get doneStatus => 'Concluída'; + + @override + String get notes => 'Notas'; + + @override + String get dueDate => 'Data limite'; + + @override + String get clearDueDate => 'Limpar data limite'; + + @override + String get dueTime => 'Hora limite'; + + @override + String get startDate => 'Data de início'; + + @override + String get startTime => 'Hora de início'; + + @override + String get endDate => 'Data de fim'; + + @override + String get endTime => 'Hora de fim'; + + @override + String get reminderDate => 'Data do lembrete'; + + @override + String get reminderTime => 'Hora do lembrete'; + + @override + String get reminder => 'Lembrete'; + + @override + String get addReminder => 'Adicionar lembrete'; + + @override + String get addGuest => 'Adicionar convidado'; + + @override + String get addGuestEmail => 'Adicionar e-mail do convidado'; + + @override + String get removeReminder => 'Remover lembrete'; + + @override + String get off => 'Desativado'; + + @override + String get repeat => 'Repetição'; + + @override + String get repeatNone => 'Não repetir'; + + @override + String get noneValue => 'Nenhum'; + + @override + String get repeatDaily => 'Diariamente'; + + @override + String get repeatWeekly => 'Semanalmente'; + + @override + String get repeatMonthly => 'Mensalmente'; + + @override + String get repeatYearly => 'Anualmente'; + + @override + String get importance => 'Importância'; + + @override + String get importanceLow => 'Baixa'; + + @override + String get importanceNormal => 'Normal'; + + @override + String get importanceHigh => 'Alta'; + + @override + String get categories => 'Categorias'; + + @override + String get scheduleSection => 'Agenda'; + + @override + String get dueGroup => 'Prazo'; + + @override + String get startGroup => 'Início'; + + @override + String get reminderGroup => 'Lembrete'; + + @override + String get organizationSection => 'Organização'; + + @override + String get actionsSection => 'Ações'; + + @override + String get advancedSection => 'Avançado'; + + @override + String get addCategory => 'Adicionar categoria'; + + @override + String get list => 'Lista'; + + @override + String get microsoftMoveUnsupported => + 'Nesta versão, não é possível mover tarefas entre listas em contas Microsoft To Do.'; + + @override + String get createSubtask => 'Criar subtarefa'; + + @override + String get moveToTop => 'Mover para o início'; + + @override + String get deleteTask => 'Eliminar tarefa'; + + @override + String get newSubtask => 'Nova subtarefa'; + + @override + String deleteTaskConfirmation(String title) { + return 'Eliminar «$title» do Google Tasks?'; + } + + @override + String get metadata => 'Metadados'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'Atualizado'; + + @override + String get parent => 'Tarefa principal'; + + @override + String get position => 'Posição'; + + @override + String get webLink => 'Ligação Web'; + + @override + String get assignment => 'Atribuição'; + + @override + String get localState => 'Estado local'; + + @override + String get pendingSync => 'Sincronização pendente'; + + @override + String get synced => 'Sincronizado'; + + @override + String get account => 'Conta'; + + @override + String get sync => 'Sincronização'; + + @override + String get manualFullSync => 'Sincronização completa manual'; + + @override + String get runInBackgroundWhenClosed => + 'Continuar em execução quando a janela for fechada'; + + @override + String get showTrayIcon => 'Mostrar ícone na área de notificação'; + + @override + String get startMinimizedToTray => + 'Iniciar minimizado na área de notificação'; + + @override + String get requiresTrayIcon => 'Requer o ícone da área de notificação.'; + + @override + String get syncComplete => 'Sincronização concluída.'; + + @override + String syncFailed(String error) { + return 'Falha na sincronização: $error'; + } + + @override + String get notifySyncFailures => 'Notificações de falhas de sincronização'; + + @override + String get notifyConflicts => 'Notificações de conflitos'; + + @override + String get notifyDueToday => 'Notificações de tarefas com prazo para hoje'; + + @override + String get eventReminders => 'Lembretes de eventos'; + + @override + String get taskReminders => 'Lembretes de tarefas'; + + @override + String get notificationDetailLevel => 'Nível de detalhe das notificações'; + + @override + String get notificationDetailPrivate => 'Privado'; + + @override + String get notificationDetailNormal => 'Normal'; + + @override + String get quietHours => 'Período de silêncio'; + + @override + String get quietHoursDescription => + 'Pausar as notificações durante este período.'; + + @override + String get quietHoursStart => 'Início do período de silêncio'; + + @override + String get quietHoursEnd => 'Fim do período de silêncio'; + + @override + String get notifications => 'Notificações'; + + @override + String get appearance => 'Aspeto'; + + @override + String get theme => 'Tema'; + + @override + String get themeSystem => 'Sistema'; + + @override + String get themeLight => 'Claro'; + + @override + String get themeDark => 'Escuro'; + + @override + String get themeFamily => 'Família de temas'; + + @override + String get themeFamilyYaru => 'Tema nativo do Ubuntu (Yaru)'; + + @override + String get localization => 'Localização'; + + @override + String get currentLocale => 'Configuração regional atual'; + + @override + String get privacy => 'Privacidade'; + + @override + String get redactTaskContentInDiagnostics => + 'Ocultar o conteúdo das tarefas nos diagnósticos'; + + @override + String get developerDiagnostics => 'Diagnósticos de programador'; + + @override + String get diagnostics => 'Diagnósticos'; + + @override + String get apiInspectorDisabled => 'Mostrar inspetor da API'; + + @override + String get googleTasksApi => 'API do Google Tasks'; + + @override + String discoveryRevision(String revision) { + return 'Revisão de descoberta: $revision'; + } + + @override + String get implementedMethods => 'Métodos implementados'; + + @override + String get supportsTasksScopes => + 'Suporta os âmbitos de autorização tasks e tasks.readonly'; + + @override + String get requiresTasksScope => 'Requer o âmbito de autorização tasks'; + + @override + String get blockedPendingOperations => 'Operações pendentes bloqueadas'; + + @override + String get signInToInspectPendingOperations => + 'Inicie sessão para inspecionar as operações pendentes.'; + + @override + String get noBlockedPendingOperations => + 'Não há operações pendentes bloqueadas.'; + + @override + String get operationActions => 'Ações da operação'; + + @override + String pendingOpListId(String id) { + return 'lista=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'tarefa=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'tentativas=$count'; + } + + @override + String get retry => 'Tentar novamente'; + + @override + String get discard => 'Descartar'; + + @override + String get discardChangesAction => 'Descartar'; + + @override + String get discardChanges => 'Descartar alterações?'; + + @override + String get discardChangesConfirmation => + 'Esta ação descarta as alterações não guardadas nesta tarefa.'; + + @override + String get retryCompleted => 'Nova tentativa concluída.'; + + @override + String get discardPendingOperation => 'Descartar operação pendente?'; + + @override + String get discardPendingOperationConfirmation => + 'Esta ação remove a operação local bloqueada. Na próxima sincronização, os dados serão novamente carregados do Google Tasks.'; + + @override + String get pendingOperationDiscarded => 'Operação pendente descartada.'; + + @override + String get syncFailureNotificationTitle => + 'Falha na sincronização do BusyMax'; + + @override + String syncFailureNotificationBody(String message) { + return 'A sincronização em segundo plano falhou. $message'; + } + + @override + String get conflictNotificationTitle => + 'Conflito de sincronização do BusyMax'; + + @override + String conflictNotificationBody(String summary) { + return 'Uma alteração local pendente foi bloqueada. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'Tarefas com prazo para hoje'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Há $count tarefas com prazo para hoje.', + one: 'Há uma tarefa com prazo para hoje.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'Lembrete de evento'; + + @override + String get taskReminderNotificationTitle => 'Lembrete de tarefa'; + + @override + String get eventReminderNotificationBody => 'O evento começa em breve.'; + + @override + String get taskReminderNotificationBody => 'O prazo da tarefa aproxima-se.'; + + @override + String get notificationOpenAction => 'Abrir'; + + @override + String get notificationDetailsHidden => + 'Os detalhes estão ocultos pelas definições de privacidade.'; + + @override + String get previousMonth => 'Mês anterior'; + + @override + String get nextMonth => 'Mês seguinte'; + + @override + String get openMonthView => 'Abrir vista mensal'; + + @override + String get previousYear => 'Ano anterior'; + + @override + String get nextYear => 'Ano seguinte'; + + @override + String get openYearView => 'Abrir vista anual'; + + @override + String weekNumberTooltip(int number) { + return 'Semana $number'; + } + + @override + String get resizeAllDayPanel => 'Redimensionar o painel de dia inteiro'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count itens', + one: '1 item', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Este calendário é só de leitura.'; + + @override + String get selectTimeZone => 'Selecionar fuso horário'; + + @override + String get searchLocations => 'Pesquisar locais'; + + @override + String get noLocationsFound => 'Nenhum local encontrado'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Eliminar «$title»?'; + } +} diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart new file mode 100644 index 0000000..728ec35 --- /dev/null +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -0,0 +1,1263 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Russian (`ru`). +class AppLocalizationsRu extends AppLocalizations { + AppLocalizationsRu([String locale = 'ru']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Подключите аккаунты Google и Microsoft, чтобы синхронизировать календари и задачи.'; + + @override + String get googlePermissionsConsentNotice => + 'На экране запроса доступа Google установите флажки «Google Календарь» и «Google Задачи».'; + + @override + String get googlePermissionsRequiredRetry => + 'BusyMax требуется доступ к Google Календарю и Google Задачам. Повторите попытку и установите оба флажка.'; + + @override + String get finishSetup => 'Завершить настройку'; + + @override + String get continueSetup => 'Продолжить'; + + @override + String get onboardingSetupTitle => 'Настройка BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'Подключите аккаунты'; + + @override + String get onboardingAccountsStepDescription => + 'Добавьте все аккаунты Google и Microsoft, которые хотите использовать. BusyMax синхронизирует календари, события, списки задач и задачи из каждого аккаунта.'; + + @override + String get onboardingPreferencesStepTitle => 'Выберите системные параметры'; + + @override + String get onboardingPreferencesStepDescription => + 'Настройте поведение приложения на рабочем столе, напоминания, уровень детализации уведомлений и внешний вид, прежде чем открыть расписание.'; + + @override + String get signInWithGoogle => 'Войти через Google'; + + @override + String get signInWithMicrosoft => 'Войти через Microsoft'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'Этот сервис не настроен.'; + + @override + String get waitingForGoogleSignIn => 'Ожидание входа через Google...'; + + @override + String get waitingForMicrosoftSignIn => 'Ожидание входа через Microsoft...'; + + @override + String get microsoftSignInNotConfigured => + 'Вход через Microsoft не настроен. Задайте MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'Отмена'; + + @override + String get close => 'Закрыть'; + + @override + String get exit => 'Выйти'; + + @override + String get options => 'Параметры'; + + @override + String get hide => 'Скрыть'; + + @override + String get show => 'Показать'; + + @override + String get export => 'Экспортировать'; + + @override + String get save => 'Сохранить'; + + @override + String get settings => 'Настройки'; + + @override + String get all => 'Все'; + + @override + String get calendarEvents => 'События'; + + @override + String get calendarTasks => 'Задачи'; + + @override + String get calendar => 'Календарь'; + + @override + String get calendars => 'Календари'; + + @override + String get newEvent => 'Новое событие'; + + @override + String get refreshCalendar => 'Обновить календарь'; + + @override + String get openInProvider => 'Открыть в сервисе'; + + @override + String get hideFromSchedule => 'Скрыть из расписания'; + + @override + String get showInSchedule => 'Показывать в расписании'; + + @override + String get noCalendarsSynced => 'Синхронизированных календарей пока нет.'; + + @override + String get allDay => 'Весь день'; + + @override + String moreItems(int count) { + return '+ ещё $count'; + } + + @override + String get noEventsOrTasks => 'Нет событий или задач'; + + @override + String get scheduleLoading => 'Загрузка расписания...'; + + @override + String get scheduleUnavailable => 'Расписание недоступно'; + + @override + String get scheduleNoSources => 'Нет видимых календарей или списков задач'; + + @override + String get scheduleNoSourcesDescription => + 'Выберите в настройках, что нужно показывать, а затем обновите расписание.'; + + @override + String get scheduleSignInRequired => 'Подключите аккаунт'; + + @override + String get scheduleSignInDescription => + 'Войдите, чтобы синхронизировать календари и задачи.'; + + @override + String get scheduleNoSearchResults => 'Подходящих событий или задач нет'; + + @override + String get scheduleNoSearchResultsDescription => + 'Попробуйте изменить запрос или сбросить текущие фильтры.'; + + @override + String get refresh => 'Обновить'; + + @override + String get trayOpenBusyMax => 'Открыть BusyMax'; + + @override + String get agendaLoadMoreOverdue => 'Загрузить ещё просроченные задачи'; + + @override + String get agendaLoadMoreNoDate => 'Загрузить ещё задачи без даты'; + + @override + String get viewDay => 'День'; + + @override + String get viewWeek => 'Неделя'; + + @override + String get viewMonth => 'Месяц'; + + @override + String get viewYear => 'Год'; + + @override + String get viewAgenda => 'Расписание'; + + @override + String get scheduleSettings => 'Расписание'; + + @override + String get scheduleDisplaySettings => 'Отображение расписания'; + + @override + String get scheduleDisplayHoursDescription => + 'В представлениях дня и недели изначально отображается этот период. Более ранние или поздние записи при необходимости расширяют его.'; + + @override + String get scheduleDayStartsAt => 'Начало дня'; + + @override + String get scheduleDayEndsAt => 'Конец дня'; + + @override + String get sourceCalendar => 'Календарь'; + + @override + String get sourceTaskList => 'Список задач'; + + @override + String get createChoiceTitle => 'Создать'; + + @override + String get createEventAtTime => 'Событие'; + + @override + String get createTaskAtDate => 'Задача'; + + @override + String get editEvent => 'Изменить событие'; + + @override + String get eventTitle => 'Название события'; + + @override + String get location => 'Место'; + + @override + String get timeSlot => 'Интервал времени'; + + @override + String get startDateTime => 'Дата и время начала'; + + @override + String get endDateTime => 'Дата и время окончания'; + + @override + String get doesNotRepeat => 'Не повторяется'; + + @override + String get defaultReminder => 'Напоминание по умолчанию'; + + @override + String get guests => 'Гости'; + + @override + String get noGuests => 'Нет гостей'; + + @override + String get description => 'Описание'; + + @override + String get availabilityShowAs => 'Показывать как'; + + @override + String get busy => 'Занят'; + + @override + String get visibility => 'Видимость'; + + @override + String get defaultVisibility => 'Видимость по умолчанию'; + + @override + String get conference => 'Конференция'; + + @override + String get noConference => 'Без конференции'; + + @override + String get providerCalendar => 'Календарь сервиса'; + + @override + String get formatBoldShortLabel => 'Ж'; + + @override + String get formatBoldTooltip => 'Полужирный'; + + @override + String get formatItalicShortLabel => 'К'; + + @override + String get formatItalicTooltip => 'Курсив'; + + @override + String get formatUnderlineShortLabel => 'Ч'; + + @override + String get formatUnderlineTooltip => 'Подчёркнутый'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: 'За $minutes минуты', + many: 'За $minutes минут', + few: 'За $minutes минуты', + one: 'За $minutes минуту', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'В момент начала'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: 'За $hours часа', + many: 'За $hours часов', + few: 'За $hours часа', + one: 'За $hours час', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: 'За $days дня', + many: 'За $days дней', + few: 'За $days дня', + one: 'За $days день', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Свободен'; + + @override + String get availabilityTentative => 'Под вопросом'; + + @override + String get availabilityOutOfOffice => 'Нет на рабочем месте'; + + @override + String get availabilityWorkingElsewhere => 'Работа в другом месте'; + + @override + String get visibilityDefault => 'По умолчанию'; + + @override + String get visibilityPublic => 'Общедоступное'; + + @override + String get visibilityPrivate => 'Личное'; + + @override + String get visibilityConfidential => 'Конфиденциальное'; + + @override + String get sensitivityNormal => 'Обычная'; + + @override + String get sensitivityPersonal => 'Личная'; + + @override + String get tasks => 'Задачи'; + + @override + String get allTasks => 'Все задачи'; + + @override + String tasksInList(String title) { + return 'Задачи в списке «$title»'; + } + + @override + String get taskLists => 'Списки задач'; + + @override + String get navigation => 'Навигация'; + + @override + String get mainMenu => 'Главное меню'; + + @override + String get keyboardShortcuts => 'Сочетания клавиш'; + + @override + String get shortcutGroupGeneral => 'Общие'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'Показать эту справку по сочетаниям клавиш'; + + @override + String get shortcutGroupNavigation => 'Навигация'; + + @override + String get shortcutNextPeriod => 'Следующий период'; + + @override + String get shortcutNextPeriodDescription => + 'Следующая неделя в представлении недели, следующий месяц в представлении месяца и так далее'; + + @override + String get shortcutPreviousPeriod => 'Предыдущий период'; + + @override + String get shortcutPreviousPeriodDescription => + 'Предыдущая неделя в представлении недели, предыдущий месяц в представлении месяца и так далее'; + + @override + String get shortcutJumpToToday => 'Перейти к сегодняшнему дню'; + + @override + String get shortcutGroupView => 'Представление'; + + @override + String get shortcutDayView => 'Представление дня'; + + @override + String get shortcutWeekView => 'Представление недели'; + + @override + String get shortcutMonthView => 'Представление месяца'; + + @override + String get shortcutYearView => 'Представление года'; + + @override + String get shortcutAgendaView => 'Расписание'; + + @override + String get shortcutGroupCreateAndEdit => 'Создание и редактирование'; + + @override + String get shortcutSaveItem => 'Сохранить событие или задачу'; + + @override + String get shortcutDeleteItem => 'Удалить событие или задачу'; + + @override + String get shortcutGroupTaskEditing => 'Редактирование задач'; + + @override + String get shortcutCancelEditing => 'Отменить редактирование'; + + @override + String get shortcutCancelEditingDescription => + 'Выйти из режима редактирования задачи или закрыть сведения о ней'; + + @override + String get aboutBusyMax => 'О приложении BusyMax'; + + @override + String get aboutBusyMaxDescription => 'Календарь и задачи'; + + @override + String get license => 'Лицензия'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => 'Веб-сайт'; + + @override + String get sourceCode => 'Исходный код'; + + @override + String get reportAnIssue => 'Сообщить о проблеме'; + + @override + String get sendFeedback => 'Отправить отзыв'; + + @override + String get feedbackSubmit => 'Отправить'; + + @override + String get feedbackCategory => 'Категория'; + + @override + String get feedbackSelectCategory => 'Выберите категорию'; + + @override + String get feedbackCategoryProblem => 'Проблема или ошибка'; + + @override + String get feedbackCategoryFeature => 'Запрос функции'; + + @override + String get feedbackCategoryPrivacySecurity => + 'Проблема конфиденциальности или безопасности'; + + @override + String get feedbackCategoryUsability => 'Проблема удобства использования'; + + @override + String get feedbackCategoryOther => 'Другое'; + + @override + String get feedbackSubject => 'Тема'; + + @override + String get feedbackDetailedMessage => 'Подробное сообщение'; + + @override + String get feedbackReplyEmail => + 'Адрес электронной почты для ответа (необязательно)'; + + @override + String get feedbackIncludeTechnicalDetails => 'Включить технические сведения'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Будут добавлены только версия Linux и выбранный язык приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не добавляются.'; + + @override + String get feedbackCategoryRequired => 'Выберите категорию.'; + + @override + String get feedbackSubjectLengthError => + 'Тема должна содержать от 3 до 120 символов.'; + + @override + String get feedbackMessageLengthError => + 'Сообщение должно содержать от 10 до 5 000 символов.'; + + @override + String get feedbackInvalidEmail => + 'Введите действительный адрес электронной почты.'; + + @override + String get feedbackConnectionError => + 'Не удалось подключиться к BusyStack. Проверьте подключение и повторите попытку.'; + + @override + String get feedbackTimeoutError => + 'Время ожидания запроса истекло. Текст отзыва сохранён. Повторите попытку.'; + + @override + String get feedbackRateLimitedError => + 'Из этой сети было отправлено слишком много отзывов. Подождите и повторите попытку.'; + + @override + String get feedbackRejectedError => + 'Сервер отклонил отправку. Проверьте поля и повторите попытку.'; + + @override + String get feedbackServerError => + 'BusyStack сейчас не может принять ваш отзыв. Текст отзыва сохранён. Повторите попытку.'; + + @override + String feedbackSuccess(String id) { + return 'Отзыв отправлен. Номер: $id'; + } + + @override + String get toggleSidebar => 'Показать или скрыть боковую панель'; + + @override + String get showSidebar => 'Показать боковую панель'; + + @override + String get hideSidebar => 'Скрыть боковую панель'; + + @override + String get accounts => 'Аккаунты'; + + @override + String get currentAccount => 'Текущий аккаунт'; + + @override + String get switchAccount => 'Сменить аккаунт'; + + @override + String get addGoogleAccount => 'Добавить аккаунт Google'; + + @override + String get addMicrosoftAccount => 'Добавить аккаунт Microsoft'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'Выполнен вход'; + + @override + String get removeAccount => 'Удалить аккаунт…'; + + @override + String get removingAccount => 'Удаление аккаунта…'; + + @override + String get removeAccountDescription => + 'Остановить синхронизацию и удалить данные этого аккаунта с устройства.'; + + @override + String removeAccountTitle(String account) { + return 'Удалить $account из BusyMax?'; + } + + @override + String get removeAccountConfirmation => + 'С этого устройства будут удалены кэшированные задачи, календари, события, напоминания и локальные изменения, ожидающие синхронизации. Несинхронизированные изменения будут потеряны. В Google и Microsoft ничего не будет удалено.'; + + @override + String get revokeGoogleAccess => + 'Также отозвать у BusyMax доступ к этому аккаунту Google'; + + @override + String get revokeGoogleAccessDescription => + 'Перед повторным подключением аккаунта потребуется снова предоставить доступ.'; + + @override + String get removeAccountAction => 'Удалить аккаунт'; + + @override + String get removeAccountFailed => + 'Не удалось завершить удаление аккаунта. Повторите попытку.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'Аккаунт удалён с этого устройства, но отозвать доступ BusyMax к Google не удалось. Вы можете отозвать доступ в аккаунте Google.'; + + @override + String get newList => 'Новый список'; + + @override + String get signInToViewTaskLists => + 'Войдите, чтобы просмотреть списки задач.'; + + @override + String get noTaskListsSynced => 'Синхронизированных списков задач пока нет.'; + + @override + String get listActions => 'Действия со списком'; + + @override + String get rename => 'Переименовать'; + + @override + String get delete => 'Удалить'; + + @override + String get renameList => 'Переименовать список'; + + @override + String get deleteList => 'Удалить список'; + + @override + String get builtInMicrosoftList => 'Встроенный'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Встроенные списки Microsoft To Do нельзя переименовывать или удалять.'; + + @override + String deleteListConfirmation(String title) { + return 'Удалить «$title» из Google Tasks?'; + } + + @override + String get deleteEvent => 'Удалить событие'; + + @override + String get title => 'Название'; + + @override + String get create => 'Создать'; + + @override + String get newTask => 'Новая задача'; + + @override + String get clearCompleted => 'Удалить выполненные'; + + @override + String get refreshList => 'Обновить список'; + + @override + String get refreshAll => 'Обновить всё'; + + @override + String get listRefreshed => 'Список обновлён.'; + + @override + String get allTasksRefreshed => 'Все аккаунты обновлены.'; + + @override + String exportedFile(String path) { + return 'Экспортировано в $path'; + } + + @override + String exportFailed(String error) { + return 'Не удалось экспортировать: $error'; + } + + @override + String refreshFailed(String error) { + return 'Не удалось обновить: $error'; + } + + @override + String get selectOrCreateTaskList => + 'Сначала выберите или создайте список задач.'; + + @override + String get signInToViewTasks => 'Войдите, чтобы просмотреть задачи.'; + + @override + String get noTasks => 'Задач нет.'; + + @override + String get noTasksYet => 'Задач пока нет'; + + @override + String get noTasksYetMessage => + 'Создайте задачу или обновите аккаунты, чтобы начать.'; + + @override + String get noTasksInList => 'В этом списке нет задач.'; + + @override + String get overdue => 'Просроченные'; + + @override + String get today => 'Сегодня'; + + @override + String get tomorrow => 'Завтра'; + + @override + String get upcoming => 'Предстоящие'; + + @override + String get noDate => 'Без даты'; + + @override + String get completed => 'Выполненные'; + + @override + String duePrefix(String date) { + return 'Срок: $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date, $time'; + } + + @override + String get taskDetails => 'Сведения о задаче'; + + @override + String get editTask => 'Изменить задачу'; + + @override + String get noTaskSelected => 'Задача не выбрана.'; + + @override + String get noTaskSelectedHelper => + 'Выберите задачу, чтобы просмотреть и изменить её сведения.'; + + @override + String get taskUnavailable => 'Задача недоступна.'; + + @override + String get signInToEditTasks => 'Войдите, чтобы изменять задачи.'; + + @override + String get refreshTask => 'Обновить задачу'; + + @override + String get primarySection => 'Основные сведения'; + + @override + String get statusSection => 'Состояние'; + + @override + String get openStatus => 'Открыта'; + + @override + String get doneStatus => 'Выполнена'; + + @override + String get notes => 'Заметки'; + + @override + String get dueDate => 'Срок'; + + @override + String get clearDueDate => 'Удалить срок выполнения'; + + @override + String get dueTime => 'Время выполнения'; + + @override + String get startDate => 'Дата начала'; + + @override + String get startTime => 'Время начала'; + + @override + String get endDate => 'Дата окончания'; + + @override + String get endTime => 'Время окончания'; + + @override + String get reminderDate => 'Дата напоминания'; + + @override + String get reminderTime => 'Время напоминания'; + + @override + String get reminder => 'Напоминание'; + + @override + String get addReminder => 'Добавить напоминание'; + + @override + String get addGuest => 'Добавить гостя'; + + @override + String get addGuestEmail => 'Добавить адрес электронной почты гостя'; + + @override + String get removeReminder => 'Удалить напоминание'; + + @override + String get off => 'Выкл.'; + + @override + String get repeat => 'Повтор'; + + @override + String get repeatNone => 'Не повторять'; + + @override + String get noneValue => 'Нет'; + + @override + String get repeatDaily => 'Ежедневно'; + + @override + String get repeatWeekly => 'Еженедельно'; + + @override + String get repeatMonthly => 'Ежемесячно'; + + @override + String get repeatYearly => 'Ежегодно'; + + @override + String get importance => 'Важность'; + + @override + String get importanceLow => 'Низкая'; + + @override + String get importanceNormal => 'Обычная'; + + @override + String get importanceHigh => 'Высокая'; + + @override + String get categories => 'Категории'; + + @override + String get scheduleSection => 'Расписание'; + + @override + String get dueGroup => 'Срок'; + + @override + String get startGroup => 'Начало'; + + @override + String get reminderGroup => 'Напоминание'; + + @override + String get organizationSection => 'Организация'; + + @override + String get actionsSection => 'Действия'; + + @override + String get advancedSection => 'Дополнительно'; + + @override + String get addCategory => 'Добавить категорию'; + + @override + String get list => 'Список'; + + @override + String get microsoftMoveUnsupported => + 'В этой версии перенос между списками для аккаунтов Microsoft To Do не поддерживается.'; + + @override + String get createSubtask => 'Создать подзадачу'; + + @override + String get moveToTop => 'Переместить в самый верх'; + + @override + String get deleteTask => 'Удалить задачу'; + + @override + String get newSubtask => 'Новая подзадача'; + + @override + String deleteTaskConfirmation(String title) { + return 'Удалить «$title» из Google Tasks?'; + } + + @override + String get metadata => 'Метаданные'; + + @override + String get id => 'Идентификатор'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'Обновлено'; + + @override + String get parent => 'Родительская задача'; + + @override + String get position => 'Позиция'; + + @override + String get webLink => 'Веб-ссылка'; + + @override + String get assignment => 'Назначение'; + + @override + String get localState => 'Локальное состояние'; + + @override + String get pendingSync => 'Ожидает синхронизации'; + + @override + String get synced => 'Синхронизировано'; + + @override + String get account => 'Аккаунт'; + + @override + String get sync => 'Синхронизация'; + + @override + String get manualFullSync => 'Полная синхронизация вручную'; + + @override + String get runInBackgroundWhenClosed => + 'Продолжать работу после закрытия окна'; + + @override + String get showTrayIcon => 'Показывать значок в области уведомлений'; + + @override + String get startMinimizedToTray => + 'Запускать свёрнутым в область уведомлений'; + + @override + String get requiresTrayIcon => 'Требуется значок в области уведомлений.'; + + @override + String get syncComplete => 'Синхронизация завершена.'; + + @override + String syncFailed(String error) { + return 'Синхронизация не удалась: $error'; + } + + @override + String get notifySyncFailures => 'Уведомлять об ошибках синхронизации'; + + @override + String get notifyConflicts => 'Уведомлять о конфликтах'; + + @override + String get notifyDueToday => 'Уведомлять о задачах на сегодня'; + + @override + String get eventReminders => 'Напоминания о событиях'; + + @override + String get taskReminders => 'Напоминания о задачах'; + + @override + String get notificationDetailLevel => 'Содержимое уведомлений'; + + @override + String get notificationDetailPrivate => 'Скрывать подробности'; + + @override + String get notificationDetailNormal => 'Показывать подробности'; + + @override + String get quietHours => 'Период без уведомлений'; + + @override + String get quietHoursDescription => 'Не показывать уведомления в это время.'; + + @override + String get quietHoursStart => 'Начало периода'; + + @override + String get quietHoursEnd => 'Конец периода'; + + @override + String get notifications => 'Уведомления'; + + @override + String get appearance => 'Внешний вид'; + + @override + String get theme => 'Тема'; + + @override + String get themeSystem => 'Системная'; + + @override + String get themeLight => 'Светлая'; + + @override + String get themeDark => 'Тёмная'; + + @override + String get themeFamily => 'Семейство тем'; + + @override + String get themeFamilyYaru => 'Стандартная тема Ubuntu (Yaru)'; + + @override + String get localization => 'Язык'; + + @override + String get currentLocale => 'Язык приложения'; + + @override + String get privacy => 'Конфиденциальность'; + + @override + String get redactTaskContentInDiagnostics => + 'Скрывать содержимое задач в диагностике'; + + @override + String get developerDiagnostics => 'Диагностика для разработчиков'; + + @override + String get diagnostics => 'Диагностика'; + + @override + String get apiInspectorDisabled => 'Показать инспектор API'; + + @override + String get googleTasksApi => 'API Google Tasks'; + + @override + String discoveryRevision(String revision) { + return 'Версия Discovery: $revision'; + } + + @override + String get implementedMethods => 'Реализованные методы'; + + @override + String get supportsTasksScopes => + 'Поддерживает области разрешений tasks и tasks.readonly'; + + @override + String get requiresTasksScope => 'Требуется область разрешений tasks'; + + @override + String get blockedPendingOperations => 'Заблокированные операции'; + + @override + String get signInToInspectPendingOperations => + 'Войдите, чтобы просмотреть ожидающие операции.'; + + @override + String get noBlockedPendingOperations => 'Заблокированных операций нет.'; + + @override + String get operationActions => 'Действия с операцией'; + + @override + String pendingOpListId(String id) { + return 'список=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'задача=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'попытки=$count'; + } + + @override + String get retry => 'Повторить'; + + @override + String get discard => 'Отменить'; + + @override + String get discardChangesAction => 'Не сохранять'; + + @override + String get discardChanges => 'Не сохранять изменения?'; + + @override + String get discardChangesConfirmation => + 'Несохранённые изменения этой задачи будут потеряны.'; + + @override + String get retryCompleted => 'Повторная попытка завершена.'; + + @override + String get discardPendingOperation => 'Удалить заблокированную операцию?'; + + @override + String get discardPendingOperationConfirmation => + 'Заблокированная локальная операция будет удалена. При следующей синхронизации данные будут заново загружены из Google Tasks.'; + + @override + String get pendingOperationDiscarded => 'Заблокированная операция удалена.'; + + @override + String get syncFailureNotificationTitle => 'Сбой синхронизации BusyMax'; + + @override + String syncFailureNotificationBody(String message) { + return 'Сбой фоновой синхронизации. $message'; + } + + @override + String get conflictNotificationTitle => 'Конфликт синхронизации BusyMax'; + + @override + String conflictNotificationBody(String summary) { + return 'Локальное изменение не удалось синхронизировать. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'Задачи на сегодня'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Сегодня нужно выполнить $count задачи.', + many: 'Сегодня нужно выполнить $count задач.', + few: 'Сегодня нужно выполнить $count задачи.', + one: 'Сегодня нужно выполнить $count задачу.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'Напоминание о событии'; + + @override + String get taskReminderNotificationTitle => 'Напоминание о задаче'; + + @override + String get eventReminderNotificationBody => 'Событие скоро начнётся.'; + + @override + String get taskReminderNotificationBody => + 'Срок выполнения задачи скоро наступит.'; + + @override + String get notificationOpenAction => 'Открыть'; + + @override + String get notificationDetailsHidden => + 'Сведения скрыты настройками конфиденциальности.'; + + @override + String get previousMonth => 'Предыдущий месяц'; + + @override + String get nextMonth => 'Следующий месяц'; + + @override + String get openMonthView => 'Открыть представление месяца'; + + @override + String get previousYear => 'Предыдущий год'; + + @override + String get nextYear => 'Следующий год'; + + @override + String get openYearView => 'Открыть представление года'; + + @override + String weekNumberTooltip(int number) { + return 'Неделя $number'; + } + + @override + String get resizeAllDayPanel => 'Изменить размер панели «Весь день»'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count записи', + many: '$count записей', + few: '$count записи', + one: '$count запись', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Этот календарь доступен только для чтения.'; + + @override + String get selectTimeZone => 'Выберите часовой пояс'; + + @override + String get searchLocations => 'Поиск города'; + + @override + String get noLocationsFound => 'Ничего не найдено'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Удалить «$title»?'; + } +} diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart new file mode 100644 index 0000000..0abb69c --- /dev/null +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -0,0 +1,1251 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Vietnamese (`vi`). +class AppLocalizationsVi extends AppLocalizations { + AppLocalizationsVi([String locale = 'vi']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Kết nối tài khoản Google và Microsoft để đồng bộ lịch và công việc.'; + + @override + String get googlePermissionsConsentNotice => + 'Trên màn hình cấp quyền của Google, hãy chọn cả quyền truy cập Lịch và Công việc.'; + + @override + String get googlePermissionsRequiredRetry => + 'Cần có quyền truy cập Google Calendar và Google Tasks. Vui lòng thử lại và chọn cả hai hộp kiểm.'; + + @override + String get finishSetup => 'Hoàn tất thiết lập'; + + @override + String get continueSetup => 'Tiếp tục'; + + @override + String get onboardingSetupTitle => 'Thiết lập BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'Kết nối tài khoản'; + + @override + String get onboardingAccountsStepDescription => + 'Thêm tất cả tài khoản Google và Microsoft bạn muốn sử dụng. BusyMax đồng bộ lịch, sự kiện, danh sách công việc và công việc từ mỗi tài khoản.'; + + @override + String get onboardingPreferencesStepTitle => 'Chọn cài đặt hệ thống'; + + @override + String get onboardingPreferencesStepDescription => + 'Thiết lập cách ứng dụng hoạt động trên máy tính, lời nhắc, mức độ chi tiết của thông báo và giao diện trước khi mở lịch biểu.'; + + @override + String get signInWithGoogle => 'Đăng nhập bằng Google'; + + @override + String get signInWithMicrosoft => 'Đăng nhập bằng Microsoft'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'Dịch vụ này chưa được cấu hình.'; + + @override + String get waitingForGoogleSignIn => 'Đang chờ đăng nhập Google...'; + + @override + String get waitingForMicrosoftSignIn => 'Đang chờ đăng nhập Microsoft...'; + + @override + String get microsoftSignInNotConfigured => + 'Tính năng đăng nhập Microsoft chưa được cấu hình. Hãy đặt MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'Hủy'; + + @override + String get close => 'Đóng'; + + @override + String get exit => 'Thoát'; + + @override + String get options => 'Tùy chọn'; + + @override + String get hide => 'Ẩn'; + + @override + String get show => 'Hiện'; + + @override + String get export => 'Xuất'; + + @override + String get save => 'Lưu'; + + @override + String get settings => 'Cài đặt'; + + @override + String get all => 'Tất cả'; + + @override + String get calendarEvents => 'Sự kiện'; + + @override + String get calendarTasks => 'Công việc'; + + @override + String get calendar => 'Lịch'; + + @override + String get calendars => 'Lịch'; + + @override + String get newEvent => 'Sự kiện mới'; + + @override + String get refreshCalendar => 'Làm mới lịch'; + + @override + String get openInProvider => 'Mở trong dịch vụ'; + + @override + String get hideFromSchedule => 'Ẩn khỏi lịch biểu'; + + @override + String get showInSchedule => 'Hiện trong lịch biểu'; + + @override + String get noCalendarsSynced => 'Chưa có lịch nào được đồng bộ.'; + + @override + String get allDay => 'Cả ngày'; + + @override + String moreItems(int count) { + return '+$count mục khác'; + } + + @override + String get noEventsOrTasks => 'Không có sự kiện hoặc công việc'; + + @override + String get scheduleLoading => 'Đang tải lịch biểu...'; + + @override + String get scheduleUnavailable => 'Lịch biểu không khả dụng'; + + @override + String get scheduleNoSources => + 'Không có lịch hoặc danh sách công việc nào đang hiển thị'; + + @override + String get scheduleNoSourcesDescription => + 'Chọn nội dung cần hiển thị trong Cài đặt, sau đó làm mới lịch biểu.'; + + @override + String get scheduleSignInRequired => 'Kết nối tài khoản'; + + @override + String get scheduleSignInDescription => + 'Đăng nhập để đồng bộ lịch và công việc.'; + + @override + String get scheduleNoSearchResults => + 'Không có sự kiện hoặc công việc phù hợp'; + + @override + String get scheduleNoSearchResultsDescription => + 'Thử tìm kiếm khác hoặc xóa các bộ lọc hiện tại.'; + + @override + String get refresh => 'Làm mới'; + + @override + String get trayOpenBusyMax => 'Mở BusyMax'; + + @override + String get agendaLoadMoreOverdue => 'Tải thêm công việc quá hạn'; + + @override + String get agendaLoadMoreNoDate => 'Tải thêm công việc không có ngày'; + + @override + String get viewDay => 'Ngày'; + + @override + String get viewWeek => 'Tuần'; + + @override + String get viewMonth => 'Tháng'; + + @override + String get viewYear => 'Năm'; + + @override + String get viewAgenda => 'Lịch biểu'; + + @override + String get scheduleSettings => 'Lịch biểu'; + + @override + String get scheduleDisplaySettings => 'Hiển thị lịch biểu'; + + @override + String get scheduleDisplayHoursDescription => + 'Chế độ xem Ngày và Tuần ban đầu hiển thị khoảng thời gian này. Các mục sớm hơn hoặc muộn hơn sẽ mở rộng khoảng hiển thị khi cần.'; + + @override + String get scheduleDayStartsAt => 'Ngày bắt đầu lúc'; + + @override + String get scheduleDayEndsAt => 'Ngày kết thúc lúc'; + + @override + String get sourceCalendar => 'Lịch'; + + @override + String get sourceTaskList => 'Danh sách công việc'; + + @override + String get createChoiceTitle => 'Tạo'; + + @override + String get createEventAtTime => 'Sự kiện'; + + @override + String get createTaskAtDate => 'Công việc'; + + @override + String get editEvent => 'Chỉnh sửa sự kiện'; + + @override + String get eventTitle => 'Tiêu đề sự kiện'; + + @override + String get location => 'Địa điểm'; + + @override + String get timeSlot => 'Khoảng thời gian'; + + @override + String get startDateTime => 'Ngày/giờ bắt đầu'; + + @override + String get endDateTime => 'Ngày/giờ kết thúc'; + + @override + String get doesNotRepeat => 'Không lặp lại'; + + @override + String get defaultReminder => 'Lời nhắc mặc định'; + + @override + String get guests => 'Khách mời'; + + @override + String get noGuests => 'Không có khách mời'; + + @override + String get description => 'Mô tả'; + + @override + String get availabilityShowAs => 'Tình trạng rảnh/bận / Hiển thị là'; + + @override + String get busy => 'Bận'; + + @override + String get visibility => 'Chế độ hiển thị'; + + @override + String get defaultVisibility => 'Chế độ hiển thị mặc định'; + + @override + String get conference => 'Cuộc họp'; + + @override + String get noConference => 'Không có cuộc họp'; + + @override + String get providerCalendar => 'Lịch của dịch vụ'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => 'Đậm'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'Nghiêng'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => 'Gạch chân'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: 'Trước $minutes phút', + one: 'Trước 1 phút', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'Khi bắt đầu'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: 'Trước $hours giờ', + one: 'Trước 1 giờ', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: 'Trước $days ngày', + one: 'Trước 1 ngày', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Rảnh'; + + @override + String get availabilityTentative => 'Dự kiến'; + + @override + String get availabilityOutOfOffice => 'Vắng mặt'; + + @override + String get availabilityWorkingElsewhere => 'Làm việc ở nơi khác'; + + @override + String get visibilityDefault => 'Mặc định'; + + @override + String get visibilityPublic => 'Công khai'; + + @override + String get visibilityPrivate => 'Riêng tư'; + + @override + String get visibilityConfidential => 'Bảo mật'; + + @override + String get sensitivityNormal => 'Bình thường'; + + @override + String get sensitivityPersonal => 'Cá nhân'; + + @override + String get tasks => 'Công việc'; + + @override + String get allTasks => 'Tất cả công việc'; + + @override + String tasksInList(String title) { + return 'Công việc trong $title'; + } + + @override + String get taskLists => 'Danh sách công việc'; + + @override + String get navigation => 'Điều hướng'; + + @override + String get mainMenu => 'Trình đơn chính'; + + @override + String get keyboardShortcuts => 'Phím tắt'; + + @override + String get shortcutGroupGeneral => 'Chung'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'Hiển thị bảng tham khảo phím tắt này'; + + @override + String get shortcutGroupNavigation => 'Điều hướng'; + + @override + String get shortcutNextPeriod => 'Khoảng tiếp theo'; + + @override + String get shortcutNextPeriodDescription => + 'Tuần tiếp theo trong chế độ xem tuần, tháng tiếp theo trong chế độ xem tháng, v.v.'; + + @override + String get shortcutPreviousPeriod => 'Khoảng trước đó'; + + @override + String get shortcutPreviousPeriodDescription => + 'Tuần trước trong chế độ xem tuần, tháng trước trong chế độ xem tháng, v.v.'; + + @override + String get shortcutJumpToToday => 'Chuyển đến hôm nay'; + + @override + String get shortcutGroupView => 'Chế độ xem'; + + @override + String get shortcutDayView => 'Chế độ xem ngày'; + + @override + String get shortcutWeekView => 'Chế độ xem tuần'; + + @override + String get shortcutMonthView => 'Chế độ xem tháng'; + + @override + String get shortcutYearView => 'Chế độ xem năm'; + + @override + String get shortcutAgendaView => 'Chế độ xem lịch biểu'; + + @override + String get shortcutGroupCreateAndEdit => 'Tạo và chỉnh sửa'; + + @override + String get shortcutSaveItem => 'Lưu sự kiện hoặc công việc'; + + @override + String get shortcutDeleteItem => 'Xóa sự kiện hoặc công việc'; + + @override + String get shortcutGroupTaskEditing => 'Chỉnh sửa công việc'; + + @override + String get shortcutCancelEditing => 'Hủy chỉnh sửa'; + + @override + String get shortcutCancelEditingDescription => + 'Đóng phần chỉnh sửa hoặc chi tiết công việc'; + + @override + String get aboutBusyMax => 'Giới thiệu BusyMax'; + + @override + String get aboutBusyMaxDescription => 'Lịch và công việc'; + + @override + String get license => 'Giấy phép'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => 'Trang web'; + + @override + String get sourceCode => 'Mã nguồn'; + + @override + String get reportAnIssue => 'Báo cáo sự cố'; + + @override + String get sendFeedback => 'Gửi phản hồi'; + + @override + String get feedbackSubmit => 'Gửi'; + + @override + String get feedbackCategory => 'Danh mục'; + + @override + String get feedbackSelectCategory => 'Chọn một danh mục'; + + @override + String get feedbackCategoryProblem => 'Sự cố hoặc lỗi'; + + @override + String get feedbackCategoryFeature => 'Yêu cầu tính năng'; + + @override + String get feedbackCategoryPrivacySecurity => + 'Vấn đề về quyền riêng tư hoặc bảo mật'; + + @override + String get feedbackCategoryUsability => 'Vấn đề về khả năng sử dụng'; + + @override + String get feedbackCategoryOther => 'Khác'; + + @override + String get feedbackSubject => 'Chủ đề'; + + @override + String get feedbackDetailedMessage => 'Nội dung chi tiết'; + + @override + String get feedbackReplyEmail => + 'Địa chỉ email để nhận phản hồi (không bắt buộc)'; + + @override + String get feedbackIncludeTechnicalDetails => 'Bao gồm chi tiết kỹ thuật'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Chỉ thêm phiên bản hệ điều hành Linux và ngôn ngữ, khu vực của ứng dụng. Không bao gồm nhật ký, dữ liệu tài khoản, tên tệp hoặc thông tin chẩn đoán khác.'; + + @override + String get feedbackCategoryRequired => 'Hãy chọn một danh mục.'; + + @override + String get feedbackSubjectLengthError => 'Chủ đề phải có từ 3 đến 120 ký tự.'; + + @override + String get feedbackMessageLengthError => + 'Nội dung phải có từ 10 đến 5.000 ký tự.'; + + @override + String get feedbackInvalidEmail => 'Nhập địa chỉ email hợp lệ.'; + + @override + String get feedbackConnectionError => + 'Không thể kết nối với BusyStack. Hãy kiểm tra kết nối và thử lại.'; + + @override + String get feedbackTimeoutError => + 'Yêu cầu đã hết thời gian chờ. Phản hồi của bạn chưa bị xóa; hãy thử lại.'; + + @override + String get feedbackRateLimitedError => + 'Đã gửi quá nhiều phản hồi từ mạng này. Vui lòng chờ rồi thử lại.'; + + @override + String get feedbackRejectedError => + 'Máy chủ đã từ chối nội dung gửi. Hãy kiểm tra các trường và thử lại.'; + + @override + String get feedbackServerError => + 'BusyStack hiện không thể nhận phản hồi của bạn. Phản hồi chưa bị xóa; hãy thử lại.'; + + @override + String feedbackSuccess(String id) { + return 'Đã gửi phản hồi. Mã tham chiếu: $id'; + } + + @override + String get toggleSidebar => 'Hiện hoặc ẩn thanh bên'; + + @override + String get showSidebar => 'Hiện bảng bên'; + + @override + String get hideSidebar => 'Ẩn bảng bên'; + + @override + String get accounts => 'Tài khoản'; + + @override + String get currentAccount => 'Tài khoản hiện tại'; + + @override + String get switchAccount => 'Chuyển tài khoản'; + + @override + String get addGoogleAccount => 'Thêm tài khoản Google'; + + @override + String get addMicrosoftAccount => 'Thêm tài khoản Microsoft'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'Đã đăng nhập'; + + @override + String get removeAccount => 'Xóa tài khoản…'; + + @override + String get removingAccount => 'Đang xóa tài khoản…'; + + @override + String get removeAccountDescription => + 'Dừng đồng bộ và xóa dữ liệu của tài khoản này khỏi thiết bị.'; + + @override + String removeAccountTitle(String account) { + return 'Xóa $account khỏi BusyMax?'; + } + + @override + String get removeAccountConfirmation => + 'Thao tác này sẽ xóa công việc, lịch, sự kiện, lời nhắc đã lưu trong bộ nhớ đệm và các thay đổi ngoại tuyến đang chờ khỏi thiết bị. Các thay đổi chưa đồng bộ sẽ bị mất. Không có dữ liệu nào bị xóa khỏi Google hoặc Microsoft.'; + + @override + String get revokeGoogleAccess => + 'Đồng thời thu hồi quyền truy cập của BusyMax vào tài khoản Google này'; + + @override + String get revokeGoogleAccessDescription => + 'Bạn sẽ cần cấp lại quyền truy cập trước khi kết nối lại.'; + + @override + String get removeAccountAction => 'Xóa tài khoản'; + + @override + String get removeAccountFailed => + 'Không thể hoàn tất việc xóa tài khoản. Hãy thử lại.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'Tài khoản đã bị xóa khỏi thiết bị này, nhưng BusyMax không thể thu hồi quyền truy cập vào tài khoản Google. Bạn có thể thu hồi quyền trong phần cài đặt Tài khoản Google.'; + + @override + String get newList => 'Danh sách mới'; + + @override + String get signInToViewTaskLists => 'Đăng nhập để xem danh sách công việc.'; + + @override + String get noTaskListsSynced => + 'Chưa có danh sách công việc nào được đồng bộ.'; + + @override + String get listActions => 'Thao tác với danh sách'; + + @override + String get rename => 'Đổi tên'; + + @override + String get delete => 'Xóa'; + + @override + String get renameList => 'Đổi tên danh sách'; + + @override + String get deleteList => 'Xóa danh sách'; + + @override + String get builtInMicrosoftList => 'Tích hợp sẵn'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Không thể đổi tên hoặc xóa danh sách tích hợp sẵn của Microsoft To Do.'; + + @override + String deleteListConfirmation(String title) { + return 'Xóa “$title” khỏi Google Tasks?'; + } + + @override + String get deleteEvent => 'Xóa sự kiện'; + + @override + String get title => 'Tiêu đề'; + + @override + String get create => 'Tạo'; + + @override + String get newTask => 'Công việc mới'; + + @override + String get clearCompleted => 'Xóa các công việc đã hoàn thành'; + + @override + String get refreshList => 'Làm mới danh sách'; + + @override + String get refreshAll => 'Làm mới tất cả'; + + @override + String get listRefreshed => 'Đã làm mới danh sách.'; + + @override + String get allTasksRefreshed => 'Đã làm mới tất cả tài khoản.'; + + @override + String exportedFile(String path) { + return 'Đã xuất sang $path'; + } + + @override + String exportFailed(String error) { + return 'Xuất không thành công: $error'; + } + + @override + String refreshFailed(String error) { + return 'Làm mới không thành công: $error'; + } + + @override + String get selectOrCreateTaskList => + 'Chọn hoặc tạo một danh sách công việc để bắt đầu.'; + + @override + String get signInToViewTasks => 'Đăng nhập để xem công việc.'; + + @override + String get noTasks => 'Không có công việc.'; + + @override + String get noTasksYet => 'Chưa có công việc'; + + @override + String get noTasksYetMessage => + 'Tạo một công việc hoặc làm mới tài khoản để bắt đầu.'; + + @override + String get noTasksInList => 'Không có công việc nào trong danh sách này.'; + + @override + String get overdue => 'Quá hạn'; + + @override + String get today => 'Hôm nay'; + + @override + String get tomorrow => 'Ngày mai'; + + @override + String get upcoming => 'Sắp tới'; + + @override + String get noDate => 'Không có ngày'; + + @override + String get completed => 'Đã hoàn thành'; + + @override + String duePrefix(String date) { + return 'Đến hạn $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => 'Chi tiết công việc'; + + @override + String get editTask => 'Chỉnh sửa công việc'; + + @override + String get noTaskSelected => 'Chưa chọn công việc.'; + + @override + String get noTaskSelectedHelper => + 'Chọn một công việc để xem và chỉnh sửa chi tiết.'; + + @override + String get taskUnavailable => 'Công việc không khả dụng.'; + + @override + String get signInToEditTasks => 'Đăng nhập để chỉnh sửa công việc.'; + + @override + String get refreshTask => 'Làm mới công việc'; + + @override + String get primarySection => 'Chính'; + + @override + String get statusSection => 'Trạng thái'; + + @override + String get openStatus => 'Chưa hoàn thành'; + + @override + String get doneStatus => 'Đã hoàn thành'; + + @override + String get notes => 'Ghi chú'; + + @override + String get dueDate => 'Ngày đến hạn'; + + @override + String get clearDueDate => 'Xóa ngày đến hạn'; + + @override + String get dueTime => 'Giờ đến hạn'; + + @override + String get startDate => 'Ngày bắt đầu'; + + @override + String get startTime => 'Giờ bắt đầu'; + + @override + String get endDate => 'Ngày kết thúc'; + + @override + String get endTime => 'Giờ kết thúc'; + + @override + String get reminderDate => 'Ngày nhắc'; + + @override + String get reminderTime => 'Giờ nhắc'; + + @override + String get reminder => 'Lời nhắc'; + + @override + String get addReminder => 'Thêm lời nhắc'; + + @override + String get addGuest => 'Thêm khách mời'; + + @override + String get addGuestEmail => 'Thêm email khách mời'; + + @override + String get removeReminder => 'Xóa lời nhắc'; + + @override + String get off => 'Tắt'; + + @override + String get repeat => 'Lặp lại'; + + @override + String get repeatNone => 'Không lặp lại'; + + @override + String get noneValue => 'Không có'; + + @override + String get repeatDaily => 'Hằng ngày'; + + @override + String get repeatWeekly => 'Hằng tuần'; + + @override + String get repeatMonthly => 'Hằng tháng'; + + @override + String get repeatYearly => 'Hằng năm'; + + @override + String get importance => 'Mức độ quan trọng'; + + @override + String get importanceLow => 'Thấp'; + + @override + String get importanceNormal => 'Bình thường'; + + @override + String get importanceHigh => 'Cao'; + + @override + String get categories => 'Danh mục'; + + @override + String get scheduleSection => 'Lịch'; + + @override + String get dueGroup => 'Đến hạn'; + + @override + String get startGroup => 'Bắt đầu'; + + @override + String get reminderGroup => 'Lời nhắc'; + + @override + String get organizationSection => 'Sắp xếp'; + + @override + String get actionsSection => 'Thao tác'; + + @override + String get advancedSection => 'Nâng cao'; + + @override + String get addCategory => 'Thêm danh mục'; + + @override + String get list => 'Danh sách'; + + @override + String get microsoftMoveUnsupported => + 'Phiên bản này không hỗ trợ di chuyển công việc giữa các danh sách trong tài khoản Microsoft To Do.'; + + @override + String get createSubtask => 'Tạo công việc con'; + + @override + String get moveToTop => 'Chuyển lên đầu'; + + @override + String get deleteTask => 'Xóa công việc'; + + @override + String get newSubtask => 'Công việc con mới'; + + @override + String deleteTaskConfirmation(String title) { + return 'Xóa “$title” khỏi Google Tasks?'; + } + + @override + String get metadata => 'Siêu dữ liệu'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'Đã cập nhật'; + + @override + String get parent => 'Công việc cha'; + + @override + String get position => 'Vị trí'; + + @override + String get webLink => 'Liên kết web'; + + @override + String get assignment => 'Phân công'; + + @override + String get localState => 'Trạng thái cục bộ'; + + @override + String get pendingSync => 'Đang chờ đồng bộ'; + + @override + String get synced => 'Đã đồng bộ'; + + @override + String get account => 'Tài khoản'; + + @override + String get sync => 'Đồng bộ'; + + @override + String get manualFullSync => 'Đồng bộ toàn bộ thủ công'; + + @override + String get runInBackgroundWhenClosed => 'Tiếp tục chạy khi đóng cửa sổ'; + + @override + String get showTrayIcon => 'Hiện biểu tượng khay hệ thống'; + + @override + String get startMinimizedToTray => 'Khởi động thu nhỏ vào khay hệ thống'; + + @override + String get requiresTrayIcon => 'Yêu cầu biểu tượng khay hệ thống.'; + + @override + String get syncComplete => 'Đồng bộ hoàn tất.'; + + @override + String syncFailed(String error) { + return 'Đồng bộ không thành công: $error'; + } + + @override + String get notifySyncFailures => 'Thông báo khi đồng bộ thất bại'; + + @override + String get notifyConflicts => 'Thông báo khi có xung đột'; + + @override + String get notifyDueToday => 'Thông báo công việc đến hạn hôm nay'; + + @override + String get eventReminders => 'Lời nhắc sự kiện'; + + @override + String get taskReminders => 'Lời nhắc công việc'; + + @override + String get notificationDetailLevel => 'Mức độ chi tiết của thông báo'; + + @override + String get notificationDetailPrivate => 'Riêng tư'; + + @override + String get notificationDetailNormal => 'Bình thường'; + + @override + String get quietHours => 'Giờ yên tĩnh'; + + @override + String get quietHoursDescription => + 'Tạm dừng thông báo trong khoảng thời gian này.'; + + @override + String get quietHoursStart => 'Bắt đầu giờ yên tĩnh'; + + @override + String get quietHoursEnd => 'Kết thúc giờ yên tĩnh'; + + @override + String get notifications => 'Thông báo'; + + @override + String get appearance => 'Giao diện'; + + @override + String get theme => 'Chủ đề'; + + @override + String get themeSystem => 'Hệ thống'; + + @override + String get themeLight => 'Sáng'; + + @override + String get themeDark => 'Tối'; + + @override + String get themeFamily => 'Họ chủ đề'; + + @override + String get themeFamilyYaru => 'Chủ đề Ubuntu nguyên bản (Yaru)'; + + @override + String get localization => 'Ngôn ngữ và khu vực'; + + @override + String get currentLocale => 'Ngôn ngữ và khu vực hiện tại'; + + @override + String get privacy => 'Quyền riêng tư'; + + @override + String get redactTaskContentInDiagnostics => + 'Ẩn nội dung công việc trong thông tin chẩn đoán'; + + @override + String get developerDiagnostics => 'Chẩn đoán dành cho nhà phát triển'; + + @override + String get diagnostics => 'Chẩn đoán'; + + @override + String get apiInspectorDisabled => 'Hiện trình kiểm tra API'; + + @override + String get googleTasksApi => 'API Google Tasks'; + + @override + String discoveryRevision(String revision) { + return 'Bản sửa đổi Discovery: $revision'; + } + + @override + String get implementedMethods => 'Phương thức đã triển khai'; + + @override + String get supportsTasksScopes => 'Hỗ trợ phạm vi tasks và tasks.readonly'; + + @override + String get requiresTasksScope => 'Yêu cầu phạm vi tasks'; + + @override + String get blockedPendingOperations => 'Thao tác đang chờ bị chặn'; + + @override + String get signInToInspectPendingOperations => + 'Đăng nhập để kiểm tra các thao tác đang chờ.'; + + @override + String get noBlockedPendingOperations => + 'Không có thao tác đang chờ nào bị chặn.'; + + @override + String get operationActions => 'Hành động cho thao tác'; + + @override + String pendingOpListId(String id) { + return 'danh_sách=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'công_việc=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'số_lần_thử=$count'; + } + + @override + String get retry => 'Thử lại'; + + @override + String get discard => 'Hủy bỏ'; + + @override + String get discardChangesAction => 'Không lưu'; + + @override + String get discardChanges => 'Hủy bỏ thay đổi?'; + + @override + String get discardChangesConfirmation => + 'Thao tác này sẽ hủy bỏ các chỉnh sửa chưa lưu đối với công việc.'; + + @override + String get retryCompleted => 'Đã thử lại.'; + + @override + String get discardPendingOperation => 'Hủy bỏ thao tác đang chờ?'; + + @override + String get discardPendingOperationConfirmation => + 'Thao tác này sẽ xóa thao tác cục bộ bị chặn. Lần đồng bộ tiếp theo sẽ tải lại dữ liệu từ Google Tasks.'; + + @override + String get pendingOperationDiscarded => 'Đã hủy bỏ thao tác đang chờ.'; + + @override + String get syncFailureNotificationTitle => 'Đồng bộ BusyMax không thành công'; + + @override + String syncFailureNotificationBody(String message) { + return 'Đồng bộ nền không thành công. $message'; + } + + @override + String get conflictNotificationTitle => 'Xung đột đồng bộ BusyMax'; + + @override + String conflictNotificationBody(String summary) { + return 'Một thay đổi cục bộ đang chờ đã bị chặn. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'Công việc đến hạn hôm nay'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Có $count công việc đến hạn hôm nay.', + one: 'Có một công việc đến hạn hôm nay.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'Lời nhắc sự kiện'; + + @override + String get taskReminderNotificationTitle => 'Lời nhắc công việc'; + + @override + String get eventReminderNotificationBody => 'Sự kiện sắp bắt đầu.'; + + @override + String get taskReminderNotificationBody => 'Công việc sắp đến hạn.'; + + @override + String get notificationOpenAction => 'Mở'; + + @override + String get notificationDetailsHidden => + 'Chi tiết bị ẩn theo cài đặt quyền riêng tư.'; + + @override + String get previousMonth => 'Tháng trước'; + + @override + String get nextMonth => 'Tháng sau'; + + @override + String get openMonthView => 'Mở chế độ xem tháng'; + + @override + String get previousYear => 'Năm trước'; + + @override + String get nextYear => 'Năm sau'; + + @override + String get openYearView => 'Mở chế độ xem năm'; + + @override + String weekNumberTooltip(int number) { + return 'Tuần $number'; + } + + @override + String get resizeAllDayPanel => 'Đổi kích thước bảng cả ngày'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count mục', + one: '1 mục', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Lịch này chỉ có thể đọc.'; + + @override + String get selectTimeZone => 'Chọn múi giờ'; + + @override + String get searchLocations => 'Tìm kiếm địa điểm'; + + @override + String get noLocationsFound => 'Không tìm thấy địa điểm'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Xóa “$title”?'; + } +} diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart new file mode 100644 index 0000000..5d7f2bf --- /dev/null +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -0,0 +1,3634 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment + +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class AppLocalizationsZh extends AppLocalizations { + AppLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => '连接 Google 和 Microsoft 帐户以同步日历和任务。'; + + @override + String get googlePermissionsConsentNotice => '在 Google 权限页面上,同时选择日历和任务权限。'; + + @override + String get googlePermissionsRequiredRetry => + '必须授予 Google 日历和 Google Tasks 权限。请重试并选中两个复选框。'; + + @override + String get finishSetup => '完成设置'; + + @override + String get continueSetup => '继续'; + + @override + String get onboardingSetupTitle => '设置 BusyMax'; + + @override + String get onboardingAccountsStepTitle => '连接帐户'; + + @override + String get onboardingAccountsStepDescription => + '添加您要使用的所有 Google 和 Microsoft 帐户。BusyMax 会同步每个帐户中的日历、日程、任务列表和任务。'; + + @override + String get onboardingPreferencesStepTitle => '选择系统设置'; + + @override + String get onboardingPreferencesStepDescription => + '打开日程前,请设置桌面行为、提醒、通知详细程度和外观。'; + + @override + String get signInWithGoogle => '使用 Google 登录'; + + @override + String get signInWithMicrosoft => '使用 Microsoft 登录'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => '尚未配置此服务。'; + + @override + String get waitingForGoogleSignIn => '正在等待 Google 登录...'; + + @override + String get waitingForMicrosoftSignIn => '正在等待 Microsoft 登录...'; + + @override + String get microsoftSignInNotConfigured => + '尚未配置 Microsoft 登录。请设置 MICROSOFT_OAUTH_CLIENT_ID。'; + + @override + String get cancel => '取消'; + + @override + String get close => '关闭'; + + @override + String get exit => '退出'; + + @override + String get options => '选项'; + + @override + String get hide => '隐藏'; + + @override + String get show => '显示'; + + @override + String get export => '导出'; + + @override + String get save => '保存'; + + @override + String get settings => '设置'; + + @override + String get all => '全部'; + + @override + String get calendarEvents => '日程'; + + @override + String get calendarTasks => '任务'; + + @override + String get calendar => '日历'; + + @override + String get calendars => '日历'; + + @override + String get newEvent => '新建日程'; + + @override + String get refreshCalendar => '刷新日历'; + + @override + String get openInProvider => '在服务中打开'; + + @override + String get hideFromSchedule => '从日程中隐藏'; + + @override + String get showInSchedule => '在日程中显示'; + + @override + String get noCalendarsSynced => '尚未同步任何日历。'; + + @override + String get allDay => '全天'; + + @override + String moreItems(int count) { + return '还有 $count 项'; + } + + @override + String get noEventsOrTasks => '没有日程或任务'; + + @override + String get scheduleLoading => '正在加载日程...'; + + @override + String get scheduleUnavailable => '日程不可用'; + + @override + String get scheduleNoSources => '没有可见的日历或任务列表'; + + @override + String get scheduleNoSourcesDescription => '请在设置中选择要显示的内容,然后刷新。'; + + @override + String get scheduleSignInRequired => '连接帐户'; + + @override + String get scheduleSignInDescription => '登录以同步日历和任务。'; + + @override + String get scheduleNoSearchResults => '没有匹配的日程或任务'; + + @override + String get scheduleNoSearchResultsDescription => '请尝试其他搜索内容或清除当前筛选条件。'; + + @override + String get refresh => '刷新'; + + @override + String get trayOpenBusyMax => '打开 BusyMax'; + + @override + String get agendaLoadMoreOverdue => '加载更多逾期任务'; + + @override + String get agendaLoadMoreNoDate => '加载更多无日期任务'; + + @override + String get viewDay => '日'; + + @override + String get viewWeek => '周'; + + @override + String get viewMonth => '月'; + + @override + String get viewYear => '年'; + + @override + String get viewAgenda => '日程'; + + @override + String get scheduleSettings => '日程'; + + @override + String get scheduleDisplaySettings => '日程显示'; + + @override + String get scheduleDisplayHoursDescription => + '日视图和周视图最初显示此时间范围。需要时,更早或更晚的项目会扩展该范围。'; + + @override + String get scheduleDayStartsAt => '每日开始时间'; + + @override + String get scheduleDayEndsAt => '每日结束时间'; + + @override + String get sourceCalendar => '日历'; + + @override + String get sourceTaskList => '任务列表'; + + @override + String get createChoiceTitle => '新建'; + + @override + String get createEventAtTime => '日程'; + + @override + String get createTaskAtDate => '任务'; + + @override + String get editEvent => '编辑日程'; + + @override + String get eventTitle => '日程标题'; + + @override + String get location => '地点'; + + @override + String get timeSlot => '时间段'; + + @override + String get startDateTime => '开始日期/时间'; + + @override + String get endDateTime => '结束日期/时间'; + + @override + String get doesNotRepeat => '不重复'; + + @override + String get defaultReminder => '默认提醒'; + + @override + String get guests => '参与者'; + + @override + String get noGuests => '没有参与者'; + + @override + String get description => '说明'; + + @override + String get availabilityShowAs => '空闲状态 / 显示为'; + + @override + String get busy => '忙碌'; + + @override + String get visibility => '可见性'; + + @override + String get defaultVisibility => '默认可见性'; + + @override + String get conference => '会议'; + + @override + String get noConference => '无会议'; + + @override + String get providerCalendar => '服务日历'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => '粗体'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => '斜体'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => '下划线'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes 分钟前', + one: '1 分钟前', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => '开始时'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours 小时前', + one: '1 小时前', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days 天前', + one: '1 天前', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => '空闲'; + + @override + String get availabilityTentative => '暂定'; + + @override + String get availabilityOutOfOffice => '不在办公室'; + + @override + String get availabilityWorkingElsewhere => '在其他地点办公'; + + @override + String get visibilityDefault => '默认'; + + @override + String get visibilityPublic => '公开'; + + @override + String get visibilityPrivate => '私密'; + + @override + String get visibilityConfidential => '机密'; + + @override + String get sensitivityNormal => '普通'; + + @override + String get sensitivityPersonal => '个人'; + + @override + String get tasks => '任务'; + + @override + String get allTasks => '所有任务'; + + @override + String tasksInList(String title) { + return '$title中的任务'; + } + + @override + String get taskLists => '任务列表'; + + @override + String get navigation => '导航'; + + @override + String get mainMenu => '主菜单'; + + @override + String get keyboardShortcuts => '键盘快捷键'; + + @override + String get shortcutGroupGeneral => '常规'; + + @override + String get shortcutKeyboardShortcutsDescription => '显示快捷键参考表'; + + @override + String get shortcutGroupNavigation => '导航'; + + @override + String get shortcutNextPeriod => '下一时段'; + + @override + String get shortcutNextPeriodDescription => '在周视图中前往下一周,在月视图中前往下个月,依此类推'; + + @override + String get shortcutPreviousPeriod => '上一时段'; + + @override + String get shortcutPreviousPeriodDescription => '在周视图中前往上一周,在月视图中前往上个月,依此类推'; + + @override + String get shortcutJumpToToday => '跳转到今天'; + + @override + String get shortcutGroupView => '视图'; + + @override + String get shortcutDayView => '日视图'; + + @override + String get shortcutWeekView => '周视图'; + + @override + String get shortcutMonthView => '月视图'; + + @override + String get shortcutYearView => '年视图'; + + @override + String get shortcutAgendaView => '日程视图'; + + @override + String get shortcutGroupCreateAndEdit => '新建和编辑'; + + @override + String get shortcutSaveItem => '保存日程或任务'; + + @override + String get shortcutDeleteItem => '删除日程或任务'; + + @override + String get shortcutGroupTaskEditing => '任务编辑'; + + @override + String get shortcutCancelEditing => '取消编辑'; + + @override + String get shortcutCancelEditingDescription => '关闭任务编辑或任务详情'; + + @override + String get aboutBusyMax => '关于 BusyMax'; + + @override + String get aboutBusyMaxDescription => '日历和任务'; + + @override + String get license => '许可证'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => '网站'; + + @override + String get sourceCode => '源代码'; + + @override + String get reportAnIssue => '报告问题'; + + @override + String get sendFeedback => '发送反馈'; + + @override + String get feedbackSubmit => '提交'; + + @override + String get feedbackCategory => '类别'; + + @override + String get feedbackSelectCategory => '选择类别'; + + @override + String get feedbackCategoryProblem => '问题或错误'; + + @override + String get feedbackCategoryFeature => '功能请求'; + + @override + String get feedbackCategoryPrivacySecurity => '隐私或安全问题'; + + @override + String get feedbackCategoryUsability => '易用性问题'; + + @override + String get feedbackCategoryOther => '其他'; + + @override + String get feedbackSubject => '主题'; + + @override + String get feedbackDetailedMessage => '详细信息'; + + @override + String get feedbackReplyEmail => '用于接收回复的电子邮件地址(可选)'; + + @override + String get feedbackIncludeTechnicalDetails => '包含技术详情'; + + @override + String get feedbackTechnicalDetailsDisclosure => + '仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。'; + + @override + String get feedbackCategoryRequired => '请选择类别。'; + + @override + String get feedbackSubjectLengthError => '主题必须为 3 至 120 个字符。'; + + @override + String get feedbackMessageLengthError => '消息必须为 10 至 5,000 个字符。'; + + @override + String get feedbackInvalidEmail => '请输入有效的电子邮件地址。'; + + @override + String get feedbackConnectionError => '无法连接到 BusyStack。请检查连接,然后重试。'; + + @override + String get feedbackTimeoutError => '请求超时。您的反馈尚未清除,请重试。'; + + @override + String get feedbackRateLimitedError => '从此网络发送的反馈过多。请稍后再试。'; + + @override + String get feedbackRejectedError => '服务器拒绝了提交。请检查各字段,然后重试。'; + + @override + String get feedbackServerError => 'BusyStack 目前无法接收您的反馈。您的反馈尚未清除,请重试。'; + + @override + String feedbackSuccess(String id) { + return '反馈已发送。参考编号:$id'; + } + + @override + String get toggleSidebar => '显示或隐藏侧边栏'; + + @override + String get showSidebar => '显示侧边栏面板'; + + @override + String get hideSidebar => '隐藏侧边栏面板'; + + @override + String get accounts => '帐户'; + + @override + String get currentAccount => '当前帐户'; + + @override + String get switchAccount => '切换帐户'; + + @override + String get addGoogleAccount => '添加 Google 帐户'; + + @override + String get addMicrosoftAccount => '添加 Microsoft 帐户'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => '已登录'; + + @override + String get removeAccount => '移除帐户…'; + + @override + String get removingAccount => '正在移除帐户…'; + + @override + String get removeAccountDescription => '停止同步并从此设备移除此帐户的数据。'; + + @override + String removeAccountTitle(String account) { + return '从 BusyMax 中移除 $account?'; + } + + @override + String get removeAccountConfirmation => + '这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将丢失。不会从 Google 或 Microsoft 删除任何内容。'; + + @override + String get revokeGoogleAccess => '同时撤销 BusyMax 对此 Google 帐户的访问权限'; + + @override + String get revokeGoogleAccessDescription => '重新连接之前,您需要再次授予访问权限。'; + + @override + String get removeAccountAction => '移除帐户'; + + @override + String get removeAccountFailed => '无法完成帐户移除。请重试。'; + + @override + String get accountRemovedGoogleRevokeFailed => + '该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。'; + + @override + String get newList => '新建列表'; + + @override + String get signInToViewTaskLists => '登录以查看任务列表。'; + + @override + String get noTaskListsSynced => '尚未同步任何任务列表。'; + + @override + String get listActions => '列表操作'; + + @override + String get rename => '重命名'; + + @override + String get delete => '删除'; + + @override + String get renameList => '重命名列表'; + + @override + String get deleteList => '删除列表'; + + @override + String get builtInMicrosoftList => '内置'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + '无法重命名或删除 Microsoft To Do 内置列表。'; + + @override + String deleteListConfirmation(String title) { + return '从 Google Tasks 中删除“$title”?'; + } + + @override + String get deleteEvent => '删除日程'; + + @override + String get title => '标题'; + + @override + String get create => '新建'; + + @override + String get newTask => '新建任务'; + + @override + String get clearCompleted => '清除已完成项'; + + @override + String get refreshList => '刷新列表'; + + @override + String get refreshAll => '全部刷新'; + + @override + String get listRefreshed => '列表已刷新。'; + + @override + String get allTasksRefreshed => '所有帐户均已刷新。'; + + @override + String exportedFile(String path) { + return '已导出到 $path'; + } + + @override + String exportFailed(String error) { + return '导出失败:$error'; + } + + @override + String refreshFailed(String error) { + return '刷新失败:$error'; + } + + @override + String get selectOrCreateTaskList => '请选择或创建任务列表以开始使用。'; + + @override + String get signInToViewTasks => '登录以查看任务。'; + + @override + String get noTasks => '没有任务。'; + + @override + String get noTasksYet => '还没有任务'; + + @override + String get noTasksYetMessage => '创建任务或刷新帐户以开始使用。'; + + @override + String get noTasksInList => '此列表中没有任务。'; + + @override + String get overdue => '已逾期'; + + @override + String get today => '今天'; + + @override + String get tomorrow => '明天'; + + @override + String get upcoming => '即将到期'; + + @override + String get noDate => '无日期'; + + @override + String get completed => '已完成'; + + @override + String duePrefix(String date) { + return '$date 到期'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => '任务详情'; + + @override + String get editTask => '编辑任务'; + + @override + String get noTaskSelected => '未选择任务。'; + + @override + String get noTaskSelectedHelper => '选择任务以查看和编辑详情。'; + + @override + String get taskUnavailable => '任务不可用。'; + + @override + String get signInToEditTasks => '登录以编辑任务。'; + + @override + String get refreshTask => '刷新任务'; + + @override + String get primarySection => '主要信息'; + + @override + String get statusSection => '状态'; + + @override + String get openStatus => '未完成'; + + @override + String get doneStatus => '已完成'; + + @override + String get notes => '备注'; + + @override + String get dueDate => '截止日期'; + + @override + String get clearDueDate => '清除截止日期'; + + @override + String get dueTime => '截止时间'; + + @override + String get startDate => '开始日期'; + + @override + String get startTime => '开始时间'; + + @override + String get endDate => '结束日期'; + + @override + String get endTime => '结束时间'; + + @override + String get reminderDate => '提醒日期'; + + @override + String get reminderTime => '提醒时间'; + + @override + String get reminder => '提醒'; + + @override + String get addReminder => '添加提醒'; + + @override + String get addGuest => '添加参与者'; + + @override + String get addGuestEmail => '添加参与者电子邮件'; + + @override + String get removeReminder => '移除提醒'; + + @override + String get off => '关闭'; + + @override + String get repeat => '重复'; + + @override + String get repeatNone => '不重复'; + + @override + String get noneValue => '无'; + + @override + String get repeatDaily => '每天'; + + @override + String get repeatWeekly => '每周'; + + @override + String get repeatMonthly => '每月'; + + @override + String get repeatYearly => '每年'; + + @override + String get importance => '重要性'; + + @override + String get importanceLow => '低'; + + @override + String get importanceNormal => '普通'; + + @override + String get importanceHigh => '高'; + + @override + String get categories => '类别'; + + @override + String get scheduleSection => '日程'; + + @override + String get dueGroup => '截止'; + + @override + String get startGroup => '开始'; + + @override + String get reminderGroup => '提醒'; + + @override + String get organizationSection => '整理'; + + @override + String get actionsSection => '操作'; + + @override + String get advancedSection => '高级'; + + @override + String get addCategory => '添加类别'; + + @override + String get list => '列表'; + + @override + String get microsoftMoveUnsupported => '此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。'; + + @override + String get createSubtask => '创建子任务'; + + @override + String get moveToTop => '移到顶部'; + + @override + String get deleteTask => '删除任务'; + + @override + String get newSubtask => '新建子任务'; + + @override + String deleteTaskConfirmation(String title) { + return '从 Google Tasks 中删除“$title”?'; + } + + @override + String get metadata => '元数据'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => '更新时间'; + + @override + String get parent => '父任务'; + + @override + String get position => '位置'; + + @override + String get webLink => '网页链接'; + + @override + String get assignment => '分配'; + + @override + String get localState => '本地状态'; + + @override + String get pendingSync => '等待同步'; + + @override + String get synced => '已同步'; + + @override + String get account => '帐户'; + + @override + String get sync => '同步'; + + @override + String get manualFullSync => '手动完整同步'; + + @override + String get runInBackgroundWhenClosed => '窗口关闭后继续在后台运行'; + + @override + String get showTrayIcon => '显示托盘图标'; + + @override + String get startMinimizedToTray => '启动时最小化到托盘'; + + @override + String get requiresTrayIcon => '需要托盘图标。'; + + @override + String get syncComplete => '同步完成。'; + + @override + String syncFailed(String error) { + return '同步失败:$error'; + } + + @override + String get notifySyncFailures => '同步失败通知'; + + @override + String get notifyConflicts => '冲突通知'; + + @override + String get notifyDueToday => '今天到期任务通知'; + + @override + String get eventReminders => '日程提醒'; + + @override + String get taskReminders => '任务提醒'; + + @override + String get notificationDetailLevel => '通知详细程度'; + + @override + String get notificationDetailPrivate => '私密'; + + @override + String get notificationDetailNormal => '普通'; + + @override + String get quietHours => '免打扰时段'; + + @override + String get quietHoursDescription => '在此时段暂停通知。'; + + @override + String get quietHoursStart => '免打扰开始时间'; + + @override + String get quietHoursEnd => '免打扰结束时间'; + + @override + String get notifications => '通知'; + + @override + String get appearance => '外观'; + + @override + String get theme => '主题'; + + @override + String get themeSystem => '系统'; + + @override + String get themeLight => '浅色'; + + @override + String get themeDark => '深色'; + + @override + String get themeFamily => '主题系列'; + + @override + String get themeFamilyYaru => 'Ubuntu 原生主题(Yaru)'; + + @override + String get localization => '语言和区域'; + + @override + String get currentLocale => '当前区域设置'; + + @override + String get privacy => '隐私'; + + @override + String get redactTaskContentInDiagnostics => '在诊断信息中隐藏任务内容'; + + @override + String get developerDiagnostics => '开发者诊断'; + + @override + String get diagnostics => '诊断'; + + @override + String get apiInspectorDisabled => '显示 API 检查器'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'Discovery 修订版:$revision'; + } + + @override + String get implementedMethods => '已实现的方法'; + + @override + String get supportsTasksScopes => '支持 tasks 和 tasks.readonly 权限范围'; + + @override + String get requiresTasksScope => '需要 tasks 权限范围'; + + @override + String get blockedPendingOperations => '被阻止的待处理操作'; + + @override + String get signInToInspectPendingOperations => '登录以检查待处理操作。'; + + @override + String get noBlockedPendingOperations => '没有被阻止的待处理操作。'; + + @override + String get operationActions => '操作选项'; + + @override + String pendingOpListId(String id) { + return '列表=$id'; + } + + @override + String pendingOpTaskId(String id) { + return '任务=$id'; + } + + @override + String pendingOpAttempts(int count) { + return '尝试次数=$count'; + } + + @override + String get retry => '重试'; + + @override + String get discard => '舍弃'; + + @override + String get discardChangesAction => '舍弃'; + + @override + String get discardChanges => '舍弃更改?'; + + @override + String get discardChangesConfirmation => '这将舍弃对此任务所做的未保存编辑。'; + + @override + String get retryCompleted => '重试完成。'; + + @override + String get discardPendingOperation => '舍弃待处理操作?'; + + @override + String get discardPendingOperationConfirmation => + '这将移除被阻止的本地操作。下次同步时将从 Google Tasks 刷新数据。'; + + @override + String get pendingOperationDiscarded => '已舍弃待处理操作。'; + + @override + String get syncFailureNotificationTitle => 'BusyMax 同步失败'; + + @override + String syncFailureNotificationBody(String message) { + return '后台同步失败。$message'; + } + + @override + String get conflictNotificationTitle => 'BusyMax 同步冲突'; + + @override + String conflictNotificationBody(String summary) { + return '一项待处理的本地更改被阻止。$summary'; + } + + @override + String get dueTodayNotificationTitle => '今天到期的任务'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '今天有 $count 项任务到期。', + one: '今天有 1 项任务到期。', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => '日程提醒'; + + @override + String get taskReminderNotificationTitle => '任务提醒'; + + @override + String get eventReminderNotificationBody => '日程即将开始。'; + + @override + String get taskReminderNotificationBody => '任务即将到期。'; + + @override + String get notificationOpenAction => '打开'; + + @override + String get notificationDetailsHidden => '根据隐私设置,详细信息已隐藏。'; + + @override + String get previousMonth => '上个月'; + + @override + String get nextMonth => '下个月'; + + @override + String get openMonthView => '打开月视图'; + + @override + String get previousYear => '上一年'; + + @override + String get nextYear => '下一年'; + + @override + String get openYearView => '打开年视图'; + + @override + String weekNumberTooltip(int number) { + return '第 $number 周'; + } + + @override + String get resizeAllDayPanel => '调整全天面板的大小'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 项', + one: '1 项', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => '此日历为只读。'; + + @override + String get selectTimeZone => '选择时区'; + + @override + String get searchLocations => '搜索地点'; + + @override + String get noLocationsFound => '未找到地点'; + + @override + String deleteCalendarConfirmation(String title) { + return '删除“$title”?'; + } +} + +/// The translations for Chinese, using the Han script (`zh_Hans`). +class AppLocalizationsZhHans extends AppLocalizationsZh { + AppLocalizationsZhHans() : super('zh_Hans'); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => '连接 Google 和 Microsoft 帐户以同步日历和任务。'; + + @override + String get googlePermissionsConsentNotice => '在 Google 权限页面上,同时选择日历和任务权限。'; + + @override + String get googlePermissionsRequiredRetry => + '必须授予 Google 日历和 Google Tasks 权限。请重试并选中两个复选框。'; + + @override + String get finishSetup => '完成设置'; + + @override + String get continueSetup => '继续'; + + @override + String get onboardingSetupTitle => '设置 BusyMax'; + + @override + String get onboardingAccountsStepTitle => '连接帐户'; + + @override + String get onboardingAccountsStepDescription => + '添加您要使用的所有 Google 和 Microsoft 帐户。BusyMax 会同步每个帐户中的日历、日程、任务列表和任务。'; + + @override + String get onboardingPreferencesStepTitle => '选择系统设置'; + + @override + String get onboardingPreferencesStepDescription => + '打开日程前,请设置桌面行为、提醒、通知详细程度和外观。'; + + @override + String get signInWithGoogle => '使用 Google 登录'; + + @override + String get signInWithMicrosoft => '使用 Microsoft 登录'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => '尚未配置此服务。'; + + @override + String get waitingForGoogleSignIn => '正在等待 Google 登录...'; + + @override + String get waitingForMicrosoftSignIn => '正在等待 Microsoft 登录...'; + + @override + String get microsoftSignInNotConfigured => + '尚未配置 Microsoft 登录。请设置 MICROSOFT_OAUTH_CLIENT_ID。'; + + @override + String get cancel => '取消'; + + @override + String get close => '关闭'; + + @override + String get exit => '退出'; + + @override + String get options => '选项'; + + @override + String get hide => '隐藏'; + + @override + String get show => '显示'; + + @override + String get export => '导出'; + + @override + String get save => '保存'; + + @override + String get settings => '设置'; + + @override + String get all => '全部'; + + @override + String get calendarEvents => '日程'; + + @override + String get calendarTasks => '任务'; + + @override + String get calendar => '日历'; + + @override + String get calendars => '日历'; + + @override + String get newEvent => '新建日程'; + + @override + String get refreshCalendar => '刷新日历'; + + @override + String get openInProvider => '在服务中打开'; + + @override + String get hideFromSchedule => '从日程中隐藏'; + + @override + String get showInSchedule => '在日程中显示'; + + @override + String get noCalendarsSynced => '尚未同步任何日历。'; + + @override + String get allDay => '全天'; + + @override + String moreItems(int count) { + return '还有 $count 项'; + } + + @override + String get noEventsOrTasks => '没有日程或任务'; + + @override + String get scheduleLoading => '正在加载日程...'; + + @override + String get scheduleUnavailable => '日程不可用'; + + @override + String get scheduleNoSources => '没有可见的日历或任务列表'; + + @override + String get scheduleNoSourcesDescription => '请在设置中选择要显示的内容,然后刷新。'; + + @override + String get scheduleSignInRequired => '连接帐户'; + + @override + String get scheduleSignInDescription => '登录以同步日历和任务。'; + + @override + String get scheduleNoSearchResults => '没有匹配的日程或任务'; + + @override + String get scheduleNoSearchResultsDescription => '请尝试其他搜索内容或清除当前筛选条件。'; + + @override + String get refresh => '刷新'; + + @override + String get trayOpenBusyMax => '打开 BusyMax'; + + @override + String get agendaLoadMoreOverdue => '加载更多逾期任务'; + + @override + String get agendaLoadMoreNoDate => '加载更多无日期任务'; + + @override + String get viewDay => '日'; + + @override + String get viewWeek => '周'; + + @override + String get viewMonth => '月'; + + @override + String get viewYear => '年'; + + @override + String get viewAgenda => '日程'; + + @override + String get scheduleSettings => '日程'; + + @override + String get scheduleDisplaySettings => '日程显示'; + + @override + String get scheduleDisplayHoursDescription => + '日视图和周视图最初显示此时间范围。需要时,更早或更晚的项目会扩展该范围。'; + + @override + String get scheduleDayStartsAt => '每日开始时间'; + + @override + String get scheduleDayEndsAt => '每日结束时间'; + + @override + String get sourceCalendar => '日历'; + + @override + String get sourceTaskList => '任务列表'; + + @override + String get createChoiceTitle => '新建'; + + @override + String get createEventAtTime => '日程'; + + @override + String get createTaskAtDate => '任务'; + + @override + String get editEvent => '编辑日程'; + + @override + String get eventTitle => '日程标题'; + + @override + String get location => '地点'; + + @override + String get timeSlot => '时间段'; + + @override + String get startDateTime => '开始日期/时间'; + + @override + String get endDateTime => '结束日期/时间'; + + @override + String get doesNotRepeat => '不重复'; + + @override + String get defaultReminder => '默认提醒'; + + @override + String get guests => '参与者'; + + @override + String get noGuests => '没有参与者'; + + @override + String get description => '说明'; + + @override + String get availabilityShowAs => '空闲状态 / 显示为'; + + @override + String get busy => '忙碌'; + + @override + String get visibility => '可见性'; + + @override + String get defaultVisibility => '默认可见性'; + + @override + String get conference => '会议'; + + @override + String get noConference => '无会议'; + + @override + String get providerCalendar => '服务日历'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => '粗体'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => '斜体'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => '下划线'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes 分钟前', + one: '1 分钟前', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => '开始时'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours 小时前', + one: '1 小时前', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days 天前', + one: '1 天前', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => '空闲'; + + @override + String get availabilityTentative => '暂定'; + + @override + String get availabilityOutOfOffice => '不在办公室'; + + @override + String get availabilityWorkingElsewhere => '在其他地点办公'; + + @override + String get visibilityDefault => '默认'; + + @override + String get visibilityPublic => '公开'; + + @override + String get visibilityPrivate => '私密'; + + @override + String get visibilityConfidential => '机密'; + + @override + String get sensitivityNormal => '普通'; + + @override + String get sensitivityPersonal => '个人'; + + @override + String get tasks => '任务'; + + @override + String get allTasks => '所有任务'; + + @override + String tasksInList(String title) { + return '$title中的任务'; + } + + @override + String get taskLists => '任务列表'; + + @override + String get navigation => '导航'; + + @override + String get mainMenu => '主菜单'; + + @override + String get keyboardShortcuts => '键盘快捷键'; + + @override + String get shortcutGroupGeneral => '常规'; + + @override + String get shortcutKeyboardShortcutsDescription => '显示快捷键参考表'; + + @override + String get shortcutGroupNavigation => '导航'; + + @override + String get shortcutNextPeriod => '下一时段'; + + @override + String get shortcutNextPeriodDescription => '在周视图中前往下一周,在月视图中前往下个月,依此类推'; + + @override + String get shortcutPreviousPeriod => '上一时段'; + + @override + String get shortcutPreviousPeriodDescription => '在周视图中前往上一周,在月视图中前往上个月,依此类推'; + + @override + String get shortcutJumpToToday => '跳转到今天'; + + @override + String get shortcutGroupView => '视图'; + + @override + String get shortcutDayView => '日视图'; + + @override + String get shortcutWeekView => '周视图'; + + @override + String get shortcutMonthView => '月视图'; + + @override + String get shortcutYearView => '年视图'; + + @override + String get shortcutAgendaView => '日程视图'; + + @override + String get shortcutGroupCreateAndEdit => '新建和编辑'; + + @override + String get shortcutSaveItem => '保存日程或任务'; + + @override + String get shortcutDeleteItem => '删除日程或任务'; + + @override + String get shortcutGroupTaskEditing => '任务编辑'; + + @override + String get shortcutCancelEditing => '取消编辑'; + + @override + String get shortcutCancelEditingDescription => '关闭任务编辑或任务详情'; + + @override + String get aboutBusyMax => '关于 BusyMax'; + + @override + String get aboutBusyMaxDescription => '日历和任务'; + + @override + String get license => '许可证'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => '网站'; + + @override + String get sourceCode => '源代码'; + + @override + String get reportAnIssue => '报告问题'; + + @override + String get sendFeedback => '发送反馈'; + + @override + String get feedbackSubmit => '提交'; + + @override + String get feedbackCategory => '类别'; + + @override + String get feedbackSelectCategory => '选择类别'; + + @override + String get feedbackCategoryProblem => '问题或错误'; + + @override + String get feedbackCategoryFeature => '功能请求'; + + @override + String get feedbackCategoryPrivacySecurity => '隐私或安全问题'; + + @override + String get feedbackCategoryUsability => '易用性问题'; + + @override + String get feedbackCategoryOther => '其他'; + + @override + String get feedbackSubject => '主题'; + + @override + String get feedbackDetailedMessage => '详细信息'; + + @override + String get feedbackReplyEmail => '用于接收回复的电子邮件地址(可选)'; + + @override + String get feedbackIncludeTechnicalDetails => '包含技术详情'; + + @override + String get feedbackTechnicalDetailsDisclosure => + '仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。'; + + @override + String get feedbackCategoryRequired => '请选择类别。'; + + @override + String get feedbackSubjectLengthError => '主题必须为 3 至 120 个字符。'; + + @override + String get feedbackMessageLengthError => '消息必须为 10 至 5,000 个字符。'; + + @override + String get feedbackInvalidEmail => '请输入有效的电子邮件地址。'; + + @override + String get feedbackConnectionError => '无法连接到 BusyStack。请检查连接,然后重试。'; + + @override + String get feedbackTimeoutError => '请求超时。您的反馈尚未清除,请重试。'; + + @override + String get feedbackRateLimitedError => '从此网络发送的反馈过多。请稍后再试。'; + + @override + String get feedbackRejectedError => '服务器拒绝了提交。请检查各字段,然后重试。'; + + @override + String get feedbackServerError => 'BusyStack 目前无法接收您的反馈。您的反馈尚未清除,请重试。'; + + @override + String feedbackSuccess(String id) { + return '反馈已发送。参考编号:$id'; + } + + @override + String get toggleSidebar => '显示或隐藏侧边栏'; + + @override + String get showSidebar => '显示侧边栏面板'; + + @override + String get hideSidebar => '隐藏侧边栏面板'; + + @override + String get accounts => '帐户'; + + @override + String get currentAccount => '当前帐户'; + + @override + String get switchAccount => '切换帐户'; + + @override + String get addGoogleAccount => '添加 Google 帐户'; + + @override + String get addMicrosoftAccount => '添加 Microsoft 帐户'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => '已登录'; + + @override + String get removeAccount => '移除帐户…'; + + @override + String get removingAccount => '正在移除帐户…'; + + @override + String get removeAccountDescription => '停止同步并从此设备移除此帐户的数据。'; + + @override + String removeAccountTitle(String account) { + return '从 BusyMax 中移除 $account?'; + } + + @override + String get removeAccountConfirmation => + '这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将丢失。不会从 Google 或 Microsoft 删除任何内容。'; + + @override + String get revokeGoogleAccess => '同时撤销 BusyMax 对此 Google 帐户的访问权限'; + + @override + String get revokeGoogleAccessDescription => '重新连接之前,您需要再次授予访问权限。'; + + @override + String get removeAccountAction => '移除帐户'; + + @override + String get removeAccountFailed => '无法完成帐户移除。请重试。'; + + @override + String get accountRemovedGoogleRevokeFailed => + '该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。'; + + @override + String get newList => '新建列表'; + + @override + String get signInToViewTaskLists => '登录以查看任务列表。'; + + @override + String get noTaskListsSynced => '尚未同步任何任务列表。'; + + @override + String get listActions => '列表操作'; + + @override + String get rename => '重命名'; + + @override + String get delete => '删除'; + + @override + String get renameList => '重命名列表'; + + @override + String get deleteList => '删除列表'; + + @override + String get builtInMicrosoftList => '内置'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + '无法重命名或删除 Microsoft To Do 内置列表。'; + + @override + String deleteListConfirmation(String title) { + return '从 Google Tasks 中删除“$title”?'; + } + + @override + String get deleteEvent => '删除日程'; + + @override + String get title => '标题'; + + @override + String get create => '新建'; + + @override + String get newTask => '新建任务'; + + @override + String get clearCompleted => '清除已完成项'; + + @override + String get refreshList => '刷新列表'; + + @override + String get refreshAll => '全部刷新'; + + @override + String get listRefreshed => '列表已刷新。'; + + @override + String get allTasksRefreshed => '所有帐户均已刷新。'; + + @override + String exportedFile(String path) { + return '已导出到 $path'; + } + + @override + String exportFailed(String error) { + return '导出失败:$error'; + } + + @override + String refreshFailed(String error) { + return '刷新失败:$error'; + } + + @override + String get selectOrCreateTaskList => '请选择或创建任务列表以开始使用。'; + + @override + String get signInToViewTasks => '登录以查看任务。'; + + @override + String get noTasks => '没有任务。'; + + @override + String get noTasksYet => '还没有任务'; + + @override + String get noTasksYetMessage => '创建任务或刷新帐户以开始使用。'; + + @override + String get noTasksInList => '此列表中没有任务。'; + + @override + String get overdue => '已逾期'; + + @override + String get today => '今天'; + + @override + String get tomorrow => '明天'; + + @override + String get upcoming => '即将到期'; + + @override + String get noDate => '无日期'; + + @override + String get completed => '已完成'; + + @override + String duePrefix(String date) { + return '$date 到期'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => '任务详情'; + + @override + String get editTask => '编辑任务'; + + @override + String get noTaskSelected => '未选择任务。'; + + @override + String get noTaskSelectedHelper => '选择任务以查看和编辑详情。'; + + @override + String get taskUnavailable => '任务不可用。'; + + @override + String get signInToEditTasks => '登录以编辑任务。'; + + @override + String get refreshTask => '刷新任务'; + + @override + String get primarySection => '主要信息'; + + @override + String get statusSection => '状态'; + + @override + String get openStatus => '未完成'; + + @override + String get doneStatus => '已完成'; + + @override + String get notes => '备注'; + + @override + String get dueDate => '截止日期'; + + @override + String get clearDueDate => '清除截止日期'; + + @override + String get dueTime => '截止时间'; + + @override + String get startDate => '开始日期'; + + @override + String get startTime => '开始时间'; + + @override + String get endDate => '结束日期'; + + @override + String get endTime => '结束时间'; + + @override + String get reminderDate => '提醒日期'; + + @override + String get reminderTime => '提醒时间'; + + @override + String get reminder => '提醒'; + + @override + String get addReminder => '添加提醒'; + + @override + String get addGuest => '添加参与者'; + + @override + String get addGuestEmail => '添加参与者电子邮件'; + + @override + String get removeReminder => '移除提醒'; + + @override + String get off => '关闭'; + + @override + String get repeat => '重复'; + + @override + String get repeatNone => '不重复'; + + @override + String get noneValue => '无'; + + @override + String get repeatDaily => '每天'; + + @override + String get repeatWeekly => '每周'; + + @override + String get repeatMonthly => '每月'; + + @override + String get repeatYearly => '每年'; + + @override + String get importance => '重要性'; + + @override + String get importanceLow => '低'; + + @override + String get importanceNormal => '普通'; + + @override + String get importanceHigh => '高'; + + @override + String get categories => '类别'; + + @override + String get scheduleSection => '日程'; + + @override + String get dueGroup => '截止'; + + @override + String get startGroup => '开始'; + + @override + String get reminderGroup => '提醒'; + + @override + String get organizationSection => '整理'; + + @override + String get actionsSection => '操作'; + + @override + String get advancedSection => '高级'; + + @override + String get addCategory => '添加类别'; + + @override + String get list => '列表'; + + @override + String get microsoftMoveUnsupported => '此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。'; + + @override + String get createSubtask => '创建子任务'; + + @override + String get moveToTop => '移到顶部'; + + @override + String get deleteTask => '删除任务'; + + @override + String get newSubtask => '新建子任务'; + + @override + String deleteTaskConfirmation(String title) { + return '从 Google Tasks 中删除“$title”?'; + } + + @override + String get metadata => '元数据'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => '更新时间'; + + @override + String get parent => '父任务'; + + @override + String get position => '位置'; + + @override + String get webLink => '网页链接'; + + @override + String get assignment => '分配'; + + @override + String get localState => '本地状态'; + + @override + String get pendingSync => '等待同步'; + + @override + String get synced => '已同步'; + + @override + String get account => '帐户'; + + @override + String get sync => '同步'; + + @override + String get manualFullSync => '手动完整同步'; + + @override + String get runInBackgroundWhenClosed => '窗口关闭后继续在后台运行'; + + @override + String get showTrayIcon => '显示托盘图标'; + + @override + String get startMinimizedToTray => '启动时最小化到托盘'; + + @override + String get requiresTrayIcon => '需要托盘图标。'; + + @override + String get syncComplete => '同步完成。'; + + @override + String syncFailed(String error) { + return '同步失败:$error'; + } + + @override + String get notifySyncFailures => '同步失败通知'; + + @override + String get notifyConflicts => '冲突通知'; + + @override + String get notifyDueToday => '今天到期任务通知'; + + @override + String get eventReminders => '日程提醒'; + + @override + String get taskReminders => '任务提醒'; + + @override + String get notificationDetailLevel => '通知详细程度'; + + @override + String get notificationDetailPrivate => '私密'; + + @override + String get notificationDetailNormal => '普通'; + + @override + String get quietHours => '免打扰时段'; + + @override + String get quietHoursDescription => '在此时段暂停通知。'; + + @override + String get quietHoursStart => '免打扰开始时间'; + + @override + String get quietHoursEnd => '免打扰结束时间'; + + @override + String get notifications => '通知'; + + @override + String get appearance => '外观'; + + @override + String get theme => '主题'; + + @override + String get themeSystem => '系统'; + + @override + String get themeLight => '浅色'; + + @override + String get themeDark => '深色'; + + @override + String get themeFamily => '主题系列'; + + @override + String get themeFamilyYaru => 'Ubuntu 原生主题(Yaru)'; + + @override + String get localization => '语言和区域'; + + @override + String get currentLocale => '当前区域设置'; + + @override + String get privacy => '隐私'; + + @override + String get redactTaskContentInDiagnostics => '在诊断信息中隐藏任务内容'; + + @override + String get developerDiagnostics => '开发者诊断'; + + @override + String get diagnostics => '诊断'; + + @override + String get apiInspectorDisabled => '显示 API 检查器'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'Discovery 修订版:$revision'; + } + + @override + String get implementedMethods => '已实现的方法'; + + @override + String get supportsTasksScopes => '支持 tasks 和 tasks.readonly 权限范围'; + + @override + String get requiresTasksScope => '需要 tasks 权限范围'; + + @override + String get blockedPendingOperations => '被阻止的待处理操作'; + + @override + String get signInToInspectPendingOperations => '登录以检查待处理操作。'; + + @override + String get noBlockedPendingOperations => '没有被阻止的待处理操作。'; + + @override + String get operationActions => '操作选项'; + + @override + String pendingOpListId(String id) { + return '列表=$id'; + } + + @override + String pendingOpTaskId(String id) { + return '任务=$id'; + } + + @override + String pendingOpAttempts(int count) { + return '尝试次数=$count'; + } + + @override + String get retry => '重试'; + + @override + String get discard => '舍弃'; + + @override + String get discardChangesAction => '舍弃'; + + @override + String get discardChanges => '舍弃更改?'; + + @override + String get discardChangesConfirmation => '这将舍弃对此任务所做的未保存编辑。'; + + @override + String get retryCompleted => '重试完成。'; + + @override + String get discardPendingOperation => '舍弃待处理操作?'; + + @override + String get discardPendingOperationConfirmation => + '这将移除被阻止的本地操作。下次同步时将从 Google Tasks 刷新数据。'; + + @override + String get pendingOperationDiscarded => '已舍弃待处理操作。'; + + @override + String get syncFailureNotificationTitle => 'BusyMax 同步失败'; + + @override + String syncFailureNotificationBody(String message) { + return '后台同步失败。$message'; + } + + @override + String get conflictNotificationTitle => 'BusyMax 同步冲突'; + + @override + String conflictNotificationBody(String summary) { + return '一项待处理的本地更改被阻止。$summary'; + } + + @override + String get dueTodayNotificationTitle => '今天到期的任务'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '今天有 $count 项任务到期。', + one: '今天有 1 项任务到期。', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => '日程提醒'; + + @override + String get taskReminderNotificationTitle => '任务提醒'; + + @override + String get eventReminderNotificationBody => '日程即将开始。'; + + @override + String get taskReminderNotificationBody => '任务即将到期。'; + + @override + String get notificationOpenAction => '打开'; + + @override + String get notificationDetailsHidden => '根据隐私设置,详细信息已隐藏。'; + + @override + String get previousMonth => '上个月'; + + @override + String get nextMonth => '下个月'; + + @override + String get openMonthView => '打开月视图'; + + @override + String get previousYear => '上一年'; + + @override + String get nextYear => '下一年'; + + @override + String get openYearView => '打开年视图'; + + @override + String weekNumberTooltip(int number) { + return '第 $number 周'; + } + + @override + String get resizeAllDayPanel => '调整全天面板的大小'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 项', + one: '1 项', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => '此日历为只读。'; + + @override + String get selectTimeZone => '选择时区'; + + @override + String get searchLocations => '搜索地点'; + + @override + String get noLocationsFound => '未找到地点'; + + @override + String deleteCalendarConfirmation(String title) { + return '删除“$title”?'; + } +} + +/// The translations for Chinese, using the Han script (`zh_Hant`). +class AppLocalizationsZhHant extends AppLocalizationsZh { + AppLocalizationsZhHant() : super('zh_Hant'); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => '連結 Google 和 Microsoft 帳戶以同步行事曆和待辦事項。'; + + @override + String get googlePermissionsConsentNotice => '在 Google 權限畫面中,同時選取行事曆和待辦事項權限。'; + + @override + String get googlePermissionsRequiredRetry => + '必須授予 Google 日曆和 Google Tasks 權限。請再試一次並勾選兩個核取方塊。'; + + @override + String get finishSetup => '完成設定'; + + @override + String get continueSetup => '繼續'; + + @override + String get onboardingSetupTitle => '設定 BusyMax'; + + @override + String get onboardingAccountsStepTitle => '連結帳戶'; + + @override + String get onboardingAccountsStepDescription => + '新增您要使用的所有 Google 和 Microsoft 帳戶。BusyMax 會同步每個帳戶中的行事曆、活動、待辦清單和待辦事項。'; + + @override + String get onboardingPreferencesStepTitle => '選擇系統設定'; + + @override + String get onboardingPreferencesStepDescription => + '開啟行程前,請設定桌面行為、提醒、通知詳細程度和外觀。'; + + @override + String get signInWithGoogle => '使用 Google 登入'; + + @override + String get signInWithMicrosoft => '使用 Microsoft 登入'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => '尚未設定此服務。'; + + @override + String get waitingForGoogleSignIn => '正在等候 Google 登入...'; + + @override + String get waitingForMicrosoftSignIn => '正在等候 Microsoft 登入...'; + + @override + String get microsoftSignInNotConfigured => + '尚未設定 Microsoft 登入。請設定 MICROSOFT_OAUTH_CLIENT_ID。'; + + @override + String get cancel => '取消'; + + @override + String get close => '關閉'; + + @override + String get exit => '結束'; + + @override + String get options => '選項'; + + @override + String get hide => '隱藏'; + + @override + String get show => '顯示'; + + @override + String get export => '匯出'; + + @override + String get save => '儲存'; + + @override + String get settings => '設定'; + + @override + String get all => '全部'; + + @override + String get calendarEvents => '活動'; + + @override + String get calendarTasks => '待辦事項'; + + @override + String get calendar => '行事曆'; + + @override + String get calendars => '行事曆'; + + @override + String get newEvent => '新增活動'; + + @override + String get refreshCalendar => '重新整理行事曆'; + + @override + String get openInProvider => '在服務中開啟'; + + @override + String get hideFromSchedule => '從行程中隱藏'; + + @override + String get showInSchedule => '在行程中顯示'; + + @override + String get noCalendarsSynced => '尚未同步任何行事曆。'; + + @override + String get allDay => '全天'; + + @override + String moreItems(int count) { + return '還有 $count 項'; + } + + @override + String get noEventsOrTasks => '沒有活動或待辦事項'; + + @override + String get scheduleLoading => '正在載入行程...'; + + @override + String get scheduleUnavailable => '無法使用行程'; + + @override + String get scheduleNoSources => '沒有可見的行事曆或待辦清單'; + + @override + String get scheduleNoSourcesDescription => '請在設定中選擇要顯示的內容,然後重新整理。'; + + @override + String get scheduleSignInRequired => '連結帳戶'; + + @override + String get scheduleSignInDescription => '登入以同步行事曆和待辦事項。'; + + @override + String get scheduleNoSearchResults => '沒有相符的活動或待辦事項'; + + @override + String get scheduleNoSearchResultsDescription => '請嘗試其他搜尋內容或清除目前的篩選條件。'; + + @override + String get refresh => '重新整理'; + + @override + String get trayOpenBusyMax => '開啟 BusyMax'; + + @override + String get agendaLoadMoreOverdue => '載入更多逾期待辦事項'; + + @override + String get agendaLoadMoreNoDate => '載入更多無日期待辦事項'; + + @override + String get viewDay => '日'; + + @override + String get viewWeek => '週'; + + @override + String get viewMonth => '月'; + + @override + String get viewYear => '年'; + + @override + String get viewAgenda => '行程'; + + @override + String get scheduleSettings => '行程'; + + @override + String get scheduleDisplaySettings => '行程顯示'; + + @override + String get scheduleDisplayHoursDescription => + '日檢視和週檢視一開始會顯示此時間範圍。必要時,較早或較晚的項目會擴大此範圍。'; + + @override + String get scheduleDayStartsAt => '每日開始時間'; + + @override + String get scheduleDayEndsAt => '每日結束時間'; + + @override + String get sourceCalendar => '行事曆'; + + @override + String get sourceTaskList => '待辦清單'; + + @override + String get createChoiceTitle => '新增'; + + @override + String get createEventAtTime => '活動'; + + @override + String get createTaskAtDate => '待辦事項'; + + @override + String get editEvent => '編輯活動'; + + @override + String get eventTitle => '活動標題'; + + @override + String get location => '地點'; + + @override + String get timeSlot => '時段'; + + @override + String get startDateTime => '開始日期/時間'; + + @override + String get endDateTime => '結束日期/時間'; + + @override + String get doesNotRepeat => '不重複'; + + @override + String get defaultReminder => '預設提醒'; + + @override + String get guests => '參與者'; + + @override + String get noGuests => '沒有參與者'; + + @override + String get description => '說明'; + + @override + String get availabilityShowAs => '空閒狀態 / 顯示為'; + + @override + String get busy => '忙碌'; + + @override + String get visibility => '顯示設定'; + + @override + String get defaultVisibility => '預設顯示設定'; + + @override + String get conference => '會議'; + + @override + String get noConference => '無會議'; + + @override + String get providerCalendar => '服務行事曆'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => '粗體'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => '斜體'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => '底線'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes 分鐘前', + one: '1 分鐘前', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => '開始時'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours 小時前', + one: '1 小時前', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days 天前', + one: '1 天前', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => '有空'; + + @override + String get availabilityTentative => '暫定'; + + @override + String get availabilityOutOfOffice => '不在辦公室'; + + @override + String get availabilityWorkingElsewhere => '在其他地點工作'; + + @override + String get visibilityDefault => '預設'; + + @override + String get visibilityPublic => '公開'; + + @override + String get visibilityPrivate => '私人'; + + @override + String get visibilityConfidential => '機密'; + + @override + String get sensitivityNormal => '一般'; + + @override + String get sensitivityPersonal => '個人'; + + @override + String get tasks => '待辦事項'; + + @override + String get allTasks => '所有待辦事項'; + + @override + String tasksInList(String title) { + return '$title中的待辦事項'; + } + + @override + String get taskLists => '待辦清單'; + + @override + String get navigation => '導覽'; + + @override + String get mainMenu => '主選單'; + + @override + String get keyboardShortcuts => '鍵盤快速鍵'; + + @override + String get shortcutGroupGeneral => '一般'; + + @override + String get shortcutKeyboardShortcutsDescription => '顯示快速鍵參考表'; + + @override + String get shortcutGroupNavigation => '導覽'; + + @override + String get shortcutNextPeriod => '下一時段'; + + @override + String get shortcutNextPeriodDescription => '在週檢視中前往下一週,在月檢視中前往下個月,依此類推'; + + @override + String get shortcutPreviousPeriod => '上一時段'; + + @override + String get shortcutPreviousPeriodDescription => '在週檢視中前往上一週,在月檢視中前往上個月,依此類推'; + + @override + String get shortcutJumpToToday => '跳至今天'; + + @override + String get shortcutGroupView => '檢視'; + + @override + String get shortcutDayView => '日檢視'; + + @override + String get shortcutWeekView => '週檢視'; + + @override + String get shortcutMonthView => '月檢視'; + + @override + String get shortcutYearView => '年檢視'; + + @override + String get shortcutAgendaView => '行程檢視'; + + @override + String get shortcutGroupCreateAndEdit => '新增和編輯'; + + @override + String get shortcutSaveItem => '儲存活動或待辦事項'; + + @override + String get shortcutDeleteItem => '刪除活動或待辦事項'; + + @override + String get shortcutGroupTaskEditing => '待辦事項編輯'; + + @override + String get shortcutCancelEditing => '取消編輯'; + + @override + String get shortcutCancelEditingDescription => '關閉待辦事項編輯或詳細資料'; + + @override + String get aboutBusyMax => '關於 BusyMax'; + + @override + String get aboutBusyMaxDescription => '行事曆與待辦事項'; + + @override + String get license => '授權'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => '網站'; + + @override + String get sourceCode => '原始碼'; + + @override + String get reportAnIssue => '回報問題'; + + @override + String get sendFeedback => '傳送意見'; + + @override + String get feedbackSubmit => '提交'; + + @override + String get feedbackCategory => '類別'; + + @override + String get feedbackSelectCategory => '選擇類別'; + + @override + String get feedbackCategoryProblem => '問題或錯誤'; + + @override + String get feedbackCategoryFeature => '功能請求'; + + @override + String get feedbackCategoryPrivacySecurity => '隱私權或安全性疑慮'; + + @override + String get feedbackCategoryUsability => '易用性疑慮'; + + @override + String get feedbackCategoryOther => '其他'; + + @override + String get feedbackSubject => '主旨'; + + @override + String get feedbackDetailedMessage => '詳細訊息'; + + @override + String get feedbackReplyEmail => '回覆用電子郵件地址(選填)'; + + @override + String get feedbackIncludeTechnicalDetails => '包含技術詳細資料'; + + @override + String get feedbackTechnicalDetailsDisclosure => + '只會加入您的 Linux 作業系統版本和應用程式語系。不會包含記錄、帳戶資料、檔案名稱或其他診斷資訊。'; + + @override + String get feedbackCategoryRequired => '請選擇類別。'; + + @override + String get feedbackSubjectLengthError => '主旨必須介於 3 到 120 個字元之間。'; + + @override + String get feedbackMessageLengthError => '訊息必須介於 10 到 5,000 個字元之間。'; + + @override + String get feedbackInvalidEmail => '請輸入有效的電子郵件地址。'; + + @override + String get feedbackConnectionError => '無法連線至 BusyStack。請檢查連線,然後再試一次。'; + + @override + String get feedbackTimeoutError => '要求逾時。您的意見尚未清除,請再試一次。'; + + @override + String get feedbackRateLimitedError => '此網路已傳送太多意見。請稍候再試。'; + + @override + String get feedbackRejectedError => '伺服器拒絕了提交內容。請檢查各欄位,然後再試一次。'; + + @override + String get feedbackServerError => 'BusyStack 目前無法接收您的意見。您的意見尚未清除,請再試一次。'; + + @override + String feedbackSuccess(String id) { + return '意見已傳送。參考編號:$id'; + } + + @override + String get toggleSidebar => '顯示或隱藏側邊欄'; + + @override + String get showSidebar => '顯示側邊欄面板'; + + @override + String get hideSidebar => '隱藏側邊欄面板'; + + @override + String get accounts => '帳戶'; + + @override + String get currentAccount => '目前帳戶'; + + @override + String get switchAccount => '切換帳戶'; + + @override + String get addGoogleAccount => '新增 Google 帳戶'; + + @override + String get addMicrosoftAccount => '新增 Microsoft 帳戶'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => '已登入'; + + @override + String get removeAccount => '移除帳戶…'; + + @override + String get removingAccount => '正在移除帳戶…'; + + @override + String get removeAccountDescription => '停止同步並從此裝置移除此帳戶的資料。'; + + @override + String removeAccountTitle(String account) { + return '要從 BusyMax 移除 $account 嗎?'; + } + + @override + String get removeAccountConfirmation => + '這會從此裝置刪除快取的待辦事項、行事曆、活動、提醒和待處理的離線變更。未同步的變更將會遺失。不會從 Google 或 Microsoft 刪除任何內容。'; + + @override + String get revokeGoogleAccess => '同時撤銷 BusyMax 對此 Google 帳戶的存取權'; + + @override + String get revokeGoogleAccessDescription => '重新連結前,您必須再次授予存取權。'; + + @override + String get removeAccountAction => '移除帳戶'; + + @override + String get removeAccountFailed => '無法完成帳戶移除。請再試一次。'; + + @override + String get accountRemovedGoogleRevokeFailed => + '該帳戶已從此裝置移除,但無法撤銷 BusyMax 對您的 Google 帳戶的存取權。您可以在 Google 帳戶中手動撤銷該權限。'; + + @override + String get newList => '新增清單'; + + @override + String get signInToViewTaskLists => '登入以查看待辦清單。'; + + @override + String get noTaskListsSynced => '尚未同步任何待辦清單。'; + + @override + String get listActions => '清單動作'; + + @override + String get rename => '重新命名'; + + @override + String get delete => '刪除'; + + @override + String get renameList => '重新命名清單'; + + @override + String get deleteList => '刪除清單'; + + @override + String get builtInMicrosoftList => '內建'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + '無法重新命名或刪除 Microsoft To Do 內建清單。'; + + @override + String deleteListConfirmation(String title) { + return '要從 Google Tasks 刪除「$title」嗎?'; + } + + @override + String get deleteEvent => '刪除活動'; + + @override + String get title => '標題'; + + @override + String get create => '新增'; + + @override + String get newTask => '新增待辦事項'; + + @override + String get clearCompleted => '清除已完成項目'; + + @override + String get refreshList => '重新整理清單'; + + @override + String get refreshAll => '全部重新整理'; + + @override + String get listRefreshed => '清單已重新整理。'; + + @override + String get allTasksRefreshed => '所有帳戶都已重新整理。'; + + @override + String exportedFile(String path) { + return '已匯出至 $path'; + } + + @override + String exportFailed(String error) { + return '匯出失敗:$error'; + } + + @override + String refreshFailed(String error) { + return '重新整理失敗:$error'; + } + + @override + String get selectOrCreateTaskList => '請選擇或建立待辦清單以開始使用。'; + + @override + String get signInToViewTasks => '登入以查看待辦事項。'; + + @override + String get noTasks => '沒有待辦事項。'; + + @override + String get noTasksYet => '還沒有待辦事項'; + + @override + String get noTasksYetMessage => '建立待辦事項或重新整理帳戶以開始使用。'; + + @override + String get noTasksInList => '此清單中沒有待辦事項。'; + + @override + String get overdue => '已逾期'; + + @override + String get today => '今天'; + + @override + String get tomorrow => '明天'; + + @override + String get upcoming => '即將到期'; + + @override + String get noDate => '無日期'; + + @override + String get completed => '已完成'; + + @override + String duePrefix(String date) { + return '$date 到期'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => '待辦事項詳細資料'; + + @override + String get editTask => '編輯待辦事項'; + + @override + String get noTaskSelected => '未選取待辦事項。'; + + @override + String get noTaskSelectedHelper => '選擇待辦事項以查看和編輯詳細資料。'; + + @override + String get taskUnavailable => '無法使用待辦事項。'; + + @override + String get signInToEditTasks => '登入以編輯待辦事項。'; + + @override + String get refreshTask => '重新整理待辦事項'; + + @override + String get primarySection => '主要資訊'; + + @override + String get statusSection => '狀態'; + + @override + String get openStatus => '未完成'; + + @override + String get doneStatus => '已完成'; + + @override + String get notes => '備註'; + + @override + String get dueDate => '到期日'; + + @override + String get clearDueDate => '清除到期日'; + + @override + String get dueTime => '到期時間'; + + @override + String get startDate => '開始日期'; + + @override + String get startTime => '開始時間'; + + @override + String get endDate => '結束日期'; + + @override + String get endTime => '結束時間'; + + @override + String get reminderDate => '提醒日期'; + + @override + String get reminderTime => '提醒時間'; + + @override + String get reminder => '提醒'; + + @override + String get addReminder => '新增提醒'; + + @override + String get addGuest => '新增參與者'; + + @override + String get addGuestEmail => '新增參與者電子郵件'; + + @override + String get removeReminder => '移除提醒'; + + @override + String get off => '關閉'; + + @override + String get repeat => '重複'; + + @override + String get repeatNone => '不重複'; + + @override + String get noneValue => '無'; + + @override + String get repeatDaily => '每天'; + + @override + String get repeatWeekly => '每週'; + + @override + String get repeatMonthly => '每月'; + + @override + String get repeatYearly => '每年'; + + @override + String get importance => '重要性'; + + @override + String get importanceLow => '低'; + + @override + String get importanceNormal => '一般'; + + @override + String get importanceHigh => '高'; + + @override + String get categories => '類別'; + + @override + String get scheduleSection => '行程'; + + @override + String get dueGroup => '到期'; + + @override + String get startGroup => '開始'; + + @override + String get reminderGroup => '提醒'; + + @override + String get organizationSection => '整理'; + + @override + String get actionsSection => '動作'; + + @override + String get advancedSection => '進階'; + + @override + String get addCategory => '新增類別'; + + @override + String get list => '清單'; + + @override + String get microsoftMoveUnsupported => + '此版本不支援在 Microsoft To Do 帳戶的清單之間移動待辦事項。'; + + @override + String get createSubtask => '建立子待辦事項'; + + @override + String get moveToTop => '移至頂端'; + + @override + String get deleteTask => '刪除待辦事項'; + + @override + String get newSubtask => '新增子待辦事項'; + + @override + String deleteTaskConfirmation(String title) { + return '要從 Google Tasks 刪除「$title」嗎?'; + } + + @override + String get metadata => '中繼資料'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => '更新時間'; + + @override + String get parent => '上層待辦事項'; + + @override + String get position => '位置'; + + @override + String get webLink => '網頁連結'; + + @override + String get assignment => '指派'; + + @override + String get localState => '本機狀態'; + + @override + String get pendingSync => '等候同步'; + + @override + String get synced => '已同步'; + + @override + String get account => '帳戶'; + + @override + String get sync => '同步'; + + @override + String get manualFullSync => '手動完整同步'; + + @override + String get runInBackgroundWhenClosed => '視窗關閉後繼續在背景執行'; + + @override + String get showTrayIcon => '顯示系統匣圖示'; + + @override + String get startMinimizedToTray => '啟動時最小化至系統匣'; + + @override + String get requiresTrayIcon => '需要系統匣圖示。'; + + @override + String get syncComplete => '同步完成。'; + + @override + String syncFailed(String error) { + return '同步失敗:$error'; + } + + @override + String get notifySyncFailures => '同步失敗通知'; + + @override + String get notifyConflicts => '衝突通知'; + + @override + String get notifyDueToday => '今天到期待辦事項通知'; + + @override + String get eventReminders => '活動提醒'; + + @override + String get taskReminders => '待辦事項提醒'; + + @override + String get notificationDetailLevel => '通知詳細程度'; + + @override + String get notificationDetailPrivate => '私人'; + + @override + String get notificationDetailNormal => '一般'; + + @override + String get quietHours => '勿擾時段'; + + @override + String get quietHoursDescription => '在此時段暫停通知。'; + + @override + String get quietHoursStart => '勿擾開始時間'; + + @override + String get quietHoursEnd => '勿擾結束時間'; + + @override + String get notifications => '通知'; + + @override + String get appearance => '外觀'; + + @override + String get theme => '主題'; + + @override + String get themeSystem => '系統'; + + @override + String get themeLight => '淺色'; + + @override + String get themeDark => '深色'; + + @override + String get themeFamily => '主題系列'; + + @override + String get themeFamilyYaru => 'Ubuntu 原生主題(Yaru)'; + + @override + String get localization => '語言與地區'; + + @override + String get currentLocale => '目前語系'; + + @override + String get privacy => '隱私權'; + + @override + String get redactTaskContentInDiagnostics => '在診斷資訊中隱藏待辦事項內容'; + + @override + String get developerDiagnostics => '開發人員診斷'; + + @override + String get diagnostics => '診斷'; + + @override + String get apiInspectorDisabled => '顯示 API 檢查器'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'Discovery 修訂版本:$revision'; + } + + @override + String get implementedMethods => '已實作的方法'; + + @override + String get supportsTasksScopes => '支援 tasks 和 tasks.readonly 權限範圍'; + + @override + String get requiresTasksScope => '需要 tasks 權限範圍'; + + @override + String get blockedPendingOperations => '遭封鎖的待處理作業'; + + @override + String get signInToInspectPendingOperations => '登入以檢查待處理作業。'; + + @override + String get noBlockedPendingOperations => '沒有遭封鎖的待處理作業。'; + + @override + String get operationActions => '操作選項'; + + @override + String pendingOpListId(String id) { + return '清單=$id'; + } + + @override + String pendingOpTaskId(String id) { + return '待辦事項=$id'; + } + + @override + String pendingOpAttempts(int count) { + return '嘗試次數=$count'; + } + + @override + String get retry => '再試一次'; + + @override + String get discard => '捨棄'; + + @override + String get discardChangesAction => '捨棄'; + + @override + String get discardChanges => '要捨棄變更嗎?'; + + @override + String get discardChangesConfirmation => '這會捨棄此待辦事項中尚未儲存的編輯內容。'; + + @override + String get retryCompleted => '重試完成。'; + + @override + String get discardPendingOperation => '要捨棄待處理作業嗎?'; + + @override + String get discardPendingOperationConfirmation => + '這會移除遭封鎖的本機作業。下次同步時將從 Google Tasks 重新整理資料。'; + + @override + String get pendingOperationDiscarded => '已捨棄待處理作業。'; + + @override + String get syncFailureNotificationTitle => 'BusyMax 同步失敗'; + + @override + String syncFailureNotificationBody(String message) { + return '背景同步失敗。$message'; + } + + @override + String get conflictNotificationTitle => 'BusyMax 同步衝突'; + + @override + String conflictNotificationBody(String summary) { + return '一項待處理的本機變更遭到封鎖。$summary'; + } + + @override + String get dueTodayNotificationTitle => '今天到期的待辦事項'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '今天有 $count 項待辦事項到期。', + one: '今天有 1 項待辦事項到期。', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => '活動提醒'; + + @override + String get taskReminderNotificationTitle => '待辦事項提醒'; + + @override + String get eventReminderNotificationBody => '活動即將開始。'; + + @override + String get taskReminderNotificationBody => '待辦事項即將到期。'; + + @override + String get notificationOpenAction => '開啟'; + + @override + String get notificationDetailsHidden => '根據隱私權設定,詳細資料已隱藏。'; + + @override + String get previousMonth => '上個月'; + + @override + String get nextMonth => '下個月'; + + @override + String get openMonthView => '開啟月檢視'; + + @override + String get previousYear => '上一年'; + + @override + String get nextYear => '下一年'; + + @override + String get openYearView => '開啟年檢視'; + + @override + String weekNumberTooltip(int number) { + return '第 $number 週'; + } + + @override + String get resizeAllDayPanel => '調整全天面板大小'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 項', + one: '1 項', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => '此行事曆為唯讀。'; + + @override + String get selectTimeZone => '選擇時區'; + + @override + String get searchLocations => '搜尋地點'; + + @override + String get noLocationsFound => '找不到地點'; + + @override + String deleteCalendarConfirmation(String title) { + return '要刪除「$title」嗎?'; + } +} diff --git a/lib/main.dart b/lib/main.dart index 9bc7b76..405f0f2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,52 +1,101 @@ -import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:system_theme/system_theme.dart'; -import 'package:window_manager/window_manager.dart'; import 'src/app/app_bootstrap.dart'; +import 'src/app/app_theme.dart'; import 'src/app/busymax_app.dart'; import 'src/config/build_config.dart'; import 'src/core/logging/redacting_logger.dart'; -import 'src/features/schedule/application/compact_agenda_data.dart'; -import 'src/features/schedule/presentation/compact_agenda_app.dart'; -import 'src/platform/main_window_command_client.dart'; -import 'src/platform/busymax_window_args.dart'; +import 'src/demo/demo_profile.dart'; +import 'src/platform/gtk_font_service.dart'; +import 'src/platform/linux_header_bar_service.dart'; -Future main(List args) async { - WidgetsFlutterBinding.ensureInitialized(); - await SystemTheme.accentColor.load(); +Future main() async { + final binding = WidgetsFlutterBinding.ensureInitialized(); + binding.deferFirstFrame(); + final buildConfig = BuildConfig.fromEnvironment(); + final LocalSettingsStore settingsStore; + if (buildConfig.useFakeProviderData) { + final settings = busyMaxDemoSettings(buildConfig.demoTheme); + settingsStore = InMemoryLocalSettingsStore(settings.toJson()); + } else { + settingsStore = const JsonFileLocalSettingsStore(); + } + + final systemAccentFuture = SystemTheme.accentColor.load(); + final initialGtkFontFuture = const GtkFontService().getGtkFont(); + final initialAppSettingsFuture = loadInitialAppSettings(settingsStore); + final initialAppSettings = await initialAppSettingsFuture; + final gtkThemeService = const GtkThemeService(); + await gtkThemeService.setPreferDark( + switch (initialAppSettings.themeModePreference) { + BusyMaxThemeModePreference.system => null, + BusyMaxThemeModePreference.light => false, + BusyMaxThemeModePreference.dark => true, + }, + ); + final desktopSettings = await Future.wait([ + systemAccentFuture, + initialGtkFontFuture, + gtkThemeService.getGtkThemeColors(), + ]); configureLogging(); - final windowController = await WindowController.fromCurrentEngine(); - final windowArgs = BusyMaxWindowArgs.parse(windowController.arguments); + final initialGtkFont = desktopSettings[1] as GtkFontSettings?; + final initialGtkThemeColors = desktopSettings[2] as GtkThemeColors?; + await _applyInitialNativeHeaderBarTheme( + settings: initialAppSettings, + gtkFont: initialGtkFont, + gtkThemeColors: initialGtkThemeColors, + ); + final overrides = [ - buildConfigProvider.overrideWithValue(BuildConfig.fromEnvironment()), + buildConfigProvider.overrideWithValue(buildConfig), + localSettingsStoreProvider.overrideWithValue(settingsStore), + initialAppSettingsProvider.overrideWithValue(initialAppSettings), + initialGtkFontSettingsProvider.overrideWithValue(initialGtkFont), + initialGtkThemeColorsProvider.overrideWithValue(initialGtkThemeColors), ]; + final demoProfile = buildConfig.useFakeProviderData + ? await BusyMaxDemoProfile.create() + : null; + final applicationOverrides = [...overrides, ...?demoProfile?.overrides]; - switch (windowArgs.kind) { - case BusyMaxWindowKind.main: - runApp(ProviderScope(overrides: overrides, child: const BusyMaxApp())); - case BusyMaxWindowKind.compactAgenda: - await configureCompactAgendaNativeWindow(); - runApp( - ProviderScope( - overrides: [ - ...overrides, - compactAgendaDataLoaderProvider.overrideWithValue( - (ref, query) => - const MainWindowCommandClient().compactAgendaSnapshot(query), - ), - ], - child: BusyMaxCompactAgendaApp( - windowController: windowController, - windowArgs: windowArgs, - ), - ), - ); - } + runApp( + ProviderScope(overrides: applicationOverrides, child: const BusyMaxApp()), + ); + binding.allowFirstFrame(); } -Future configureCompactAgendaNativeWindow() async { - await windowManager.ensureInitialized(); +Future _applyInitialNativeHeaderBarTheme({ + required AppSettings settings, + required GtkFontSettings? gtkFont, + required GtkThemeColors? gtkThemeColors, +}) async { + final platformDispatcher = WidgetsBinding.instance.platformDispatcher; + final brightness = switch (settings.themeModePreference) { + BusyMaxThemeModePreference.system => platformDispatcher.platformBrightness, + BusyMaxThemeModePreference.light => Brightness.light, + BusyMaxThemeModePreference.dark => Brightness.dark, + }; + final highContrast = platformDispatcher.accessibilityFeatures.highContrast; + final theme = buildBusyMaxTheme( + brightness: brightness, + accentColor: gtkThemeColors?.accent ?? SystemTheme.accentColor.accent, + family: settings.themeFamily, + gtkFontFamily: gtkFont?.family, + gtkFontSize: gtkFont?.size, + gtkThemeColors: gtkThemeColors, + highContrast: highContrast, + ); + final headerBarService = LinuxHeaderBarService(); + try { + await headerBarService.initialize(); + await headerBarService.setTheme( + busyMaxHeaderBarThemeFor(theme, highContrast: highContrast), + ); + } finally { + headerBarService.dispose(); + } } diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 0baa7e6..29d2dc4 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -15,6 +15,7 @@ import '../features/auth/data/auth_repository.dart'; import '../features/feedback/data/feedback_api_client.dart'; import '../features/notifications/desktop_notification_service.dart'; import '../features/notifications/notification_scheduler.dart'; +import '../features/sync/account_sync_operations.dart'; import '../features/sync/all_accounts_sync_scheduler.dart'; import '../features/sync/calendar_sync_engine.dart'; import '../features/sync/pending_mutation_sync_requester.dart'; @@ -35,8 +36,6 @@ import '../microsoft_calendar/microsoft_calendar_api_client.dart'; import '../microsoft_todo/api/microsoft_todo_api_client.dart'; import '../microsoft_todo/api/microsoft_todo_google_tasks_adapter.dart'; import '../microsoft_todo/oauth/microsoft_oauth_service.dart'; -import '../platform/compact_agenda_window_service.dart'; -import '../platform/linux_header_bar_service.dart'; import '../platform/linux_window_service.dart'; import '../task_providers/task_provider.dart'; import '../schedule/schedule_commands.dart'; @@ -45,6 +44,7 @@ import 'app_router.dart'; import 'app_settings.dart'; export '../app/app_settings.dart'; +export '../platform/linux_header_bar_provider.dart'; final buildConfigProvider = Provider( (ref) => BuildConfig.fromEnvironment(), @@ -100,6 +100,10 @@ final applicationOAuthServiceProvider = Provider((ref) { final oAuthServiceProvider = applicationOAuthServiceProvider; +final applicationOAuthGatewayProvider = Provider( + (ref) => ref.watch(applicationOAuthServiceProvider), +); + final microsoftOAuthServiceProvider = Provider((ref) { return MicrosoftOAuthService( config: ref.watch(buildConfigProvider), @@ -109,6 +113,11 @@ final microsoftOAuthServiceProvider = Provider((ref) { ); }); +final applicationMicrosoftOAuthServiceProvider = + Provider( + (ref) => ref.watch(microsoftOAuthServiceProvider), + ); + final authenticatedHttpClientProvider = Provider((ref) { return AuthenticatedHttpClient( inner: ref.watch(retryingHttpClientProvider), @@ -128,9 +137,11 @@ final desktopNotificationBackendProvider = Provider( final desktopNotificationServiceProvider = Provider( (ref) { + final settings = ref.watch(appSettingsControllerProvider); return DesktopNotificationService( backend: ref.watch(desktopNotificationBackendProvider), - settings: ref.watch(appSettingsControllerProvider), + settings: settings, + locale: settings.locale, ); }, ); @@ -139,24 +150,12 @@ final linuxWindowServiceProvider = Provider( (ref) => const LinuxWindowService(), ); -final linuxHeaderBarServiceProvider = Provider((ref) { - final service = LinuxHeaderBarService(); - ref.onDispose(service.dispose); - return service; -}); - -final compactAgendaWindowServiceProvider = Provider( - (ref) { - return const CompactAgendaWindowService(); - }, -); - final authRepositoryProvider = Provider((ref) { return AuthRepository( - oAuth: ref.watch(applicationOAuthServiceProvider), + oAuth: ref.watch(applicationOAuthGatewayProvider), database: ref.watch(databaseProvider), accountsRepository: ref.watch(accountsRepositoryProvider), - microsoftOAuth: ref.watch(microsoftOAuthServiceProvider), + microsoftOAuth: ref.watch(applicationMicrosoftOAuthServiceProvider), ); }); @@ -270,22 +269,49 @@ final microsoftAsGoogleTasksApiClientForAccountProvider = ); }); -typedef SyncEngineForAccountFactory = SyncEngine Function(String accountId); +final taskRemoteApiClientForAccountProvider = + Provider.family((ref, accountId) { + final accounts = ref.watch(accountsStreamProvider).valueOrNull; + AccountEntity? account; + for (final candidate in accounts ?? const []) { + if (candidate.id == accountId) { + account = candidate; + break; + } + } + if (account == null) { + return null; + } + return switch (account.provider) { + TaskProvider.microsoft => ref.watch( + microsoftAsGoogleTasksApiClientForAccountProvider(accountId), + ), + TaskProvider.google => ref.watch( + googleTasksApiClientForAccountProvider(accountId), + ), + }; + }); + +typedef SyncEngineForAccountFactory = + SyncEngine Function(String accountId, TaskProvider provider); final syncEngineForAccountFactoryProvider = Provider((ref) { - return (accountId) { - final apiClient = accountId.startsWith('microsoft:') - ? ref.read( - microsoftAsGoogleTasksApiClientForAccountProvider(accountId), - ) - : ref.read(googleTasksApiClientForAccountProvider(accountId)); + return (accountId, provider) { + final apiClient = switch (provider) { + TaskProvider.microsoft => ref.read( + microsoftAsGoogleTasksApiClientForAccountProvider(accountId), + ), + TaskProvider.google => ref.read( + googleTasksApiClientForAccountProvider(accountId), + ), + }; return SyncEngine( database: ref.read(databaseProvider), apiClient: apiClient, accountId: accountId, - fullRefreshOnly: accountId.startsWith('microsoft:'), + fullRefreshOnly: provider == TaskProvider.microsoft, onConflictBlocked: ref .read(desktopNotificationServiceProvider) .notifyConflict, @@ -294,14 +320,19 @@ final syncEngineForAccountFactoryProvider = }); typedef CalendarSyncEngineForAccountFactory = - CalendarSyncEngine Function(String accountId); + CalendarSyncEngine Function(String accountId, TaskProvider provider); final calendarSyncEngineForAccountFactoryProvider = Provider((ref) { - return (accountId) { - final client = accountId.startsWith('microsoft:') - ? ref.read(microsoftCalendarApiClientForAccountProvider(accountId)) - : ref.read(googleCalendarApiClientForAccountProvider(accountId)); + return (accountId, provider) { + final client = switch (provider) { + TaskProvider.microsoft => ref.read( + microsoftCalendarApiClientForAccountProvider(accountId), + ), + TaskProvider.google => ref.read( + googleCalendarApiClientForAccountProvider(accountId), + ), + }; return CalendarSyncEngine( database: ref.read(databaseProvider), client: client, @@ -315,23 +346,54 @@ final calendarSyncEngineForAccountFactoryProvider = }; }); +final accountSyncOperationsProvider = Provider((ref) { + final accountsRepository = ref.watch(accountsRepositoryProvider); + + Future providerForAccount(String accountId) async { + final account = await accountsRepository.accountById(accountId); + if (account == null) { + throw StateError('Account $accountId is unavailable.'); + } + return account.provider; + } + + return DelegatingAccountSyncOperations( + syncTasks: (accountId, {required full}) async { + final provider = await providerForAccount(accountId); + final engine = ref.read(syncEngineForAccountFactoryProvider)( + accountId, + provider, + ); + if (full) { + await engine.fullSync(); + } else { + await engine.incrementalSync(); + } + }, + syncCalendar: (accountId, {required full}) async { + final provider = await providerForAccount(accountId); + final engine = ref.read(calendarSyncEngineForAccountFactoryProvider)( + accountId, + provider, + ); + if (full) { + await engine.fullSync(); + } else { + await engine.incrementalSync(); + } + }, + ); +}); + typedef SignedInSyncRunner = Future Function(String accountId, bool initial); final signedInSyncRunnerProvider = Provider((ref) { return (accountId, initial) async { - final syncEngine = ref.read(syncEngineForAccountFactoryProvider)(accountId); - final calendarSyncEngine = ref.read( - calendarSyncEngineForAccountFactoryProvider, - )(accountId); try { - if (initial) { - await syncEngine.fullSync(); - await calendarSyncEngine.fullSync(); - } else { - await syncEngine.incrementalSync(); - await calendarSyncEngine.incrementalSync(); - } + await ref + .read(accountSyncOperationsProvider) + .syncAccount(accountId, full: initial); } on Object catch (error) { await _markAccountReconnectRequiredIfMissingSyncToken( ref, @@ -348,11 +410,8 @@ typedef AllAccountsSyncRunner = Future Function(); final allAccountsSyncRunnerProvider = Provider((ref) { Future syncAccount(String accountId) async { await ref - .read(syncEngineForAccountFactoryProvider)(accountId) - .incrementalSync(); - await ref - .read(calendarSyncEngineForAccountFactoryProvider)(accountId) - .incrementalSync(); + .read(accountSyncOperationsProvider) + .syncAccount(accountId, full: false); } return () { @@ -412,20 +471,7 @@ final googleTasksApiClientProvider = Provider((ref) { if (accountId == null) { return null; } - final selectedAccount = ref.watch(selectedAccountProvider); - final provider = - selectedAccount?.provider ?? - (accountId.startsWith('microsoft:') - ? TaskProvider.microsoft - : TaskProvider.google); - return switch (provider) { - TaskProvider.microsoft => ref.watch( - microsoftAsGoogleTasksApiClientForAccountProvider(accountId), - ), - TaskProvider.google => ref.watch( - googleTasksApiClientForAccountProvider(accountId), - ), - }; + return ref.watch(taskRemoteApiClientForAccountProvider(accountId)); }); final taskListsRepositoryProvider = Provider((ref) { @@ -466,15 +512,10 @@ final tasksRepositoryProvider = Provider((ref) { final tasksRepositoryForAccountProvider = Provider.family((ref, accountId) { - final apiClient = accountId.startsWith('microsoft:') - ? ref.watch( - microsoftAsGoogleTasksApiClientForAccountProvider(accountId), - ) - : ref.watch(googleTasksApiClientForAccountProvider(accountId)); return TasksRepository( database: ref.watch(databaseProvider), accountId: accountId, - apiClient: apiClient, + apiClient: ref.watch(taskRemoteApiClientForAccountProvider(accountId)), onMutationQueued: ref .watch(pendingMutationSyncRequesterForAccountProvider(accountId)) .request, @@ -486,19 +527,15 @@ final tasksRepositoryForAccountProvider = final Provider syncEngineProvider = Provider((ref) { final accountId = ref.watch(activeAccountProvider); final apiClient = ref.watch(googleTasksApiClientProvider); - if (accountId == null || apiClient == null) { + final account = ref.watch(selectedAccountProvider); + if (accountId == null || apiClient == null || account?.id != accountId) { return null; } - final provider = - ref.watch(selectedAccountProvider)?.provider ?? - (accountId.startsWith('microsoft:') - ? TaskProvider.microsoft - : TaskProvider.google); return SyncEngine( database: ref.watch(databaseProvider), apiClient: apiClient, accountId: accountId, - fullRefreshOnly: provider == TaskProvider.microsoft, + fullRefreshOnly: account!.provider == TaskProvider.microsoft, onConflictBlocked: ref .watch(desktopNotificationServiceProvider) .notifyConflict, @@ -508,13 +545,14 @@ final Provider syncEngineProvider = Provider((ref) { final pendingMutationSyncRequesterProvider = Provider((ref) { final accountId = ref.watch(activeAccountProvider); - final syncEngine = ref.watch(syncEngineProvider); - if (accountId == null || syncEngine == null) { + if (accountId == null) { return null; } final requester = PendingMutationSyncRequester( - sync: syncEngine.incrementalSync, + sync: () => ref + .read(accountSyncOperationsProvider) + .syncTasks(accountId, full: false), onSyncFailure: ref .watch(desktopNotificationServiceProvider) .notifySyncFailure, @@ -530,11 +568,10 @@ final pendingMutationSyncRequesterProvider = final pendingMutationSyncRequesterForAccountProvider = Provider.family((ref, accountId) { - final syncEngine = ref.watch(syncEngineForAccountFactoryProvider)( - accountId, - ); final requester = PendingMutationSyncRequester( - sync: syncEngine.incrementalSync, + sync: () => ref + .read(accountSyncOperationsProvider) + .syncTasks(accountId, full: false), onSyncFailure: ref .watch(desktopNotificationServiceProvider) .notifySyncFailure, @@ -567,11 +604,8 @@ final pendingOpResolutionServiceProvider = final syncSchedulerProvider = Provider((ref) { Future syncAccount(String accountId) async { await ref - .read(syncEngineForAccountFactoryProvider)(accountId) - .incrementalSync(); - await ref - .read(calendarSyncEngineForAccountFactoryProvider)(accountId) - .incrementalSync(); + .read(accountSyncOperationsProvider) + .syncAccount(accountId, full: false); } final scheduler = AllAccountsSyncScheduler( diff --git a/lib/src/app/app_router.dart b/lib/src/app/app_router.dart index d603c32..2891ffb 100644 --- a/lib/src/app/app_router.dart +++ b/lib/src/app/app_router.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:yaru/yaru.dart'; import '../features/auth/data/auth_repository.dart'; import '../features/auth/presentation/sign_in_screen.dart'; @@ -8,6 +9,9 @@ import '../features/settings/presentation/settings_screen.dart'; import '../features/schedule/presentation/schedule_workspace.dart'; import '../schedule/schedule_scope.dart'; import 'app_bootstrap.dart'; +import 'busymax_design.dart'; +import 'busymax_layout.dart'; +import 'busymax_surface_colors.dart'; final rootNavigatorKey = GlobalKey(); @@ -42,7 +46,10 @@ final appRouterProvider = Provider((ref) { return null; }, routes: [ - GoRoute(path: '/', builder: (context, state) => const _SplashScreen()), + GoRoute( + path: '/', + builder: (context, state) => const BusyMaxStartupView(), + ), GoRoute( path: '/sign-in', builder: (context, state) => const SignInScreen(), @@ -53,28 +60,31 @@ final appRouterProvider = Provider((ref) { ), GoRoute( path: '/tasks', - builder: (context, state) => - const ScheduleWorkspace(initialScope: ScheduleScope.tasks), - routes: [ - GoRoute( - path: ':listId', - builder: (context, state) => - const ScheduleWorkspace(initialScope: ScheduleScope.tasks), - routes: [ - GoRoute( - path: ':taskId', - builder: (context, state) => - const ScheduleWorkspace(initialScope: ScheduleScope.tasks), - ), - ], - ), - ], + pageBuilder: (context, state) => _tasksWorkspacePage(), + ), + GoRoute( + path: r'/tasks/:taskRoute(.*)', + redirect: (context, state) { + final segmentCount = state.uri.pathSegments.length; + return segmentCount == 3 || segmentCount == 4 ? null : '/tasks'; + }, + pageBuilder: (context, state) { + final segments = state.uri.pathSegments; + return _tasksWorkspacePage( + accountId: segments[1], + taskListId: segments[2], + taskId: segments.length == 4 ? segments[3] : null, + ); + }, ), GoRoute( path: '/settings', - builder: (context, state) => SettingsScreen( - initialPage: settingsPageFromRouteValue( - state.uri.queryParameters['page'], + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: SettingsScreen( + initialPage: settingsPageFromRouteValue( + state.uri.queryParameters['page'], + ), ), ), ), @@ -82,11 +92,54 @@ final appRouterProvider = Provider((ref) { ); }); -class _SplashScreen extends StatelessWidget { - const _SplashScreen(); +const _tasksWorkspacePageKey = ValueKey('tasks-workspace'); + +Page _tasksWorkspacePage({ + String? accountId, + String? taskListId, + String? taskId, +}) { + return NoTransitionPage( + key: _tasksWorkspacePageKey, + child: ScheduleWorkspace( + initialScope: ScheduleScope.tasks, + initialTaskAccountId: accountId, + initialTaskListId: taskListId, + initialTaskId: taskId, + ), + ); +} + +class BusyMaxStartupView extends StatelessWidget { + const BusyMaxStartupView({super.key}); @override Widget build(BuildContext context) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); + final colors = BusyMaxSurfaceColors.of(context); + return Scaffold( + backgroundColor: colors.window, + body: LayoutBuilder( + builder: (context, constraints) { + final content = ColoredBox( + key: const ValueKey('startup-content'), + color: colors.window, + child: const Center(child: YaruCircularProgressIndicator()), + ); + if (!BusyMaxLayoutRules.showSidebar(constraints.maxWidth)) { + return content; + } + return Row( + children: [ + const SizedBox( + key: ValueKey('startup-sidebar'), + width: BusyMaxSizes.sidebarWidth, + child: BusyMaxSidebarSurface(child: SizedBox.expand()), + ), + Expanded(child: content), + ], + ); + }, + ), + ); } } diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index 2fd7568..78629c9 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import '../l10n/app_locale.dart'; import '../schedule/schedule_view_mode.dart'; enum BusyMaxThemeFamily { yaru } @@ -18,6 +19,7 @@ enum NotificationDetailLevel { private, normal } const defaultScheduleDayStartMinute = 7 * 60; const defaultScheduleDayEndMinute = 22 * 60; +const Object _unset = Object(); extension BusyMaxThemeModePreferenceX on BusyMaxThemeModePreference { ThemeMode get themeMode { @@ -33,6 +35,7 @@ class AppSettings { const AppSettings({ required this.themeFamily, required this.themeModePreference, + required this.localeTag, required this.notifySyncFailures, required this.notifyConflicts, required this.notifyDueToday, @@ -47,7 +50,6 @@ class AppSettings { required this.quietHoursStart, required this.quietHoursEnd, required this.redactTaskContentInDiagnostics, - required this.detailedNotifications, required this.lastDueTodayNotificationDate, required this.taskListScheduleVisibility, required this.scheduleViewMode, @@ -59,6 +61,7 @@ class AppSettings { return const AppSettings( themeFamily: BusyMaxThemeFamily.yaru, themeModePreference: BusyMaxThemeModePreference.system, + localeTag: null, notifySyncFailures: true, notifyConflicts: true, notifyDueToday: false, @@ -73,7 +76,6 @@ class AppSettings { quietHoursStart: '22:00', quietHoursEnd: '07:00', redactTaskContentInDiagnostics: true, - detailedNotifications: false, lastDueTodayNotificationDate: null, taskListScheduleVisibility: {}, scheduleViewMode: ScheduleViewMode.week, @@ -102,16 +104,37 @@ class AppSettings { fallbackStart: defaults.scheduleDayStartMinute, fallbackEnd: defaults.scheduleDayEndMinute, ); - final detailedNotifications = - json['detailedNotifications'] as bool? ?? - defaults.detailedNotifications; - final notificationDetailLevel = detailedNotifications - ? NotificationDetailLevel.normal - : _enumFromName( - NotificationDetailLevel.values, - json['notificationDetailLevel'], - defaults.notificationDetailLevel, - ); + final notificationDetailLevel = + _enumFromNameOrNull( + NotificationDetailLevel.values, + json['notificationDetailLevel'], + ) ?? + switch (json['detailedNotifications']) { + true => NotificationDetailLevel.normal, + false => NotificationDetailLevel.private, + _ => defaults.notificationDetailLevel, + }; + var quietHoursStart = _normalizedTimeOfDay( + json['quietHoursStart'], + defaults.quietHoursStart, + ); + var quietHoursEnd = _normalizedTimeOfDay( + json['quietHoursEnd'], + defaults.quietHoursEnd, + ); + if (quietHoursStart == quietHoursEnd) { + quietHoursStart = defaults.quietHoursStart; + quietHoursEnd = defaults.quietHoursEnd; + } + final runInBackgroundWhenClosed = + json['runInBackgroundWhenClosed'] as bool? ?? + defaults.runInBackgroundWhenClosed; + final startMinimizedToTray = + json['startMinimizedToTray'] as bool? ?? defaults.startMinimizedToTray; + final showTrayIcon = + (json['showTrayIcon'] as bool? ?? defaults.showTrayIcon) || + runInBackgroundWhenClosed || + startMinimizedToTray; return AppSettings( themeFamily: _enumFromName( BusyMaxThemeFamily.values, @@ -123,6 +146,7 @@ class AppSettings { json['themeModePreference'], defaults.themeModePreference, ), + localeTag: normalizeBusyMaxLocaleTag(json['localeTag']?.toString()), notifySyncFailures: json['notifySyncFailures'] as bool? ?? defaults.notifySyncFailures, notifyConflicts: @@ -134,26 +158,19 @@ class AppSettings { defaults.notifyEventReminders, notifyTaskReminders: json['notifyTaskReminders'] as bool? ?? defaults.notifyTaskReminders, - runInBackgroundWhenClosed: - json['runInBackgroundWhenClosed'] as bool? ?? - defaults.runInBackgroundWhenClosed, - showTrayIcon: json['showTrayIcon'] as bool? ?? defaults.showTrayIcon, - startMinimizedToTray: - json['startMinimizedToTray'] as bool? ?? - defaults.startMinimizedToTray, + runInBackgroundWhenClosed: runInBackgroundWhenClosed, + showTrayIcon: showTrayIcon, + startMinimizedToTray: startMinimizedToTray, quitExitsCompletely: json['quitExitsCompletely'] as bool? ?? defaults.quitExitsCompletely, notificationDetailLevel: notificationDetailLevel, quietHoursEnabled: json['quietHoursEnabled'] as bool? ?? defaults.quietHoursEnabled, - quietHoursStart: - json['quietHoursStart']?.toString() ?? defaults.quietHoursStart, - quietHoursEnd: - json['quietHoursEnd']?.toString() ?? defaults.quietHoursEnd, + quietHoursStart: quietHoursStart, + quietHoursEnd: quietHoursEnd, redactTaskContentInDiagnostics: json['redactTaskContentInDiagnostics'] as bool? ?? defaults.redactTaskContentInDiagnostics, - detailedNotifications: detailedNotifications, lastDueTodayNotificationDate: json['lastDueTodayNotificationDate'] ?.toString(), taskListScheduleVisibility: _boolMap(json['taskListScheduleVisibility']), @@ -169,6 +186,7 @@ class AppSettings { final BusyMaxThemeFamily themeFamily; final BusyMaxThemeModePreference themeModePreference; + final String? localeTag; final bool notifySyncFailures; final bool notifyConflicts; final bool notifyDueToday; @@ -183,7 +201,6 @@ class AppSettings { final String quietHoursStart; final String quietHoursEnd; final bool redactTaskContentInDiagnostics; - final bool detailedNotifications; final String? lastDueTodayNotificationDate; final Map taskListScheduleVisibility; final ScheduleViewMode scheduleViewMode; @@ -192,10 +209,13 @@ class AppSettings { ThemeMode get themeMode => themeModePreference.themeMode; + Locale? get locale => busyMaxLocaleFromTag(localeTag); + Map toJson() { return { 'themeFamily': themeFamily.name, 'themeModePreference': themeModePreference.name, + 'localeTag': localeTag, 'notifySyncFailures': notifySyncFailures, 'notifyConflicts': notifyConflicts, 'notifyDueToday': notifyDueToday, @@ -210,7 +230,6 @@ class AppSettings { 'quietHoursStart': quietHoursStart, 'quietHoursEnd': quietHoursEnd, 'redactTaskContentInDiagnostics': redactTaskContentInDiagnostics, - 'detailedNotifications': detailedNotifications, 'lastDueTodayNotificationDate': lastDueTodayNotificationDate, 'taskListScheduleVisibility': taskListScheduleVisibility, 'scheduleViewMode': scheduleViewMode.name, @@ -222,6 +241,7 @@ class AppSettings { AppSettings copyWith({ BusyMaxThemeFamily? themeFamily, BusyMaxThemeModePreference? themeModePreference, + Object? localeTag = _unset, bool? notifySyncFailures, bool? notifyConflicts, bool? notifyDueToday, @@ -236,7 +256,6 @@ class AppSettings { String? quietHoursStart, String? quietHoursEnd, bool? redactTaskContentInDiagnostics, - bool? detailedNotifications, String? lastDueTodayNotificationDate, Map? taskListScheduleVisibility, ScheduleViewMode? scheduleViewMode, @@ -256,6 +275,9 @@ class AppSettings { return AppSettings( themeFamily: themeFamily ?? this.themeFamily, themeModePreference: themeModePreference ?? this.themeModePreference, + localeTag: identical(localeTag, _unset) + ? this.localeTag + : normalizeBusyMaxLocaleTag(localeTag as String?), notifySyncFailures: notifySyncFailures ?? this.notifySyncFailures, notifyConflicts: notifyConflicts ?? this.notifyConflicts, notifyDueToday: notifyDueToday ?? this.notifyDueToday, @@ -273,8 +295,6 @@ class AppSettings { quietHoursEnd: quietHoursEnd ?? this.quietHoursEnd, redactTaskContentInDiagnostics: redactTaskContentInDiagnostics ?? this.redactTaskContentInDiagnostics, - detailedNotifications: - detailedNotifications ?? this.detailedNotifications, lastDueTodayNotificationDate: clearLastDueTodayNotificationDate ? null : lastDueTodayNotificationDate ?? this.lastDueTodayNotificationDate, @@ -332,9 +352,15 @@ class JsonFileLocalSettingsStore implements LocalSettingsStore { typedef _AppSettingsMutation = AppSettings Function(AppSettings current); class AppSettingsController extends StateNotifier { - AppSettingsController(this._store) : super(AppSettings.defaults()) { + AppSettingsController(this._store, {AppSettings? initialSettings}) + : super(initialSettings ?? AppSettings.defaults()) { _persistenceState = state; - _loadFuture = _load(); + if (initialSettings == null) { + _loadFuture = _load(); + } else { + _loadComplete = true; + _loadFuture = Future.value(); + } _writeTail = _loadFuture; } @@ -355,6 +381,10 @@ class AppSettingsController extends StateNotifier { ); } + Future setLocaleTag(String? localeTag) { + return _mutate((current) => current.copyWith(localeTag: localeTag)); + } + Future setScheduleViewMode(ScheduleViewMode mode) { return _mutate((current) => current.copyWith(scheduleViewMode: mode)); } @@ -413,17 +443,31 @@ class AppSettingsController extends StateNotifier { Future setRunInBackgroundWhenClosed(bool enabled) { return _mutate( - (current) => current.copyWith(runInBackgroundWhenClosed: enabled), + (current) => current.copyWith( + runInBackgroundWhenClosed: enabled, + showTrayIcon: enabled ? true : current.showTrayIcon, + ), ); } Future setShowTrayIcon(bool enabled) { - return _mutate((current) => current.copyWith(showTrayIcon: enabled)); + return _mutate( + (current) => current.copyWith( + showTrayIcon: enabled, + runInBackgroundWhenClosed: enabled + ? current.runInBackgroundWhenClosed + : false, + startMinimizedToTray: enabled ? current.startMinimizedToTray : false, + ), + ); } Future setStartMinimizedToTray(bool enabled) { return _mutate( - (current) => current.copyWith(startMinimizedToTray: enabled), + (current) => current.copyWith( + startMinimizedToTray: enabled, + showTrayIcon: enabled ? true : current.showTrayIcon, + ), ); } @@ -433,10 +477,7 @@ class AppSettingsController extends StateNotifier { Future setNotificationDetailLevel(NotificationDetailLevel level) { return _mutate( - (current) => current.copyWith( - notificationDetailLevel: level, - detailedNotifications: level != NotificationDetailLevel.private, - ), + (current) => current.copyWith(notificationDetailLevel: level), ); } @@ -444,20 +485,29 @@ class AppSettingsController extends StateNotifier { return _mutate((current) => current.copyWith(quietHoursEnabled: enabled)); } - Future setRedactTaskContentInDiagnostics(bool enabled) { - return _mutate( - (current) => current.copyWith(redactTaskContentInDiagnostics: enabled), - ); + Future setQuietHoursStart(String time) { + return _mutate((current) { + final normalized = _normalizedTimeOfDay(time, current.quietHoursStart); + if (normalized == current.quietHoursEnd) { + return current; + } + return current.copyWith(quietHoursStart: normalized); + }); } - Future setDetailedNotifications(bool enabled) { + Future setQuietHoursEnd(String time) { + return _mutate((current) { + final normalized = _normalizedTimeOfDay(time, current.quietHoursEnd); + if (normalized == current.quietHoursStart) { + return current; + } + return current.copyWith(quietHoursEnd: normalized); + }); + } + + Future setRedactTaskContentInDiagnostics(bool enabled) { return _mutate( - (current) => current.copyWith( - detailedNotifications: enabled, - notificationDetailLevel: enabled - ? NotificationDetailLevel.normal - : NotificationDetailLevel.private, - ), + (current) => current.copyWith(redactTaskContentInDiagnostics: enabled), ); } @@ -480,12 +530,7 @@ class AppSettingsController extends StateNotifier { } Future _load() async { - AppSettings loaded; - try { - loaded = AppSettings.fromJson(await _store.load()); - } on Object { - loaded = AppSettings.defaults(); - } + final loaded = await loadInitialAppSettings(_store); _persistenceState = loaded; var merged = loaded; @@ -547,9 +592,14 @@ final localSettingsStoreProvider = Provider( (ref) => const JsonFileLocalSettingsStore(), ); +final initialAppSettingsProvider = Provider((ref) => null); + final appSettingsControllerProvider = StateNotifierProvider((ref) { - return AppSettingsController(ref.watch(localSettingsStoreProvider)); + return AppSettingsController( + ref.watch(localSettingsStoreProvider), + initialSettings: ref.watch(initialAppSettingsProvider), + ); }); final busyMaxThemeControllerProvider = Provider((ref) { @@ -558,16 +608,47 @@ final busyMaxThemeControllerProvider = Provider((ref) { ); }); +Future loadInitialAppSettings(LocalSettingsStore store) async { + try { + return AppSettings.fromJson(await store.load()); + } on Object { + return AppSettings.defaults(); + } +} + T _enumFromName(List values, Object? name, T fallback) { + return _enumFromNameOrNull(values, name) ?? fallback; +} + +T? _enumFromNameOrNull(List values, Object? name) { if (name == null) { - return fallback; + return null; } for (final value in values) { if (value.name == name.toString()) { return value; } } - return fallback; + return null; +} + +String _normalizedTimeOfDay(Object? value, String fallback) { + final parts = value?.toString().trim().split(':') ?? const []; + if (parts.length != 2) { + return fallback; + } + 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 fallback; + } + return '${hour.toString().padLeft(2, '0')}:' + '${minute.toString().padLeft(2, '0')}'; } int _minuteOfDay(Object? value, int fallback, {bool allowEndOfDay = false}) { diff --git a/lib/src/app/app_theme.dart b/lib/src/app/app_theme.dart index b319eff..e920bac 100644 --- a/lib/src/app/app_theme.dart +++ b/lib/src/app/app_theme.dart @@ -11,14 +11,21 @@ ThemeData buildBusyMaxTheme({ String? gtkFontFamily, double? gtkFontSize, GtkThemeColors? gtkThemeColors, + bool highContrast = false, }) { + final effectiveAccentColor = highContrast + ? brightness == Brightness.dark + ? Colors.white + : Colors.black + : accentColor; return switch (family) { BusyMaxThemeFamily.yaru => BusyMaxYaruTheme.build( brightness: brightness, - accentColor: accentColor, + accentColor: effectiveAccentColor, gtkFontFamily: gtkFontFamily, gtkFontSize: gtkFontSize, gtkThemeColors: gtkThemeColors, + highContrast: highContrast, ), }; } diff --git a/lib/src/app/busymax_about_dialog.dart b/lib/src/app/busymax_about_dialog.dart index 02c7db8..0fa8cbc 100644 --- a/lib/src/app/busymax_about_dialog.dart +++ b/lib/src/app/busymax_about_dialog.dart @@ -5,174 +5,132 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:yaru/yaru.dart'; -import '../features/feedback/data/feedback_api_client.dart'; -import '../features/feedback/presentation/feedback_dialog.dart'; import '../l10n/l10n.dart'; import '../platform/linux_header_bar_service.dart'; import 'busymax_design.dart'; +import 'busymax_dialog_identity.dart'; +import 'busymax_dialogs.dart'; +import 'busymax_surface_colors.dart'; -const _busyMaxWebsiteUri = 'https://github.com/busystack/busymax'; -const _busyMaxIssuesUri = 'https://github.com/busystack/busymax/issues'; +const _busyMaxWebsiteUrl = 'https://busystack.org'; +const _busyMaxRepositoryUrl = 'https://github.com/busystack/busymax/'; +const _apacheLicenseUrl = 'https://www.apache.org/licenses/LICENSE-2.0'; Future showBusyMaxAboutDialog( BuildContext context, { - required FeedbackSubmissionService feedbackSubmissionService, LinuxHeaderBarService? headerBarService, -}) async { - final service = headerBarService; - if (service != null) { - unawaited(service.setModalBarrierVisible(true)); - } - _BusyMaxAboutAction? action; - try { - action = await showDialog<_BusyMaxAboutAction>( - context: context, - builder: (dialogContext) => BusyMaxAboutDialog( - onSendFeedback: () => - Navigator.of(dialogContext).pop(_BusyMaxAboutAction.sendFeedback), - ), - ); - } finally { - if (service != null) { - unawaited(service.setModalBarrierVisible(false)); - } - } - if (action == _BusyMaxAboutAction.sendFeedback && context.mounted) { - await showBusyMaxFeedbackDialog( - context, - submissionService: feedbackSubmissionService, - headerBarService: headerBarService, - ); - } +}) { + return showBusyMaxModalDialog( + context, + headerBarService: headerBarService, + builder: (dialogContext) => const BusyMaxAboutDialog(), + ); } class BusyMaxAboutDialog extends StatelessWidget { - const BusyMaxAboutDialog({super.key, this.onSendFeedback}); - - final VoidCallback? onSendFeedback; + const BusyMaxAboutDialog({super.key}); @override Widget build(BuildContext context) { final l10n = context.l10n; - final textTheme = Theme.of(context).textTheme; - return Dialog( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Stack( - children: [ - Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Align( - alignment: Alignment.center, - child: const _BusyMaxLogo(size: 72), - ), - const SizedBox(height: BusyMaxSpacing.md), - Text( - l10n.appTitle, - textAlign: TextAlign.center, - style: textTheme.headlineSmall, - ), - const SizedBox(height: BusyMaxSpacing.xs), - Text( - l10n.aboutBusyMaxDescription, - textAlign: TextAlign.center, - style: textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: BusyMaxSpacing.sm), - Align( - alignment: Alignment.center, - child: FutureBuilder( - future: PackageInfo.fromPlatform(), - builder: (context, snapshot) { - final info = snapshot.data; - final version = info == null - ? '' - : 'v${info.version}+${info.buildNumber}'; - return _VersionTag(version: version); - }, - ), - ), - const SizedBox(height: BusyMaxSpacing.lg), - BusyMaxGroupedList( - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.website, - leading: const Icon(Icons.language), - trailing: const Icon( - Icons.open_in_new, - size: BusyMaxSizes.iconSm, - ), - onTap: () => unawaited( - _openExternalUri(Uri.parse(_busyMaxWebsiteUri)), - ), - ), - BusyMaxActionRow( - title: l10n.sendFeedback, - leading: const Icon(Icons.feedback_outlined), - trailing: const Icon( - Icons.chevron_right, - size: BusyMaxSizes.iconSm, - ), - onTap: onSendFeedback, - ), - BusyMaxActionRow( - title: l10n.reportAnIssue, - leading: const Icon(YaruIcons.warning), - trailing: const Icon( - Icons.open_in_new, - size: BusyMaxSizes.iconSm, - ), - onTap: () => unawaited( - _openExternalUri(Uri.parse(_busyMaxIssuesUri)), - ), - ), - ], - ), - ], - ), + final theme = Theme.of(context); + return BusyMaxInformationalDialog( + closeLabel: l10n.close, + maxWidth: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BusyMaxDialogIdentity( + visual: const _BusyMaxLogo(), + title: l10n.appTitle, + ), + const SizedBox(height: BusyMaxSpacing.xs), + Text( + l10n.aboutBusyMaxDescription, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - PositionedDirectional( - top: BusyMaxSpacing.sm, - end: BusyMaxSpacing.sm, - child: BusyMaxDialogCloseButton( - tooltip: l10n.close, - onPressed: () => Navigator.of(context).pop(), + ), + const SizedBox(height: BusyMaxSpacing.sm), + FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + final info = snapshot.data; + final version = info == null ? '' : _formatVersion(info); + return _VersionTag(version: version); + }, + ), + const SizedBox(height: BusyMaxSpacing.md), + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.license, + subtitle: l10n.apacheLicenseName, + leading: const Icon(YaruIcons.information), + trailing: const Icon(YaruIcons.external_link), + onTap: () => + unawaited(_openExternalUri(Uri.parse(_apacheLicenseUrl))), ), - ), - ], - ), + BusyMaxActionRow( + title: l10n.website, + subtitle: _busyMaxWebsiteUrl, + leading: const Icon(YaruIcons.home), + trailing: const Icon(YaruIcons.external_link), + onTap: () => + unawaited(_openExternalUri(Uri.parse(_busyMaxWebsiteUrl))), + ), + BusyMaxActionRow( + title: l10n.sourceCode, + subtitle: _busyMaxRepositoryUrl, + leading: const Icon(YaruIcons.code), + trailing: const Icon(YaruIcons.external_link), + onTap: () => unawaited( + _openExternalUri(Uri.parse(_busyMaxRepositoryUrl)), + ), + ), + ], + ), + ], ), ); } } -enum _BusyMaxAboutAction { sendFeedback } - class _BusyMaxLogo extends StatelessWidget { - const _BusyMaxLogo({required this.size}); - - final double size; + const _BusyMaxLogo(); @override Widget build(BuildContext context) { return Image.asset( 'assets/branding/busymax-logo.png', - width: size, - height: size, + width: BusyMaxDialogIdentity.visualExtent, + height: BusyMaxDialogIdentity.visualExtent, filterQuality: FilterQuality.high, errorBuilder: (context, error, stackTrace) => - SizedBox.square(dimension: size), + const SizedBox.square(dimension: BusyMaxDialogIdentity.visualExtent), ); } } +String _formatVersion(PackageInfo info) { + final version = info.version.trim(); + final buildNumber = info.buildNumber.trim(); + if (version.isEmpty && buildNumber.isEmpty) { + return ''; + } + final versionWithBuild = switch ((version, buildNumber)) { + ('', final build) => build, + (final release, '') => release, + (final release, final build) => '$release+$build', + }; + return versionWithBuild.startsWith('v') + ? versionWithBuild + : 'v$versionWithBuild'; +} + class _VersionTag extends StatelessWidget { const _VersionTag({required this.version}); @@ -183,17 +141,34 @@ class _VersionTag extends StatelessWidget { if (version.isEmpty) { return const SizedBox.shrink(); } - return DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.sm, - vertical: BusyMaxSpacing.xs, + final theme = Theme.of(context); + final colors = BusyMaxSurfaceColors.of(context); + return Center( + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.control, + borderRadius: BorderRadius.circular(BusyMaxRadius.pill), + border: Border.all(color: colors.divider), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.md, + vertical: BusyMaxSpacing.xs, + ), + child: Directionality( + textDirection: TextDirection.ltr, + child: Text( + version, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.foreground, + fontWeight: FontWeight.w600, + ), + ), + ), ), - child: Text(version, style: Theme.of(context).textTheme.labelMedium), ), ); } diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 3331e9a..96199ee 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -2,18 +2,20 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter/services.dart'; import 'package:system_theme/system_theme.dart'; import 'package:ubuntu_localizations/ubuntu_localizations.dart'; import '../platform/busymax_tray_service.dart'; import '../platform/gtk_font_service.dart'; +import '../platform/linux_header_bar_configuration_synchronizer.dart'; import '../platform/linux_header_bar_service.dart'; import '../platform/linux_window_service.dart'; -import '../platform/main_window_command_bridge.dart'; +import '../l10n/locale_resolution.dart'; +import '../schedule/schedule_commands.dart'; import 'app_bootstrap.dart'; import 'app_router.dart'; import 'busymax_keyboard_shortcuts_dialog.dart'; +import 'busymax_shortcuts.dart'; import '../../l10n/generated/app_localizations.dart'; import 'busymax_yaru_theme.dart'; import 'busymax_design.dart'; @@ -25,9 +27,39 @@ typedef BusyMaxTrayServiceFactory = required LinuxWindowService windowService, required BusyMaxTrayLabels labels, required Future Function() onOpenAgenda, - Future Function()? onBeforeQuit, }); +BusyMaxHeaderBarTheme busyMaxHeaderBarThemeFor( + ThemeData theme, { + required bool highContrast, +}) { + final colors = theme.extension()!; + return BusyMaxHeaderBarTheme( + preferDark: theme.brightness == Brightness.dark, + highContrast: highContrast, + windowBackgroundColor: colors.window, + // This header is deliberately borderless and visually continuous with + // the main workspace, so both use the window surface role. + backgroundColor: colors.window, + sidebarBackgroundColor: colors.sidebar, + foregroundColor: colors.foreground, + sidebarBorderColor: colors.sidebarBorder, + dialogBackgroundColor: colors.dialog, + dialogOutlineColor: colors.dialogOutline, + modalBarrierColor: colors.shade, + tooltip: BusyMaxHeaderBarTooltipTheme( + backgroundColor: BusyMaxTooltipStyle.background, + foregroundColor: BusyMaxTooltipStyle.foreground, + borderColor: BusyMaxTooltipStyle.border, + borderRadius: BusyMaxRadius.tooltip, + fontSize: theme.tooltipTheme.textStyle?.fontSize ?? 14, + horizontalPadding: BusyMaxSpacing.tooltipHorizontal, + verticalPadding: BusyMaxSpacing.tooltipVertical, + minimumHeight: BusyMaxSizes.tooltipMinHeight, + ), + ); +} + class BusyMaxApp extends ConsumerStatefulWidget { const BusyMaxApp({super.key, this.trayServiceFactory}); @@ -44,15 +76,23 @@ class _BusyMaxAppState extends ConsumerState { bool? _lastTrayEnabled; bool _startMinimizedHandled = false; bool _settingsReady = false; + var _scheduleCommandSequence = 0; + late final BusyMaxHeaderBarConfigurationSynchronizer + _headerBarConfigurationSynchronizer; @override void initState() { super.initState(); + _headerBarConfigurationSynchronizer = + BusyMaxHeaderBarConfigurationSynchronizer( + ref.read(linuxHeaderBarServiceProvider), + ); unawaited(_waitForSettings()); } @override void dispose() { + _headerBarConfigurationSynchronizer.dispose(); final tray = _trayService; if (tray != null) { unawaited(tray.stop()); @@ -84,9 +124,9 @@ class _BusyMaxAppState extends ConsumerState { return SystemThemeBuilder( builder: (context, systemColor) { final accentColor = - ubuntuAccentColor ?? gtkThemeColors?.accent ?? systemColor.accent; + gtkThemeColors?.accent ?? ubuntuAccentColor ?? systemColor.accent; return MaterialApp.router( - title: 'BusyMax', + onGenerateTitle: (context) => AppLocalizations.of(context).appTitle, debugShowCheckedModeBanner: false, theme: buildBusyMaxTheme( brightness: Brightness.light, @@ -104,28 +144,49 @@ class _BusyMaxAppState extends ConsumerState { gtkFontSize: gtkFont?.size, gtkThemeColors: gtkThemeColors, ), + highContrastTheme: buildBusyMaxTheme( + brightness: Brightness.light, + accentColor: accentColor, + family: settings.themeFamily, + gtkFontFamily: gtkFont?.family, + gtkFontSize: gtkFont?.size, + gtkThemeColors: gtkThemeColors, + highContrast: true, + ), + highContrastDarkTheme: buildBusyMaxTheme( + brightness: Brightness.dark, + accentColor: accentColor, + family: settings.themeFamily, + gtkFontFamily: gtkFont?.family, + gtkFontSize: gtkFont?.size, + gtkThemeColors: gtkThemeColors, + highContrast: true, + ), themeMode: settings.themeMode, + locale: settings.locale, localizationsDelegates: const [ ...AppLocalizations.localizationsDelegates, ...GlobalUbuntuLocalizations.delegates, ], + localeListResolutionCallback: resolveBusyMaxLocales, supportedLocales: AppLocalizations.supportedLocales, builder: (context, child) { final l10n = AppLocalizations.of(context); - _configureNativeHeaderBarTheme(context, ref); + _configureNativeHeaderBarTheme(context); _configureBackgroundServices( ref, settings, BusyMaxTrayLabels( - openBusyMax: l10n.compactAgendaOpenBusyMax, + openBusyMax: l10n.trayOpenBusyMax, agenda: l10n.viewAgenda, quitBusyMax: l10n.exit, ), ); return Shortcuts( shortcuts: const { - SingleActivator(LogicalKeyboardKey.slash, control: true): + BusyMaxShortcutActivators.keyboardShortcuts: _KeyboardShortcutsIntent(), + BusyMaxShortcutActivators.settings: _OpenSettingsIntent(), }, child: Actions( actions: { @@ -147,11 +208,18 @@ class _BusyMaxAppState extends ConsumerState { return null; }, ), - }, - child: MainWindowCommandBridge( - child: _BusyMaxWindowCornerClip( - child: child ?? const SizedBox.shrink(), + _OpenSettingsIntent: CallbackAction<_OpenSettingsIntent>( + onInvoke: (intent) { + if (router.state.uri.path != '/settings') { + unawaited(router.push('/settings')); + } + return null; + }, ), + }, + child: ColoredBox( + color: BusyMaxSurfaceColors.of(context).window, + child: child ?? const SizedBox.shrink(), ), ), ); @@ -162,15 +230,10 @@ class _BusyMaxAppState extends ConsumerState { ); } - void _configureNativeHeaderBarTheme(BuildContext context, WidgetRef ref) { - final colors = BusyMaxSurfaceColors.of(context); - final colorScheme = Theme.of(context).colorScheme; + void _configureNativeHeaderBarTheme(BuildContext context) { final l10n = AppLocalizations.of(context); final materialL10n = MaterialLocalizations.of(context); - final modalBarrierColor = Theme.of( - context, - ).colorScheme.scrim.withValues(alpha: 0.32); - final preferDark = Theme.of(context).brightness == Brightness.dark; + final theme = Theme.of(context); final labels = BusyMaxHeaderBarLabels( today: l10n.today, day: l10n.viewDay, @@ -180,44 +243,45 @@ class _BusyMaxAppState extends ConsumerState { agenda: l10n.viewAgenda, search: materialL10n.searchFieldLabel, create: l10n.create, + createEvent: l10n.createEventAtTime, + createTask: l10n.createTaskAtDate, refresh: l10n.refreshAll, menu: l10n.mainMenu, previous: materialL10n.previousPageTooltip, next: materialL10n.nextPageTooltip, - sidebar: l10n.toggleSidebar, + showSidebarPanel: l10n.showSidebar, + hideSidebarPanel: l10n.hideSidebar, back: materialL10n.backButtonTooltip, settings: l10n.settings, keyboardShortcuts: l10n.keyboardShortcuts, + reportIssue: l10n.reportAnIssue, aboutBusyMax: l10n.aboutBusyMax, + todayShortcut: BusyMaxShortcutLabels.today, + dayShortcut: BusyMaxShortcutLabels.dayView, + weekShortcut: BusyMaxShortcutLabels.weekView, + monthShortcut: BusyMaxShortcutLabels.monthView, + yearShortcut: BusyMaxShortcutLabels.yearView, + agendaShortcut: BusyMaxShortcutLabels.agendaView, + searchShortcut: BusyMaxShortcutLabels.search, + sidebarShortcut: BusyMaxShortcutLabels.sidebar, + createEventShortcut: BusyMaxShortcutLabels.newEvent, + createTaskShortcut: BusyMaxShortcutLabels.newTask, + previousShortcut: BusyMaxShortcutLabels.previousPeriod, + nextShortcut: BusyMaxShortcutLabels.nextPeriod, + settingsShortcut: BusyMaxShortcutLabels.settings, + keyboardShortcutsShortcut: BusyMaxShortcutLabels.keyboardShortcuts, + ); + _headerBarConfigurationSynchronizer.schedule( + BusyMaxHeaderBarConfiguration( + labels: labels, + sidebarWidth: BusyMaxSizes.sidebarWidth, + textDirection: Directionality.of(context), + theme: busyMaxHeaderBarThemeFor( + theme, + highContrast: MediaQuery.highContrastOf(context), + ), + ), ); - WidgetsBinding.instance.addPostFrameCallback((_) { - final service = ref.read(linuxHeaderBarServiceProvider); - unawaited(() async { - await service.initialize(); - await service.setLocalizedLabels(labels); - await service.setSidebarWidth(BusyMaxSizes.sidebarWidth); - await service.setTheme( - BusyMaxHeaderBarTheme( - preferDark: preferDark, - windowBackgroundColor: colors.window, - backgroundColor: colors.view, - sidebarBackgroundColor: colors.sidebar, - foregroundColor: colors.foreground, - mutedForegroundColor: colors.mutedForeground, - disabledForegroundColor: colors.disabledForeground, - controlColor: colors.control, - controlHoverColor: colors.controlHover, - controlActiveColor: colors.controlActive, - accentColor: colorScheme.primary, - accentForegroundColor: colorScheme.onPrimary, - popoverBackgroundColor: colors.popover, - borderColor: colors.border, - shadeColor: colors.shade, - modalBarrierColor: modalBarrierColor, - ), - ); - }()); - }); } void _configureBackgroundServices( @@ -230,7 +294,6 @@ class _BusyMaxAppState extends ConsumerState { } final windowService = ref.read(linuxWindowServiceProvider); - final trayEnabled = settings.showTrayIcon || settings.runInBackgroundWhenClosed || @@ -248,12 +311,10 @@ class _BusyMaxAppState extends ConsumerState { return; } _lastTrayEnabled = trayEnabled; - final compactAgendaWindows = ref.read(compactAgendaWindowServiceProvider); final tray = _trayService ??= _createTrayService( windowService: windowService, labels: labels, - onOpenAgenda: compactAgendaWindows.toggle, - onBeforeQuit: compactAgendaWindows.closeIfOpen, + onOpenAgenda: () => _openMainAgenda(ref, windowService), ); if (trayEnabled) { unawaited( @@ -273,7 +334,6 @@ class _BusyMaxAppState extends ConsumerState { required LinuxWindowService windowService, required BusyMaxTrayLabels labels, required Future Function() onOpenAgenda, - Future Function()? onBeforeQuit, }) { final factory = widget.trayServiceFactory; if (factory != null) { @@ -281,17 +341,29 @@ class _BusyMaxAppState extends ConsumerState { windowService: windowService, labels: labels, onOpenAgenda: onOpenAgenda, - onBeforeQuit: onBeforeQuit, ); } return BusyMaxTrayService( windowService: windowService, labels: labels, onOpenAgenda: onOpenAgenda, - onBeforeQuit: onBeforeQuit, ); } + Future _openMainAgenda( + WidgetRef ref, + LinuxWindowService windowService, + ) async { + await windowService.showWindow(); + ref + .read(scheduleWorkspaceCommandProvider.notifier) + .state = ScheduleWorkspaceCommand( + ScheduleWorkspaceCommandKind.agenda, + ++_scheduleCommandSequence, + ); + ref.read(appRouterProvider).go('/schedule'); + } + void _setHideOnClose(LinuxWindowService windowService, bool enabled) { if (_lastHideOnClose == enabled) { return; @@ -337,22 +409,6 @@ class _KeyboardShortcutsIntent extends Intent { const _KeyboardShortcutsIntent(); } -class _BusyMaxWindowCornerClip extends StatelessWidget { - const _BusyMaxWindowCornerClip({required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context) { - return ClipRRect( - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(BusyMaxRadius.window), - ), - clipBehavior: Clip.antiAliasWithSaveLayer, - child: ColoredBox( - color: BusyMaxSurfaceColors.of(context).window, - child: child, - ), - ); - } +class _OpenSettingsIntent extends Intent { + const _OpenSettingsIntent(); } diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 531de32..5411d1c 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -1,8 +1,15 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:ubuntu_widgets/ubuntu_widgets.dart'; +import 'package:flutter/services.dart'; import 'package:yaru/yaru.dart'; import '../l10n/l10n.dart'; +import '../platform/native_menu_service.dart'; +import 'busymax_glyphs.dart'; import 'busymax_surface_colors.dart'; abstract final class BusyMaxSpacing { @@ -11,29 +18,29 @@ abstract final class BusyMaxSpacing { static const double headerInset = 6; static const double sm = 8; static const double md = 12; - static const double tooltipHorizontal = sm; - static const double tooltipVertical = 5; static const double lg = kYaruPagePadding; static const double xl = kYaruPagePadding * 1.5; static const double xxl = kYaruPagePadding * 2; + static const double tooltipHorizontal = 10; + static const double tooltipVertical = 6; } abstract final class BusyMaxRadius { static const double sm = kYaruButtonRadius; + static const double tooltip = kYaruButtonRadius; static const double md = kYaruContainerRadius; static const double lg = kYaruContainerRadius; static const double headerButton = kYaruButtonRadius; - static const double window = kYaruContainerRadius; + static const double window = kYaruWindowRadius; + static const double pill = 999; } abstract final class BusyMaxSizes { static const double sidebarWidth = 300; static const double detailsWidth = 700; static const double compactDetailsWidth = 700; + static const double comboWidth = 220; static const double toolbarHeight = kYaruTitleBarHeight; - static const double pushButtonWidth = 136; - static const double pushButtonHeight = 36; - static const Size pushButtonSize = Size(pushButtonWidth, pushButtonHeight); static const double sidebarRowHeight = 36; static const double taskRowMinHeight = 48; static const double iconSm = 16; @@ -43,67 +50,199 @@ abstract final class BusyMaxSizes { static const double headerIcon = kYaruIconSize; static const double sidebarActionButton = headerIconButton; static const double sidebarActionIcon = headerIcon; - static const double miniCalendarWeekButton = headerIconButton; - static const double aboutCloseButton = - headerIconButton - BusyMaxSpacing.sm - BusyMaxSpacing.xxs; - static const double popoverActionButton = headerIconButton; + static const double popoverActionButton = kYaruTitleBarItemHeight; + static const double popoverActionIcon = iconSm; static const double popoverArrowWidth = 18; static const double popoverArrowHeight = 10; + static const double tooltipMinHeight = 30; +} + +abstract final class BusyMaxFormLayout { + static const double comboInlineMaxFraction = 0.46; +} + +abstract final class BusyMaxCalendarHeaderLayout { + static const int monthControlFlex = 3; + static const int yearControlFlex = 2; } +/// BusyMax's single deliberate adjustment to Yaru's surface depth. +/// +/// Grouped cards need a little more separation from the application canvas. +/// Floating controls keep the elevations supplied by Yaru; feature widgets +/// must not define their own elevations or shadow geometry. abstract final class BusyMaxElevation { - static const double surface = 1; - static const double popover = 6; - static const double tooltip = 10; - static const double window = 12; + static const double groupedCard = 2; +} + +abstract final class BusyMaxStroke { + static const double outline = 1; +} + +abstract final class BusyMaxAlpha { + static const double calendarGridLight = 0.10; + static const double calendarGridDark = 0.06; + static const double groupedRowLightHoverStrength = 0.50; + static const double tooltipBackground = 0.80; + static const double tooltipBorder = 0.10; +} + +abstract final class BusyMaxMotion { + static const Duration dialogInsets = Duration(milliseconds: 160); + static const Curve dialogInsetsCurve = Curves.easeOutCubic; + static const Duration tooltipWait = Duration(milliseconds: 500); +} + +/// Cross-toolkit tooltip visuals. +/// +/// Flutter and the native GTK header bar render separate tooltip widgets. +/// This contract keeps their surface geometry and palette identical while +/// allowing both toolkits to retain native positioning and accessibility. +abstract final class BusyMaxTooltipStyle { + static final Color background = Colors.black.withValues( + alpha: BusyMaxAlpha.tooltipBackground, + ); + static const Color foreground = Colors.white; + static final Color border = Colors.white.withValues( + alpha: BusyMaxAlpha.tooltipBorder, + ); + static const EdgeInsets padding = EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.tooltipHorizontal, + vertical: BusyMaxSpacing.tooltipVertical, + ); + static const BorderRadius borderRadius = BorderRadius.all( + Radius.circular(BusyMaxRadius.tooltip), + ); + static const BoxConstraints constraints = BoxConstraints( + minHeight: BusyMaxSizes.tooltipMinHeight, + ); } +enum BusyMaxPopoverShadowRole { standard, details } + abstract final class BusyMaxShadow { static const double floatingBlur = 24; static const Offset floatingOffset = Offset(0, 8); static const double windowMargin = 32; - static Color _scaleAlpha(Color color, double scale) { - return color.withValues( - alpha: (color.a * scale).clamp(0.0, 1.0).toDouble(), - ); - } + /// The native libadwaita/Yaru card depth used by `.card` and + /// `list.boxed-list`. + /// + /// Flutter's physical elevation shadow is deliberately biased toward the + /// bottom edge. Native boxed lists instead combine a quiet perimeter with + /// two compact layers, which keeps the top edge legible without making the + /// card look detached from the page. + static List nativeCardShadows(Color semanticShadow) { + Color layer(double opacity) { + return semanticShadow.withValues(alpha: semanticShadow.a * opacity); + } - static Color floatingColor(BuildContext context) { - return BusyMaxSurfaceColors.of(context).shade; + // Flutter paints later shadows over earlier ones, so keep the native CSS + // perimeter last and the broadest layer first. + return [ + BoxShadow( + color: layer(0.03), + blurRadius: 6, + spreadRadius: 2, + offset: const Offset(0, 2), + ), + BoxShadow( + color: layer(0.07), + blurRadius: 3, + spreadRadius: 1, + offset: const Offset(0, 1), + ), + BoxShadow(color: layer(0.03), spreadRadius: 1), + ]; } - static Color tooltipColor(BuildContext context) { - return _scaleAlpha(floatingColor(context), 1.45); + static List nativeCardShadowsFor(BuildContext context) { + final theme = Theme.of(context); + return nativeCardShadows( + CardTheme.of(context).shadowColor ?? theme.colorScheme.shadow, + ); } - static List floatingShadows(Color color) { - return [ - BoxShadow(color: color, blurRadius: floatingBlur, offset: floatingOffset), - ]; - } + /// Current libadwaita popover depth, expressed as native CSS layers. + /// + /// A physical Material elevation uses a directional spot shadow and is + /// therefore a poor match for an anchored Linux popover. These two restrained + /// layers mirror libadwaita's popover profile: a defined near edge over a + /// broad, low-opacity ambient shadow. + static List nativePopoverShadows( + Color semanticShadow, { + BusyMaxPopoverShadowRole role = BusyMaxPopoverShadowRole.standard, + }) { + Color layer(double opacity) { + return semanticShadow.withValues(alpha: semanticShadow.a * opacity); + } - static List tooltipShadows(Color color) { + final (ambientBlur, nearBlur) = switch (role) { + BusyMaxPopoverShadowRole.standard => (14.0, 5.0), + // Details carry more visual content than a compact menu. Preserve the + // same native depth and semantic color while tightening only the blur + // by half a pixel. + BusyMaxPopoverShadowRole.details => (13.5, 4.5), + }; + + // ShapeDecoration paints in declaration order. Put the ambient layer + // first so the defined near edge remains above it, matching CSS stacking. return [ BoxShadow( - color: _scaleAlpha(color, 1.45), - blurRadius: 30, - offset: const Offset(0, 10), + color: layer(0.05), + blurRadius: ambientBlur, + spreadRadius: 3, + offset: const Offset(0, 2), ), BoxShadow( - color: _scaleAlpha(color, 0.9), - blurRadius: 8, - offset: const Offset(0, 2), + color: layer(0.09), + blurRadius: nearBlur, + spreadRadius: 1, + offset: const Offset(0, 1), ), ]; } - static List floatingShadowsFor(BuildContext context) { - return floatingShadows(floatingColor(context)); + static List nativePopoverShadowsFor( + BuildContext context, { + BusyMaxPopoverShadowRole role = BusyMaxPopoverShadowRole.standard, + }) { + return nativePopoverShadows( + Theme.of(context).colorScheme.shadow, + role: role, + ); + } + + /// Viewport space required for the farthest libadwaita popover shadow. + /// + /// Flutter's blur mask extends to roughly three standard deviations. Derive + /// the reservation from the shared profile so geometry and paint cannot + /// silently drift apart. + static double get nativePopoverPaintMargin { + final shadows = nativePopoverShadows(const Color(0xFF000000)); + final extent = shadows.fold(0, (maximum, shadow) { + final blurExtent = ui.Shadow.convertRadiusToSigma(shadow.blurRadius) * 3; + final offsetExtent = math.max( + shadow.offset.dx.abs(), + shadow.offset.dy.abs(), + ); + return math.max(maximum, blurExtent + shadow.spreadRadius + offsetExtent); + }); + return extent.ceilToDouble(); + } + + static Color floatingColor(BuildContext context) { + return BusyMaxSurfaceColors.of(context).shade; + } + + static List floatingShadows(Color color) { + return [ + BoxShadow(color: color, blurRadius: floatingBlur, offset: floatingOffset), + ]; } - static List tooltipShadowsFor(BuildContext context) { - return tooltipShadows(floatingColor(context)); + static List floatingShadowsFor(BuildContext context) { + return floatingShadows(floatingColor(context)); } static List windowShadows(Color color) { @@ -158,146 +297,307 @@ class BusyMaxPopoverSurface extends StatelessWidget { super.key, required this.child, required this.color, + this.outlineColor, this.arrowSide = BusyMaxPopoverArrowSide.top, this.arrowAlignment = 0.5, this.padding = EdgeInsets.zero, + this.shadowRole = BusyMaxPopoverShadowRole.standard, }); final Widget child; final Color color; + final Color? outlineColor; final BusyMaxPopoverArrowSide arrowSide; final double arrowAlignment; final EdgeInsetsGeometry padding; + final BusyMaxPopoverShadowRole shadowRole; @override Widget build(BuildContext context) { final arrowHeight = BusyMaxSizes.popoverArrowHeight; - return PhysicalShape( - clipper: _BusyMaxPopoverClipper( - side: arrowSide, - alignment: arrowAlignment.clamp(0.0, 1.0).toDouble(), + final alignment = arrowAlignment.clamp(0.0, 1.0).toDouble(); + final shape = _BusyMaxPopoverBorder( + arrowSide: arrowSide, + alignment: alignment, + ); + final outlineShape = shape.copyWith( + side: BorderSide( + color: outlineColor ?? BusyMaxSurfaceColors.of(context).floatingBorder, + width: BusyMaxStroke.outline, + strokeAlign: BorderSide.strokeAlignInside, ), - color: color, - elevation: BusyMaxElevation.tooltip, - shadowColor: BusyMaxShadow.tooltipColor(context), - clipBehavior: Clip.antiAlias, - child: Padding( - padding: EdgeInsets.only( - top: arrowSide == BusyMaxPopoverArrowSide.top ? arrowHeight : 0, - bottom: arrowSide == BusyMaxPopoverArrowSide.bottom ? arrowHeight : 0, + ); + final paddedChild = Padding( + padding: EdgeInsets.only( + top: arrowSide == BusyMaxPopoverArrowSide.top ? arrowHeight : 0, + bottom: arrowSide == BusyMaxPopoverArrowSide.bottom ? arrowHeight : 0, + ), + child: Padding(padding: padding, child: child), + ); + return DecoratedBox( + decoration: ShapeDecoration( + color: color, + shadows: BusyMaxShadow.nativePopoverShadowsFor( + context, + role: shadowRole, ), - child: Padding(padding: padding, child: child), + shape: shape, ), + child: ClipPath( + clipper: ShapeBorderClipper(shape: shape), + clipBehavior: Clip.antiAlias, + child: DecoratedBox( + position: DecorationPosition.foreground, + decoration: ShapeDecoration(shape: outlineShape), + child: paddedChild, + ), + ), + ); + } +} + +/// A rich anchored-content adapter for the shared GTK-style popover surface. +/// +/// GTK exposes one popover surface role irrespective of the child content. +/// This adapter contributes only details layout; [BusyMaxPopoverSurface] +/// supplies the shared semantic fill, perimeter, and libadwaita shadow. +class BusyMaxContentPopoverSurface extends StatelessWidget { + const BusyMaxContentPopoverSurface({ + super.key, + required this.child, + this.arrowSide = BusyMaxPopoverArrowSide.top, + this.arrowAlignment = 0.5, + this.padding = EdgeInsets.zero, + }); + + final Widget child; + final BusyMaxPopoverArrowSide arrowSide; + final double arrowAlignment; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final colors = BusyMaxSurfaceColors.of(context); + return BusyMaxPopoverSurface( + color: colors.popover, + outlineColor: colors.floatingBorder, + arrowSide: arrowSide, + arrowAlignment: arrowAlignment, + padding: padding, + shadowRole: BusyMaxPopoverShadowRole.details, + child: child, ); } } -class _BusyMaxPopoverClipper extends CustomClipper { - const _BusyMaxPopoverClipper({required this.side, required this.alignment}); +class _BusyMaxPopoverBorder extends OutlinedBorder { + const _BusyMaxPopoverBorder({ + required this.arrowSide, + required this.alignment, + super.side = BorderSide.none, + }); - final BusyMaxPopoverArrowSide side; + final BusyMaxPopoverArrowSide arrowSide; final double alignment; @override - Path getClip(Size size) { - final arrowWidth = BusyMaxSizes.popoverArrowWidth; - final arrowHeight = BusyMaxSizes.popoverArrowHeight; - final radius = Radius.circular(BusyMaxRadius.md); - final bodyTop = side == BusyMaxPopoverArrowSide.top ? arrowHeight : 0.0; - final bodyBottom = side == BusyMaxPopoverArrowSide.bottom - ? size.height - arrowHeight - : size.height; - final body = RRect.fromRectAndRadius( - Rect.fromLTRB(0, bodyTop, size.width, bodyBottom), - radius, + _BusyMaxPopoverBorder copyWith({BorderSide? side}) { + return _BusyMaxPopoverBorder( + arrowSide: arrowSide, + alignment: alignment, + side: side ?? this.side, ); - final minArrowCenter = BusyMaxRadius.md + arrowWidth / 2; - final maxArrowCenter = size.width - minArrowCenter; - final arrowCenter = (size.width * alignment) - .clamp(minArrowCenter, maxArrowCenter) - .toDouble(); + } - final path = Path()..addRRect(body); - if (side == BusyMaxPopoverArrowSide.top) { - path - ..moveTo(arrowCenter - arrowWidth / 2, bodyTop) - ..lineTo(arrowCenter, 0) - ..lineTo(arrowCenter + arrowWidth / 2, bodyTop) - ..close(); - } else { - path - ..moveTo(arrowCenter - arrowWidth / 2, bodyBottom) - ..lineTo(arrowCenter, size.height) - ..lineTo(arrowCenter + arrowWidth / 2, bodyBottom) - ..close(); + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + return getOuterPath(rect, textDirection: textDirection); + } + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + return _busyMaxPopoverPath(rect, side: arrowSide, alignment: alignment); + } + + @override + void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { + if (side.style == BorderStyle.none || side.width <= 0) { + return; } - return path; + final path = getOuterPath(rect, textDirection: textDirection); + canvas + ..save() + ..clipPath(path) + ..drawPath( + path, + side.toPaint() + ..style = PaintingStyle.stroke + ..strokeWidth = side.width * 2, + ) + ..restore(); + } + + @override + _BusyMaxPopoverBorder scale(double t) { + return _BusyMaxPopoverBorder( + arrowSide: arrowSide, + alignment: alignment, + side: side.scale(t), + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is _BusyMaxPopoverBorder && + other.arrowSide == arrowSide && + other.alignment == alignment && + other.side == side; } @override - bool shouldReclip(covariant _BusyMaxPopoverClipper oldClipper) { - return oldClipper.side != side || oldClipper.alignment != alignment; + int get hashCode => Object.hash(arrowSide, alignment, side); +} + +Path _busyMaxPopoverPath( + Rect rect, { + required BusyMaxPopoverArrowSide side, + required double alignment, +}) { + if (rect.isEmpty) { + return Path(); + } + final arrowHalfWidth = math.min( + BusyMaxSizes.popoverArrowWidth / 2, + rect.width / 2, + ); + final arrowHeight = math.min( + BusyMaxSizes.popoverArrowHeight, + rect.height / 2, + ); + final bodyHeight = rect.height - arrowHeight; + final radius = Radius.circular( + math.min(BusyMaxRadius.md, math.min(rect.width, bodyHeight) / 2), + ); + final bodyTop = side == BusyMaxPopoverArrowSide.top + ? rect.top + arrowHeight + : rect.top; + final bodyBottom = side == BusyMaxPopoverArrowSide.bottom + ? rect.bottom - arrowHeight + : rect.bottom; + final body = RRect.fromRectAndRadius( + Rect.fromLTRB(rect.left, bodyTop, rect.right, bodyBottom), + radius, + ); + final preferredArrowInset = radius.x + arrowHalfWidth; + final minArrowCenter = math.min(rect.width / 2, preferredArrowInset); + final maxArrowCenter = math.max(minArrowCenter, rect.width - minArrowCenter); + final arrowCenter = + (rect.width * alignment) + .clamp(minArrowCenter, maxArrowCenter) + .toDouble() + + rect.left; + + final bodyPath = Path()..addRRect(body); + final arrowPath = Path(); + if (side == BusyMaxPopoverArrowSide.top) { + arrowPath + ..moveTo(arrowCenter - arrowHalfWidth, bodyTop) + ..lineTo(arrowCenter, rect.top) + ..lineTo(arrowCenter + arrowHalfWidth, bodyTop) + ..close(); + } else { + arrowPath + ..moveTo(arrowCenter - arrowHalfWidth, bodyBottom) + ..lineTo(arrowCenter, rect.bottom) + ..lineTo(arrowCenter + arrowHalfWidth, bodyBottom) + ..close(); } + return Path.combine(PathOperation.union, bodyPath, arrowPath); } -class BusyMaxCircularAction extends StatelessWidget { - const BusyMaxCircularAction({ +RoundedRectangleBorder busyMaxHeaderButtonShape() { + return RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), + ); +} + +/// An icon button whose BusyMax semantic state style remains authoritative. +/// +/// `YaruIconButton` supplies excellent defaults, but in Yaru 10.2 its +/// internally complete [ButtonStyle] is merged ahead of the caller's style. +/// That makes caller-provided foreground, background, and overlay state +/// properties unreachable. This adapter keeps Flutter's native button +/// interaction model and Yaru's optional keyboard-focus border while allowing +/// the shared BusyMax header style to resolve those semantic states. +class BusyMaxHeaderIconButton extends StatelessWidget { + const BusyMaxHeaderIconButton({ super.key, required this.icon, - required this.tooltip, required this.onPressed, - this.destructive = false, + this.tooltip, + this.iconSize = BusyMaxSizes.headerIcon, + this.foregroundColor, + this.backgroundColor, + this.overlayColor, + this.fixedSize, + this.shape, }); - final IconData icon; - final String tooltip; - final VoidCallback onPressed; - final bool destructive; + final Widget icon; + final VoidCallback? onPressed; + final String? tooltip; + final double iconSize; + final Color? foregroundColor; + final WidgetStateProperty? backgroundColor; + final WidgetStateProperty? overlayColor; + final Size? fixedSize; + final OutlinedBorder? shape; @override Widget build(BuildContext context) { - final surfaceColors = BusyMaxSurfaceColors.of(context); - final foregroundColor = destructive - ? Theme.of(context).colorScheme.error - : surfaceColors.mutedForeground; - return Tooltip( - message: tooltip, - child: Material( - color: surfaceColors.control, - shape: const CircleBorder(), - clipBehavior: Clip.antiAlias, - child: InkWell( - customBorder: const CircleBorder(), - hoverColor: surfaceColors.controlHover, - focusColor: surfaceColors.controlHover, - highlightColor: surfaceColors.controlHover, - splashColor: Colors.transparent, - onTap: onPressed, - child: SizedBox.square( - dimension: BusyMaxSizes.popoverActionButton, - child: Icon( - icon, - size: BusyMaxSizes.iconSm, - color: foregroundColor, - ), + final button = IconButton( + tooltip: tooltip, + icon: icon, + iconSize: iconSize, + onPressed: onPressed, + style: + busyMaxHeaderIconButtonStyle( + context, + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + overlayColor: overlayColor, + ).copyWith( + fixedSize: fixedSize == null + ? null + : WidgetStatePropertyAll(fixedSize), + minimumSize: fixedSize == null + ? null + : WidgetStatePropertyAll(fixedSize), + maximumSize: fixedSize == null + ? null + : WidgetStatePropertyAll(fixedSize), + shape: shape == null ? null : WidgetStatePropertyAll(shape), ), - ), - ), ); + return YaruTheme.maybeOf(context)?.focusBorders == true + ? YaruFocusBorder.primary( + borderRadius: BorderRadius.circular(100), + child: button, + ) + : button; } } -RoundedRectangleBorder busyMaxHeaderButtonShape() { - return RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ); -} - -ButtonStyle busyMaxHeaderIconButtonStyle({ +ButtonStyle busyMaxHeaderIconButtonStyle( + BuildContext context, { Color? foregroundColor, WidgetStateProperty? backgroundColor, WidgetStateProperty? overlayColor, }) { + final disabledForeground = BusyMaxSurfaceColors.of( + context, + ).disabledForeground; return ButtonStyle( fixedSize: const WidgetStatePropertyAll( Size.square(BusyMaxSizes.headerIconButton), @@ -310,9 +610,11 @@ ButtonStyle busyMaxHeaderIconButtonStyle({ ), padding: const WidgetStatePropertyAll(EdgeInsets.zero), tapTargetSize: MaterialTapTargetSize.shrinkWrap, - foregroundColor: foregroundColor == null - ? null - : WidgetStatePropertyAll(foregroundColor), + foregroundColor: WidgetStateProperty.resolveWith((states) { + return states.contains(WidgetState.disabled) + ? disabledForeground + : foregroundColor; + }), backgroundColor: backgroundColor, overlayColor: overlayColor, side: const WidgetStatePropertyAll(BorderSide.none), @@ -320,11 +622,15 @@ ButtonStyle busyMaxHeaderIconButtonStyle({ ); } -ButtonStyle busyMaxHeaderTextButtonStyle({ +ButtonStyle busyMaxHeaderTextButtonStyle( + BuildContext context, { Color? foregroundColor, WidgetStateProperty? backgroundColor, WidgetStateProperty? overlayColor, }) { + final disabledForeground = BusyMaxSurfaceColors.of( + context, + ).disabledForeground; return ButtonStyle( minimumSize: const WidgetStatePropertyAll( Size(BusyMaxSizes.headerIconButton, BusyMaxSizes.headerIconButton), @@ -333,9 +639,11 @@ ButtonStyle busyMaxHeaderTextButtonStyle({ EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), ), tapTargetSize: MaterialTapTargetSize.shrinkWrap, - foregroundColor: foregroundColor == null - ? null - : WidgetStatePropertyAll(foregroundColor), + foregroundColor: WidgetStateProperty.resolveWith((states) { + return states.contains(WidgetState.disabled) + ? disabledForeground + : foregroundColor; + }), backgroundColor: backgroundColor, overlayColor: overlayColor, side: const WidgetStatePropertyAll(BorderSide.none), @@ -369,7 +677,8 @@ WidgetStateProperty busyMaxHeaderButtonBackground( if (states.contains(WidgetState.disabled)) { return surfaceColors.disabledControl; } - if (states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.pressed) || + states.contains(WidgetState.selected)) { return surfaceColors.controlActive; } if (states.contains(WidgetState.hovered) || @@ -380,160 +689,192 @@ WidgetStateProperty busyMaxHeaderButtonBackground( }); } -ButtonStyle busyMaxDropdownButtonStyle(BuildContext context) { - return ButtonStyle( - minimumSize: const WidgetStatePropertyAll( - Size(BusyMaxSizes.headerIconButton, BusyMaxSizes.headerIconButton), - ), - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: BusyMaxSpacing.lg), - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - foregroundColor: WidgetStatePropertyAll( - Theme.of(context).colorScheme.onSurfaceVariant, - ), - backgroundColor: WidgetStateProperty.resolveWith((states) { - return Colors.transparent; - }), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - side: const WidgetStatePropertyAll(BorderSide.none), - shape: WidgetStatePropertyAll(busyMaxHeaderButtonShape()), - elevation: const WidgetStatePropertyAll(0), - shadowColor: const WidgetStatePropertyAll(Colors.transparent), - surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), - animationDuration: Duration.zero, - ); -} +/// BusyMax's cross-platform fallback for a native desktop search entry. +/// +/// Linux header bars use `GtkSearchEntry`. Flutter-owned layouts delegate +/// geometry, icons, and interaction states to Yaru instead of restyling a raw +/// [TextField]. +class BusyMaxSearchField extends StatefulWidget { + const BusyMaxSearchField({ + super.key, + this.controller, + this.hintText, + this.autofocus = false, + this.focusRequest = 0, + this.onChanged, + this.onSubmitted, + this.onClear, + this.clearButtonSemanticLabel, + }); -InputDecoration busyMaxDropdownDecoration() { - return const InputDecoration( - filled: false, - isCollapsed: true, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - ); -} + final TextEditingController? controller; + final String? hintText; + final bool autofocus; -MenuStyle busyMaxDropdownMenuStyle(BuildContext context, {double? minWidth}) { - final popupTheme = Theme.of(context).popupMenuTheme; - final colorScheme = Theme.of(context).colorScheme; - return MenuStyle( - backgroundColor: WidgetStatePropertyAll( - popupTheme.color ?? colorScheme.surfaceContainerHigh, - ), - surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), - shadowColor: WidgetStatePropertyAll(BusyMaxShadow.floatingColor(context)), - elevation: const WidgetStatePropertyAll(BusyMaxElevation.popover), - padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 4)), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ), - ), - side: const WidgetStatePropertyAll(BorderSide.none), - visualDensity: VisualDensity.standard, - minimumSize: WidgetStatePropertyAll(Size(minWidth ?? 0, 0)), - ); + /// Increment this value to focus the Yaru-owned text entry again. + final int focusRequest; + + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final VoidCallback? onClear; + final String? clearButtonSemanticLabel; + + @override + State createState() => _BusyMaxSearchFieldState(); } -ButtonStyle busyMaxDropdownMenuItemStyle(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return ButtonStyle( - minimumSize: const WidgetStatePropertyAll(Size(0, 36)), - maximumSize: const WidgetStatePropertyAll(Size(double.infinity, 36)), - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - textStyle: WidgetStatePropertyAll(Theme.of(context).textTheme.labelLarge), - foregroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return colorScheme.onSurfaceVariant.withValues(alpha: 0.55); - } - return colorScheme.onSurface; - }), - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) { - return colorScheme.onSurfaceVariant.withValues(alpha: 0.12); - } - if (states.contains(WidgetState.hovered) || - states.contains(WidgetState.focused)) { - return colorScheme.onSurfaceVariant.withValues(alpha: 0.08); - } - return Colors.transparent; - }), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - side: const WidgetStatePropertyAll(BorderSide.none), - surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), - shadowColor: const WidgetStatePropertyAll(Colors.transparent), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ), - ), - animationDuration: Duration.zero, +class _BusyMaxSearchFieldState extends State { + final _focusScopeNode = FocusScopeNode( + debugLabel: 'BusyMaxSearchField scope', ); -} + final _yaruKeyboardFocusNode = FocusNode( + debugLabel: 'BusyMaxSearchField keyboard listener', + skipTraversal: true, + ); + late TextEditingController _controller; + late bool _ownsController; + late bool _isEmpty; -ButtonStyle busyMaxPushButtonStyle(ButtonStyle? style) { - return const ButtonStyle( - fixedSize: WidgetStatePropertyAll( - Size.fromHeight(BusyMaxSizes.pushButtonHeight), - ), - minimumSize: WidgetStatePropertyAll(BusyMaxSizes.pushButtonSize), - padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 12)), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ).merge(style); -} + @override + void initState() { + super.initState(); + _attachController(widget.controller); + if (widget.autofocus) { + _requestTextFocus(); + } + } -ButtonStyle busyMaxHeaderPushButtonStyle(ButtonStyle? style) { - return ButtonStyle( - minimumSize: const WidgetStatePropertyAll( - Size(BusyMaxSizes.headerIconButton, BusyMaxSizes.headerIconButton), - ), - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: BusyMaxSpacing.xl), - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: WidgetStatePropertyAll(busyMaxHeaderButtonShape()), - ).merge(style); -} + @override + void didUpdateWidget(covariant BusyMaxSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _detachController(); + _attachController(widget.controller); + } + if (oldWidget.focusRequest != widget.focusRequest) { + _requestTextFocus(); + } + } -abstract final class BusyMaxPushButton { - static PushButton elevated({ - required Widget child, - required VoidCallback? onPressed, - VoidCallback? onLongPress, - ValueChanged? onHover, - ValueChanged? onFocusChange, - ButtonStyle? style, - FocusNode? focusNode, - bool autofocus = false, - Clip clipBehavior = Clip.none, - WidgetStatesController? statesController, - Key? key, - }) { - return PushButton.elevated( - key: key, - onPressed: onPressed, - onLongPress: onLongPress, - onHover: onHover, - onFocusChange: onFocusChange, - style: busyMaxPushButtonStyle(style), - focusNode: focusNode, - autofocus: autofocus, - clipBehavior: clipBehavior, - statesController: statesController, - child: child, + @override + void dispose() { + _detachController(); + _focusScopeNode.dispose(); + _yaruKeyboardFocusNode.dispose(); + super.dispose(); + } + + void _attachController(TextEditingController? controller) { + _ownsController = controller == null; + _controller = controller ?? TextEditingController(); + _isEmpty = _controller.text.isEmpty; + _controller.addListener(_handleControllerChanged); + } + + void _detachController() { + _controller.removeListener(_handleControllerChanged); + if (_ownsController) { + _controller.dispose(); + } + } + + void _handleControllerChanged() { + final isEmpty = _controller.text.isEmpty; + if (_isEmpty == isEmpty || !mounted) { + return; + } + setState(() => _isEmpty = isEmpty); + } + + void _clear() { + widget.onClear?.call(); + _controller.clear(); + } + + void _requestTextFocus() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + // Yaru's public focus node belongs to its keyboard listener. Keep that + // wrapper out of traversal and focus the first Yaru-owned control, + // which is the actual text entry. + for (final node in _focusScopeNode.traversalDescendants) { + if (node.canRequestFocus) { + node.requestFocus(); + return; + } + } + }); + } + + @override + Widget build(BuildContext context) { + final clearLabel = + widget.clearButtonSemanticLabel ?? + MaterialLocalizations.of(context).clearButtonTooltip; + final contentPadding = switch (Directionality.of(context)) { + TextDirection.ltr => const EdgeInsets.only( + left: BusyMaxSpacing.md, + right: kYaruTitleBarItemHeight, + ), + TextDirection.rtl => const EdgeInsets.only( + left: kYaruTitleBarItemHeight, + right: BusyMaxSpacing.md, + ), + }; + return FocusScope( + node: _focusScopeNode, + child: SizedBox( + height: kYaruTitleBarItemHeight, + child: Stack( + fit: StackFit.expand, + children: [ + YaruSearchField( + controller: _controller, + focusNode: _yaruKeyboardFocusNode, + hintText: widget.hintText, + height: kYaruTitleBarItemHeight, + contentPadding: contentPadding, + autofocus: widget.autofocus, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + clearIconSemanticLabel: clearLabel, + ), + if (widget.onClear != null && !_isEmpty) + Align( + alignment: AlignmentDirectional.centerEnd, + child: Padding( + padding: const EdgeInsetsDirectional.only( + end: BusyMaxSpacing.xxs, + ), + child: BusyMaxHeaderIconButton( + tooltip: clearLabel, + icon: const Icon(YaruIcons.edit_clear), + iconSize: BusyMaxSizes.iconSm, + fixedSize: const Size.square( + kYaruTitleBarItemHeight - BusyMaxSpacing.headerInset, + ), + shape: const CircleBorder(), + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll( + Colors.transparent, + ), + onPressed: _clear, + ), + ), + ), + ], + ), + ), ); } +} - static PushButton filled({ +abstract final class BusyMaxPushButton { + /// A neutral desktop action. Yaru renders this with its standard filled + /// control surface and native interaction geometry. + static FilledButton standard({ required Widget child, required VoidCallback? onPressed, VoidCallback? onLongPress, @@ -546,13 +887,13 @@ abstract final class BusyMaxPushButton { WidgetStatesController? statesController, Key? key, }) { - return PushButton.filled( + return FilledButton( key: key, onPressed: onPressed, onLongPress: onLongPress, onHover: onHover, onFocusChange: onFocusChange, - style: busyMaxPushButtonStyle(style), + style: style, focusNode: focusNode, autofocus: autofocus, clipBehavior: clipBehavior, @@ -561,7 +902,9 @@ abstract final class BusyMaxPushButton { ); } - static PushButton outlined({ + /// A suggested action. Yaru reserves the accent-filled elevated role for + /// the single preferred action in a group. + static ElevatedButton suggested({ required Widget child, required VoidCallback? onPressed, VoidCallback? onLongPress, @@ -574,13 +917,13 @@ abstract final class BusyMaxPushButton { WidgetStatesController? statesController, Key? key, }) { - return PushButton.outlined( + return ElevatedButton( key: key, onPressed: onPressed, onLongPress: onLongPress, onHover: onHover, onFocusChange: onFocusChange, - style: busyMaxPushButtonStyle(style), + style: style, focusNode: focusNode, autofocus: autofocus, clipBehavior: clipBehavior, @@ -588,10 +931,14 @@ abstract final class BusyMaxPushButton { child: child, ); } -} -abstract final class BusyMaxHeaderPushButton { - static PushButton filled({ + /// A destructive desktop action. + /// + /// Keep destructive emphasis on the final action in a confirmation dialog; + /// ordinary destructive rows should continue to use semantic error + /// foregrounds without becoming accent-filled buttons. + static ElevatedButton destructive({ + required BuildContext context, required Widget child, required VoidCallback? onPressed, VoidCallback? onLongPress, @@ -604,13 +951,18 @@ abstract final class BusyMaxHeaderPushButton { WidgetStatesController? statesController, Key? key, }) { - return PushButton.filled( + final colorScheme = Theme.of(context).colorScheme; + return ElevatedButton( key: key, onPressed: onPressed, onLongPress: onLongPress, onHover: onHover, onFocusChange: onFocusChange, - style: busyMaxHeaderPushButtonStyle(style), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + iconColor: colorScheme.onError, + ).merge(style), focusNode: focusNode, autofocus: autofocus, clipBehavior: clipBehavior, @@ -618,32 +970,56 @@ abstract final class BusyMaxHeaderPushButton { child: child, ); } +} - static PushButton outlined({ - required Widget child, - required VoidCallback? onPressed, - VoidCallback? onLongPress, - ValueChanged? onHover, - ValueChanged? onFocusChange, - ButtonStyle? style, - FocusNode? focusNode, - bool autofocus = false, - Clip clipBehavior = Clip.none, - WidgetStatesController? statesController, - Key? key, - }) { - return PushButton.outlined( - key: key, - onPressed: onPressed, - onLongPress: onLongPress, - onHover: onHover, - onFocusChange: onFocusChange, - style: busyMaxHeaderPushButtonStyle(style), - focusNode: focusNode, - autofocus: autofocus, - clipBehavior: clipBehavior, - statesController: statesController, - child: child, +/// A contained circular action for compact popover toolbars. +/// +/// Yaru owns focus, hover, press feedback, and desktop control geometry. The +/// outer semantic surface only adds the persistent containment used by Ubuntu +/// detail-popover actions. +class BusyMaxPopoverIconButton extends StatelessWidget { + const BusyMaxPopoverIconButton({ + super.key, + required this.icon, + required this.tooltip, + required this.onPressed, + this.destructive = false, + }); + + final IconData icon; + final String tooltip; + final VoidCallback? onPressed; + final bool destructive; + + @override + Widget build(BuildContext context) { + final colors = BusyMaxSurfaceColors.of(context); + final foreground = destructive ? Theme.of(context).colorScheme.error : null; + final enabled = onPressed != null; + return Material( + color: enabled ? colors.control : colors.disabledControl, + shape: const CircleBorder(), + child: YaruIconButton( + icon: Icon( + icon, + size: BusyMaxSizes.popoverActionIcon, + color: enabled ? foreground : colors.disabledForeground, + ), + iconSize: BusyMaxSizes.popoverActionButton, + style: const ButtonStyle( + minimumSize: WidgetStatePropertyAll( + Size.square(BusyMaxSizes.popoverActionButton), + ), + maximumSize: WidgetStatePropertyAll( + Size.square(BusyMaxSizes.popoverActionButton), + ), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: WidgetStatePropertyAll(BorderSide.none), + shape: WidgetStatePropertyAll(CircleBorder()), + ), + tooltip: tooltip, + onPressed: onPressed, + ), ); } } @@ -660,21 +1036,102 @@ Color busyMaxHoverBackground(BuildContext context) { ); } -Color busyMaxEditorRowHoverColor(BuildContext context) { - final surfaceColors = BusyMaxSurfaceColors.of(context); - return surfaceColors.foreground.withValues( - alpha: Theme.of(context).brightness == Brightness.dark ? 0.045 : 0.055, +Color busyMaxRowHoverColor(BuildContext context) { + final theme = Theme.of(context); + final hover = theme.hoverColor; + if (theme.colorScheme.isHighContrast || + theme.colorScheme.brightness == Brightness.dark) { + return hover; + } + return hover.withValues( + alpha: hover.a * BusyMaxAlpha.groupedRowLightHoverStrength, + ); +} + +/// Input decoration inherited by controls hosted in a grouped-list row. +/// +/// The enclosing [BusyMaxGroupedList] owns the surface, outline, shadow, +/// padding, and separators. Yaru and Flutter entry controls continue to own +/// their editing behavior while this theme prevents a second input surface +/// from being painted inside the native grouped row. +InputDecorationThemeData busyMaxGroupedInputDecorationTheme( + BuildContext context, +) { + final theme = Theme.of(context); + final labelColor = theme.colorScheme.onSurfaceVariant; + final labelStyle = theme.textTheme.bodyMedium?.copyWith(color: labelColor); + + return theme.inputDecorationTheme.copyWith( + filled: false, + fillColor: Colors.transparent, + hoverColor: Colors.transparent, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + labelStyle: labelStyle, + floatingLabelStyle: labelStyle, + floatingLabelBehavior: FloatingLabelBehavior.auto, + ); +} + +InputDecoration busyMaxGroupedTextFieldDecoration( + BuildContext context, { + required String labelText, + String? errorText, + bool alignLabelWithHint = false, +}) { + final decoration = InputDecoration( + labelText: labelText, + errorText: errorText, + alignLabelWithHint: alignLabelWithHint, + ); + final defaults = busyMaxGroupedInputDecorationTheme(context); + final resolved = decoration.applyDefaults(defaults); + if (errorText == null) { + return resolved; + } + final errorLabelStyle = Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.error); + return resolved.copyWith( + labelStyle: errorLabelStyle, + floatingLabelStyle: errorLabelStyle, ); } Color busyMaxModalBarrierColor(BuildContext context) { - return Theme.of(context).colorScheme.scrim.withValues(alpha: 0.32); + // The semantic shade already carries the toolkit's intended modal opacity. + // Consumers must not multiply or cap it and thereby create mode-specific + // dimming behavior. + return BusyMaxSurfaceColors.of(context).shade; } Color busyMaxPanelBorder(BuildContext context) { return Theme.of(context).colorScheme.outlineVariant; } +/// A low-emphasis separator for repeated calendar cells and time slots. +/// +/// GTK's generic separator can be recessed (darker than its surface), which +/// is appropriate for native list separators but makes a dense dark calendar +/// grid look black. Calendar grids instead use a subtle foreground tint, while +/// high-contrast themes retain their full-strength semantic outline. +Color busyMaxCalendarGridColor(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + if (colorScheme.isHighContrast) { + return colorScheme.outlineVariant; + } + return colorScheme.onSurface.withValues( + alpha: colorScheme.brightness == Brightness.dark + ? BusyMaxAlpha.calendarGridDark + : BusyMaxAlpha.calendarGridLight, + ); +} + TextStyle? busyMaxSectionHeaderStyle(BuildContext context) { final theme = Theme.of(context); return theme.textTheme.titleSmall?.copyWith( @@ -682,6 +1139,30 @@ TextStyle? busyMaxSectionHeaderStyle(BuildContext context) { ); } +/// The semantic title style used by Flutter-rendered window headers. +/// +/// Linux applies GTK's `title` style class to the native header label. The +/// Flutter fallback keeps the theme's title geometry and color, and mirrors +/// only that semantic emphasis. +TextStyle busyMaxHeaderTitleStyle(BuildContext context) { + return (Theme.of(context).textTheme.titleMedium ?? const TextStyle()) + .copyWith(fontWeight: FontWeight.bold); +} + +Widget _busyMaxGroupedRowSubtitle( + BuildContext context, + Widget child, { + bool enabled = true, +}) { + final colors = BusyMaxSurfaceColors.of(context); + return DefaultTextStyle.merge( + style: TextStyle( + color: enabled ? colors.mutedForeground : colors.disabledForeground, + ), + child: child, + ); +} + class BusyMaxClamp extends StatelessWidget { const BusyMaxClamp({ super.key, @@ -721,6 +1202,58 @@ class BusyMaxClamp extends StatelessWidget { } } +/// Semantic parent surfaces that can contain a grouped card. +/// +/// GTK's card role is a contextual layer in dark themes. Keeping the parent +/// role explicit lets the shared grouped-card adapter resolve one opaque paint +/// color for Flutter's elevated [Material] without tying every card to the +/// main window background. +enum BusyMaxSurfaceRole { window, view, sidebar, dialog, popover } + +class BusyMaxSurfaceScope extends InheritedWidget { + const BusyMaxSurfaceScope({ + super.key, + required this.role, + required super.child, + }); + + final BusyMaxSurfaceRole role; + + static BusyMaxSurfaceRole roleOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.role ?? + BusyMaxSurfaceRole.window; + } + + @override + bool updateShouldNotify(BusyMaxSurfaceScope oldWidget) { + return role != oldWidget.role; + } +} + +Color busyMaxGroupedSurfaceColor( + BuildContext context, { + BusyMaxSurfaceRole? parentRole, +}) { + final colors = BusyMaxSurfaceColors.of(context); + final role = parentRole ?? BusyMaxSurfaceScope.roleOf(context); + if (role == BusyMaxSurfaceRole.window) { + // The native bridge already resolves the opaque card role against the + // window. Reuse that authoritative value exactly instead of recomputing + // an equivalent color with different floating-point channel values. + return colors.card; + } + final parent = switch (role) { + BusyMaxSurfaceRole.window => colors.window, + BusyMaxSurfaceRole.view => colors.view, + BusyMaxSurfaceRole.sidebar => colors.sidebar, + BusyMaxSurfaceRole.dialog => colors.dialog, + BusyMaxSurfaceRole.popover => colors.popover, + }; + return Color.alphaBlend(colors.groupedSurface, parent); +} + class BusyMaxGroupedList extends StatelessWidget { const BusyMaxGroupedList({ super.key, @@ -782,24 +1315,63 @@ class BusyMaxSurface extends StatelessWidget { required this.child, this.filled = true, this.color, + this.side, this.clipBehavior = Clip.antiAlias, }); final Widget child; final bool filled; final Color? color; + final BorderSide? side; final Clip clipBehavior; @override Widget build(BuildContext context) { - final borderRadius = BorderRadius.circular(BusyMaxRadius.md); + final cardTheme = CardTheme.of(context); final surfaceColors = BusyMaxSurfaceColors.of(context); + final fallbackShape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BusyMaxRadius.md), + ); + final themedShape = cardTheme.shape; + final ShapeBorder shape; + if (themedShape is OutlinedBorder) { + shape = side == null ? themedShape : themedShape.copyWith(side: side); + } else if (themedShape != null && side == null) { + shape = themedShape; + } else { + shape = fallbackShape.copyWith(side: side ?? BorderSide.none); + } + final surfaceColor = filled + ? color ?? cardTheme.color ?? surfaceColors.card + : Colors.transparent; + if (filled) { + final shadowShape = shape is OutlinedBorder + ? shape.copyWith(side: BorderSide.none) + : shape; + return DecoratedBox( + decoration: ShapeDecoration( + shape: shadowShape, + shadows: BusyMaxShadow.nativeCardShadowsFor(context), + ), + child: Card( + margin: EdgeInsets.zero, + semanticContainer: false, + color: surfaceColor, + // Native card depth is painted once by the shared ShapeDecoration + // above. Keep Card for Yaru geometry, clipping, and semantics while + // suppressing Material's directional duplicate. + shadowColor: Colors.transparent, + shape: shape, + clipBehavior: clipBehavior, + child: child, + ), + ); + } return Material( - color: filled ? color ?? surfaceColors.card : Colors.transparent, - elevation: filled ? BusyMaxElevation.surface : 0, - shadowColor: BusyMaxShadow.floatingColor(context), - surfaceTintColor: Colors.transparent, - shape: RoundedRectangleBorder(borderRadius: borderRadius), + color: Colors.transparent, + elevation: 0, + surfaceTintColor: cardTheme.surfaceTintColor ?? Colors.transparent, + shape: shape, clipBehavior: clipBehavior, child: child, ); @@ -818,20 +1390,123 @@ class BusyMaxGroupedSurface extends StatelessWidget { @override Widget build(BuildContext context) { - final borderRadius = BorderRadius.circular(BusyMaxRadius.md); - final surfaceColors = BusyMaxSurfaceColors.of(context); - return Material( - color: surfaceColors.control, - elevation: BusyMaxElevation.surface, - shadowColor: BusyMaxShadow.floatingColor(context), - surfaceTintColor: Colors.transparent, - shape: RoundedRectangleBorder(borderRadius: borderRadius), + final highContrast = MediaQuery.highContrastOf(context); + return BusyMaxSurface( + color: busyMaxGroupedSurfaceColor(context), + side: highContrast + ? BorderSide(color: Theme.of(context).colorScheme.outline) + : null, clipBehavior: clipBehavior, child: child, ); } } +class BusyMaxSidebarSurface extends StatelessWidget { + const BusyMaxSidebarSurface({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final surfaceColors = BusyMaxSurfaceColors.of(context); + return Material( + color: surfaceColors.sidebar, + child: DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + border: BorderDirectional( + end: BorderSide( + color: surfaceColors.sidebarBorder, + width: BusyMaxStroke.outline, + ), + ), + ), + child: child, + ), + ); + } +} + +/// A GTK-style navigation list for a persistent desktop sidebar. +/// +/// The list delegates row interaction, focus handling, and selection geometry +/// to Yaru's master-detail controls while mapping their visual states to +/// BusyMax's semantic surface roles. +class BusyMaxSidebarNavigation extends StatelessWidget { + const BusyMaxSidebarNavigation({super.key, required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = BusyMaxSurfaceColors.of(context); + final masterDetailTheme = YaruMasterDetailTheme.of(context); + + return Theme( + data: theme.copyWith( + listTileTheme: theme.listTileTheme.copyWith( + selectedColor: colors.foreground, + selectedTileColor: Color.alphaBlend(colors.control, colors.sidebar), + tileColor: Colors.transparent, + iconColor: colors.mutedForeground, + textColor: colors.foreground, + titleTextStyle: theme.textTheme.bodyMedium, + contentPadding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.sm, + ), + horizontalTitleGap: BusyMaxSpacing.sm, + minVerticalPadding: 0, + minLeadingWidth: BusyMaxSizes.iconSm, + minTileHeight: BusyMaxSizes.sidebarRowHeight, + visualDensity: VisualDensity.standard, + titleAlignment: ListTileTitleAlignment.center, + ), + ), + child: ListView.separated( + padding: + masterDetailTheme.listPadding ?? + const EdgeInsets.symmetric(vertical: BusyMaxSpacing.sm), + itemCount: children.length, + itemBuilder: (context, index) => children[index], + separatorBuilder: (context, index) => SizedBox( + height: masterDetailTheme.tileSpacing ?? BusyMaxSpacing.xxs, + ), + ), + ); + } +} + +/// A selectable row for [BusyMaxSidebarNavigation]. +class BusyMaxSidebarNavigationTile extends StatelessWidget { + const BusyMaxSidebarNavigationTile({ + super.key, + required this.selected, + required this.leading, + required this.title, + required this.onTap, + }); + + final bool selected; + final Widget leading; + final Widget title; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return YaruMasterTile( + selected: selected, + leading: IconTheme.merge( + data: const IconThemeData(size: BusyMaxSizes.iconSm), + child: leading, + ), + title: title, + onTap: onTap, + ); + } +} + class _BusyMaxGroupedListSurface extends StatelessWidget { const _BusyMaxGroupedListSurface({ required this.filled, @@ -850,7 +1525,7 @@ class _BusyMaxGroupedListSurface extends StatelessWidget { for (var index = 0; index < children.length; index++) ...[ children[index], if (index < children.length - 1) - Divider(height: 1, thickness: 1, color: surfaceColors.subtleBorder), + Divider(height: 1, thickness: 1, color: surfaceColors.cardShade), ], ], ); @@ -861,101 +1536,10 @@ class _BusyMaxGroupedListSurface extends StatelessWidget { } } -class _BusyMaxRowTile extends StatelessWidget { - const _BusyMaxRowTile({ - this.title, - this.titleText, - this.subtitle, - this.leading, - this.trailing, - this.onTap, - this.enabled = true, - this.autofocus = false, - this.hoverColor, - }) : assert((title != null) ^ (titleText != null)); - - final Widget? title; - final String? titleText; - final Widget? subtitle; - final Widget? leading; - final Widget? trailing; - final VoidCallback? onTap; - final bool enabled; - final bool autofocus; - final Color? hoverColor; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final surfaceColors = BusyMaxSurfaceColors.of(context); - final effectiveHoverColor = - hoverColor ?? busyMaxEditorRowHoverColor(context); - final titleWidget = DefaultTextStyle.merge( - style: theme.textTheme.labelLarge?.copyWith( - color: enabled ? null : theme.disabledColor, - ), - child: title ?? Text(titleText!), - ); - final subtitleWidget = subtitle != null - ? DefaultTextStyle.merge( - style: theme.textTheme.labelMedium?.copyWith( - color: enabled ? null : theme.disabledColor, - ), - child: subtitle!, - ) - : null; - - return ConstrainedBox( - constraints: const BoxConstraints(minHeight: 54), - child: Material( - color: surfaceColors.control, - surfaceTintColor: Colors.transparent, - child: InkWell( - onTap: enabled ? onTap : null, - autofocus: autofocus, - hoverColor: effectiveHoverColor, - focusColor: surfaceColors.controlHover, - highlightColor: surfaceColors.controlActive, - splashColor: Colors.transparent, - splashFactory: NoSplash.splashFactory, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.md, - vertical: BusyMaxSpacing.sm, - ), - child: Row( - children: [ - if (leading != null) ...[ - leading!, - const SizedBox(width: BusyMaxSpacing.md), - ], - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - titleWidget, - if (subtitleWidget != null) ...[ - subtitleWidget, - const SizedBox(height: 1), - ], - ], - ), - ), - if (trailing != null) ...[ - const SizedBox(width: BusyMaxSpacing.md), - trailing!, - ], - ], - ), - ), - ), - ), - ); - } -} +typedef BusyMaxRowActivationCallback = + void Function(BuildContext context, Offset? globalPosition); -class BusyMaxActionRow extends StatelessWidget { +class BusyMaxActionRow extends StatefulWidget { const BusyMaxActionRow({ super.key, required this.title, @@ -965,13 +1549,13 @@ class BusyMaxActionRow extends StatelessWidget { this.leading, this.trailing, this.onTap, - this.onPointerDown, + this.onActivated, this.enabled = true, this.tooltip, this.destructive = false, this.autofocus = false, this.hoverColor, - }); + }) : assert(onTap == null || onActivated == null); final String title; final String? subtitle; @@ -980,57 +1564,135 @@ class BusyMaxActionRow extends StatelessWidget { final Widget? leading; final Widget? trailing; final VoidCallback? onTap; - final ValueChanged? onPointerDown; + final BusyMaxRowActivationCallback? onActivated; final bool enabled; final String? tooltip; final bool destructive; final bool autofocus; final Color? hoverColor; + @override + State createState() => _BusyMaxActionRowState(); +} + +class _BusyMaxActionRowState extends State { + int? _primaryPointer; + Offset? _pointerDownPosition; + + @override + void didUpdateWidget(covariant BusyMaxActionRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (!widget.enabled || widget.onActivated == null) { + _clearPointer(); + } + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final titleStyle = destructive ? TextStyle(color: colorScheme.error) : null; - final row = _BusyMaxRowTile( - leading: leading, - title: - titleWidget ?? - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, + final colors = BusyMaxSurfaceColors.of(context); + final titleStyle = widget.destructive + ? TextStyle( + color: widget.enabled + ? colorScheme.error + : colors.disabledForeground, + ) + : null; + final subtitle = + widget.subtitleWidget ?? + (widget.subtitle == null || widget.subtitle!.isEmpty + ? null + : Text( + widget.subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + )); + final interactive = + widget.enabled && (widget.onTap != null || widget.onActivated != null); + final row = YaruListTile.square( + leading: widget.leading, + title: + widget.titleWidget ?? + Text( + widget.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: titleStyle, ), - subtitle: - subtitleWidget ?? - (subtitle == null || subtitle!.isEmpty - ? null - : Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis)), - trailing: trailing, - enabled: enabled, - autofocus: autofocus, - hoverColor: hoverColor, - onTap: enabled ? onTap : null, + subtitle: subtitle == null + ? null + : _busyMaxGroupedRowSubtitle( + context, + subtitle, + enabled: widget.enabled, + ), + trailing: widget.trailing, + enabled: widget.enabled, + autofocus: widget.autofocus, + hoverColor: widget.hoverColor ?? busyMaxRowHoverColor(context), + onTap: interactive ? _activate : null, ); - final trackedRow = onPointerDown == null + final trackedRow = widget.onActivated == null ? row : Listener( - onPointerDown: enabled - ? (event) => onPointerDown!(event.position) - : null, + onPointerDown: widget.enabled ? _handlePointerDown : null, + onPointerUp: widget.enabled ? _handlePointerUp : null, + onPointerCancel: widget.enabled ? _handlePointerCancel : null, child: row, ); - if (enabled || tooltip == null) { + if (widget.enabled || widget.tooltip == null) { return trackedRow; } return Tooltip( - message: tooltip!, + message: widget.tooltip!, child: Opacity(opacity: 0.6, child: IgnorePointer(child: trackedRow)), ); } + + void _handlePointerDown(PointerDownEvent event) { + if (event.buttons != kPrimaryButton) { + return; + } + _primaryPointer = event.pointer; + _pointerDownPosition = event.position; + } + + void _handlePointerUp(PointerUpEvent event) { + if (_primaryPointer != event.pointer) { + return; + } + final pointer = event.pointer; + scheduleMicrotask(() { + if (mounted && _primaryPointer == pointer) { + _clearPointer(); + } + }); + } + + void _handlePointerCancel(PointerCancelEvent event) { + if (_primaryPointer == event.pointer) { + _clearPointer(); + } + } + + void _activate() { + final onActivated = widget.onActivated; + if (onActivated == null) { + widget.onTap?.call(); + return; + } + final globalPosition = _pointerDownPosition; + _clearPointer(); + onActivated(context, globalPosition); + } + + void _clearPointer() { + _primaryPointer = null; + _pointerDownPosition = null; + } } class BusyMaxCategoryEditorRow extends StatelessWidget { @@ -1041,7 +1703,6 @@ class BusyMaxCategoryEditorRow extends StatelessWidget { required this.categories, required this.suggestions, required this.adding, - required this.controller, required this.onAddPressed, required this.onSubmitted, required this.onCancelAdding, @@ -1054,7 +1715,6 @@ class BusyMaxCategoryEditorRow extends StatelessWidget { final List categories; final List suggestions; final bool adding; - final TextEditingController controller; final VoidCallback onAddPressed; final ValueChanged onSubmitted; final VoidCallback onCancelAdding; @@ -1063,11 +1723,10 @@ class BusyMaxCategoryEditorRow extends StatelessWidget { @override Widget build(BuildContext context) { - final visibleSuggestions = [ - for (final suggestion in suggestions) - if (suggestion.trim().isNotEmpty && !categories.contains(suggestion)) - suggestion, - ]; + final visibleSuggestions = _visibleCategorySuggestions( + suggestions: suggestions, + selectedCategories: categories, + ); return BusyMaxActionRow( title: title, leading: const Icon(Icons.sell_outlined), @@ -1084,8 +1743,7 @@ class BusyMaxCategoryEditorRow extends StatelessWidget { onDeleted: () => onDeleted(category), ), if (adding) ...[ - _BusyMaxCategoryInputChip( - controller: controller, + _BusyMaxCategoryInput( hintText: addLabel, suggestions: visibleSuggestions, inputKey: inputKey, @@ -1109,43 +1767,15 @@ class _BusyMaxCategoryChip extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final surfaceColors = BusyMaxSurfaceColors.of(context); - return DecoratedBox( - decoration: BoxDecoration( - color: surfaceColors.control, - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ), - child: Padding( - padding: const EdgeInsetsDirectional.only( - start: BusyMaxSpacing.md, - end: BusyMaxSpacing.xs, - top: BusyMaxSpacing.xs, - bottom: BusyMaxSpacing.xs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 160), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelLarge, - ), - ), - const SizedBox(width: BusyMaxSpacing.xs), - _BusyMaxCategoryIconAction( - icon: YaruIcons.window_close, - tooltip: - '${MaterialLocalizations.of(context).deleteButtonTooltip} $label', - color: colorScheme.onSurfaceVariant, - onPressed: onDeleted, - ), - ], - ), + return InputChip( + label: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 160), + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), ), + deleteIcon: const Icon(YaruIcons.window_close), + deleteButtonTooltipMessage: + '${MaterialLocalizations.of(context).deleteButtonTooltip} $label', + onDeleted: onDeleted, ); } } @@ -1158,48 +1788,16 @@ class _BusyMaxAddCategoryChip extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Material( - color: Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ), - child: InkWell( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - hoverColor: busyMaxEditorRowHoverColor(context), - onTap: onPressed, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.md, - vertical: BusyMaxSpacing.xs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - YaruIcons.plus, - size: BusyMaxSizes.iconSm, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: BusyMaxSpacing.xs), - Text( - label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), + return ActionChip( + avatar: const Icon(YaruIcons.plus, size: BusyMaxSizes.iconSm), + label: Text(label), + onPressed: onPressed, ); } } -class _BusyMaxCategoryInputChip extends StatefulWidget { - const _BusyMaxCategoryInputChip({ - required this.controller, +class _BusyMaxCategoryInput extends StatefulWidget { + const _BusyMaxCategoryInput({ required this.hintText, required this.suggestions, this.inputKey, @@ -1207,7 +1805,6 @@ class _BusyMaxCategoryInputChip extends StatefulWidget { required this.onCancel, }); - final TextEditingController controller; final String hintText; final List suggestions; final Key? inputKey; @@ -1215,316 +1812,189 @@ class _BusyMaxCategoryInputChip extends StatefulWidget { final VoidCallback onCancel; @override - State<_BusyMaxCategoryInputChip> createState() => - _BusyMaxCategoryInputChipState(); + State<_BusyMaxCategoryInput> createState() => _BusyMaxCategoryInputState(); } -class _BusyMaxCategoryInputChipState extends State<_BusyMaxCategoryInputChip> { - late final FocusNode _focusNode; - - @override - void initState() { - super.initState(); - _focusNode = FocusNode(); - } - - @override - void dispose() { - _focusNode.dispose(); - super.dispose(); - } +class _BusyMaxCategoryInputState extends State<_BusyMaxCategoryInput> { + FocusNode? _requestedFocusNode; @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final surfaceColors = BusyMaxSurfaceColors.of(context); - return DecoratedBox( - decoration: BoxDecoration( - color: surfaceColors.control, - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ), - child: Padding( - padding: const EdgeInsetsDirectional.only( - start: BusyMaxSpacing.md, - end: BusyMaxSpacing.xs, - ), - child: SizedBox( - width: 180, - height: 30, - child: Row( - children: [ - Expanded( - child: RawAutocomplete( - textEditingController: widget.controller, - focusNode: _focusNode, - displayStringForOption: (option) => option, - optionsViewOpenDirection: OptionsViewOpenDirection.down, - optionsBuilder: _categoryOptionsFor, - onSelected: widget.onSubmitted, - fieldViewBuilder: - (context, controller, focusNode, onFieldSubmitted) { - return TextField( - key: widget.inputKey, - controller: controller, - focusNode: focusNode, - autofocus: true, - decoration: busyMaxDropdownDecoration().copyWith( - hintText: widget.hintText, - ), - textInputAction: TextInputAction.done, - onSubmitted: _submitTypedCategory, - ); - }, - optionsViewBuilder: (context, onSelected, options) { - return _BusyMaxCategoryAutocompleteOptions( - options: options.toList(growable: false), - onSelected: onSelected, - ); - }, + final materialL10n = MaterialLocalizations.of(context); + return SizedBox( + width: 260, + child: Focus( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + widget.onCancel(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: YaruAutocomplete( + displayStringForOption: (option) => option, + optionsMaxHeight: 240, + optionsBuilder: (value) => + _matchingCategorySuggestions(widget.suggestions, value), + onSelected: widget.onSubmitted, + fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) { + _requestInitialFocus(focusNode); + + void submitTypedCategory() { + final category = _canonicalCategory( + controller.text, + widget.suggestions, + ); + if (category != null) { + widget.onSubmitted(category); + } + } + + return TextField( + key: widget.inputKey, + controller: controller, + focusNode: focusNode, + autofocus: true, + decoration: InputDecoration( + hintText: widget.hintText, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + YaruIconButton( + tooltip: materialL10n.okButtonLabel, + icon: const Icon(YaruIcons.checkmark), + onPressed: submitTypedCategory, + ), + YaruIconButton( + tooltip: materialL10n.cancelButtonLabel, + icon: const Icon(YaruIcons.window_close), + onPressed: widget.onCancel, + ), + ], ), ), - _BusyMaxCategoryIconAction( - icon: YaruIcons.checkmark, - tooltip: MaterialLocalizations.of(context).okButtonLabel, - color: colorScheme.onSurfaceVariant, - onPressed: () => _submitTypedCategory(widget.controller.text), - ), - const SizedBox(width: BusyMaxSpacing.xs), - _BusyMaxCategoryIconAction( - icon: YaruIcons.window_close, - tooltip: MaterialLocalizations.of(context).cancelButtonLabel, - color: colorScheme.onSurfaceVariant, - onPressed: widget.onCancel, - ), - ], - ), + textInputAction: TextInputAction.done, + onSubmitted: (value) { + final options = _matchingCategorySuggestions( + widget.suggestions, + TextEditingValue(text: value), + ); + if (options.isEmpty) { + submitTypedCategory(); + } else { + onFieldSubmitted(); + } + }, + ); + }, ), ), ); } - Iterable _categoryOptionsFor(TextEditingValue value) { - final query = value.text.trim().toLowerCase(); - if (query.isEmpty) { - return const []; + void _requestInitialFocus(FocusNode focusNode) { + if (identical(_requestedFocusNode, focusNode)) { + return; } - final matching = [ - for (final suggestion in widget.suggestions) - if (suggestion.toLowerCase().contains(query)) suggestion, - ]; - matching.sort((left, right) { - final leftLower = left.toLowerCase(); - final rightLower = right.toLowerCase(); - final leftStarts = leftLower.startsWith(query); - final rightStarts = rightLower.startsWith(query); - if (leftStarts != rightStarts) { - return leftStarts ? -1 : 1; + _requestedFocusNode = focusNode; + // This field is inserted into an already-focused editor, so TextField's + // autofocus alone does not reliably move focus from the Add chip. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && identical(_requestedFocusNode, focusNode)) { + focusNode.requestFocus(); } - return leftLower.compareTo(rightLower); }); - return matching.take(8); } +} - void _submitTypedCategory(String value) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - return; +List _visibleCategorySuggestions({ + required List suggestions, + required List selectedCategories, +}) { + final selected = { + for (final category in selectedCategories) _normalizedCategory(category), + }; + final seen = {}; + return [ + for (final suggestion in suggestions) + if (suggestion.trim() case final trimmed + when trimmed.isNotEmpty && + !selected.contains(_normalizedCategory(trimmed)) && + seen.add(_normalizedCategory(trimmed))) + trimmed, + ]; +} + +Iterable _matchingCategorySuggestions( + List suggestions, + TextEditingValue value, +) { + final query = _normalizedCategory(value.text); + if (query.isEmpty) { + return const []; + } + final matching = [ + for (final suggestion in suggestions) + if (_normalizedCategory(suggestion).contains(query)) suggestion, + ]; + matching.sort((left, right) { + final leftNormalized = _normalizedCategory(left); + final rightNormalized = _normalizedCategory(right); + final leftExact = leftNormalized == query; + final rightExact = rightNormalized == query; + if (leftExact != rightExact) { + return leftExact ? -1 : 1; } - String? existing; - for (final suggestion in widget.suggestions) { - if (suggestion.toLowerCase() == trimmed.toLowerCase()) { - existing = suggestion; - break; - } + final leftStarts = leftNormalized.startsWith(query); + final rightStarts = rightNormalized.startsWith(query); + if (leftStarts != rightStarts) { + return leftStarts ? -1 : 1; } - widget.onSubmitted(existing ?? trimmed); - } -} - -class _BusyMaxCategoryIconAction extends StatelessWidget { - const _BusyMaxCategoryIconAction({ - required this.icon, - required this.tooltip, - required this.color, - required this.onPressed, + return leftNormalized.compareTo(rightNormalized); }); - - static const _size = 22.0; - - final IconData icon; - final String tooltip; - final Color color; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final surfaceColors = BusyMaxSurfaceColors.of(context); - return Tooltip( - message: tooltip, - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - clipBehavior: Clip.antiAlias, - child: InkWell( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - hoverColor: surfaceColors.controlHover, - focusColor: surfaceColors.controlHover, - highlightColor: surfaceColors.controlActive, - splashColor: Colors.transparent, - onTap: onPressed, - child: SizedBox.square( - dimension: _size, - child: Icon(icon, size: BusyMaxSizes.iconSm, color: color), - ), - ), - ), - ); - } + return matching.take(8); } -class _BusyMaxCategoryAutocompleteOptions extends StatelessWidget { - const _BusyMaxCategoryAutocompleteOptions({ - required this.options, - required this.onSelected, - }); - - final List options; - final ValueChanged onSelected; - - @override - Widget build(BuildContext context) { - if (options.isEmpty) { - return const SizedBox.shrink(); +String? _canonicalCategory(String value, List suggestions) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return null; + } + final normalized = _normalizedCategory(trimmed); + for (final suggestion in suggestions) { + if (_normalizedCategory(suggestion) == normalized) { + return suggestion; } - final popupTheme = Theme.of(context).popupMenuTheme; - final colorScheme = Theme.of(context).colorScheme; - return LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth.isFinite - ? constraints.maxWidth - : 180.0; - final labelWidth = (width - BusyMaxSpacing.md * 2).clamp(0.0, width); - return Align( - alignment: Alignment.topLeft, - child: Material( - color: popupTheme.color ?? colorScheme.surfaceContainerHigh, - elevation: BusyMaxElevation.popover, - shadowColor: BusyMaxShadow.floatingColor(context), - surfaceTintColor: Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ), - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: width, - maxWidth: width, - maxHeight: 240, - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: options.length, - itemBuilder: (context, index) { - final option = options[index]; - return _BusyMaxCategoryAutocompleteOption( - width: width, - labelWidth: labelWidth, - option: option, - onSelected: onSelected, - ); - }, - ), - ), - ), - ), - ); - }, - ); } + return trimmed; } -class _BusyMaxCategoryAutocompleteOption extends StatelessWidget { - const _BusyMaxCategoryAutocompleteOption({ - required this.width, - required this.labelWidth, - required this.option, - required this.onSelected, - }); - - final double width; - final double labelWidth; - final String option; - final ValueChanged onSelected; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return SizedBox( - width: width, - height: 36, - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - clipBehavior: Clip.antiAlias, - child: InkWell( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - hoverColor: colorScheme.onSurfaceVariant.withValues(alpha: 0.08), - focusColor: colorScheme.onSurfaceVariant.withValues(alpha: 0.08), - highlightColor: colorScheme.onSurfaceVariant.withValues(alpha: 0.12), - splashColor: Colors.transparent, - onTap: () => onSelected(option), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), - child: Align( - alignment: AlignmentDirectional.centerStart, - child: SizedBox( - width: labelWidth, - child: Text( - option, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: colorScheme.onSurface, - ), - ), - ), - ), - ), - ), - ), - ); - } -} +String _normalizedCategory(String value) => value.trim().toLowerCase(); class BusyMaxCalendarValueRow extends StatelessWidget { const BusyMaxCalendarValueRow({ super.key, required this.label, - required this.value, + required this.entry, this.leading, this.trailingIcons = const [], - this.onTap, this.enabled = true, this.tooltip, }); final String label; - final String value; + final Widget entry; final Widget? leading; final List trailingIcons; - final VoidCallback? onTap; final bool enabled; final String? tooltip; @override Widget build(BuildContext context) { - final row = _BusyMaxRowTile( + final row = YaruListTile.square( leading: leading, - title: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text(value, maxLines: 1, overflow: TextOverflow.ellipsis), + title: Semantics(container: true, label: label, child: entry), trailing: trailingIcons.isEmpty ? null : Row( @@ -1538,7 +2008,6 @@ class BusyMaxCalendarValueRow extends StatelessWidget { ], ), enabled: enabled, - onTap: enabled ? onTap : null, ); if (enabled || tooltip == null) { @@ -1547,7 +2016,10 @@ class BusyMaxCalendarValueRow extends StatelessWidget { return Tooltip( message: tooltip!, - child: Opacity(opacity: 0.6, child: IgnorePointer(child: row)), + child: Opacity( + opacity: 0.6, + child: IgnorePointer(child: ExcludeFocus(child: row)), + ), ); } } @@ -1595,23 +2067,57 @@ class BusyMaxCalendarNotesCard extends StatelessWidget { } } +/// A single-selection row following the native AdwComboRow interaction model. +/// +/// The whole row owns hover, focus, and activation. Its current value remains +/// plain trailing content, while menu presentation is delegated to the shared +/// host-toolkit adapter. class BusyMaxComboRow extends StatelessWidget { - const BusyMaxComboRow({ + BusyMaxComboRow({ super.key, required this.title, - required this.values, + required List values, required this.selected, required this.labelFor, required this.onSelected, this.subtitle, + this.errorText, this.leading, this.enabled = true, this.tooltip, - this.width = 220, + this.width = BusyMaxSizes.comboWidth, this.trailingAction, - this.menuItemBuilder, - this.selectedBuilder, - }); + this.selectorLeadingBuilder, + }) : values = List.unmodifiable(values) { + if (this.values.isEmpty) { + throw ArgumentError.value( + values, + 'values', + 'A combo row requires at least one value.', + ); + } + if (this.values.toSet().length != this.values.length) { + throw ArgumentError.value( + values, + 'values', + 'A combo row requires unique values.', + ); + } + if (!this.values.contains(selected)) { + throw ArgumentError.value( + selected, + 'selected', + 'The selected value must be present in values.', + ); + } + if (!width.isFinite || width <= 0) { + throw ArgumentError.value( + width, + 'width', + 'The maximum value width must be finite and positive.', + ); + } + } final String title; final List values; @@ -1619,58 +2125,167 @@ class BusyMaxComboRow extends StatelessWidget { final String Function(T value) labelFor; final ValueChanged onSelected; final String? subtitle; + final String? errorText; final Widget? leading; final bool enabled; final String? tooltip; final double width; final Widget? trailingAction; - final Widget Function(BuildContext context, T value)? menuItemBuilder; - final Widget Function(BuildContext context, T value)? selectedBuilder; + final Widget Function(BuildContext context, T value)? selectorLeadingBuilder; @override Widget build(BuildContext context) { - final row = _BusyMaxRowTile( - leading: leading, - titleText: title, - subtitle: subtitle == null ? null : Text(subtitle!), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: width, - child: MenuButtonBuilder( - selected: selected, - values: values, - menuPosition: PopupMenuPosition.under, - decoration: busyMaxDropdownDecoration(), - style: busyMaxDropdownButtonStyle(context), - menuStyle: busyMaxDropdownMenuStyle(context, minWidth: width), - itemStyle: busyMaxDropdownMenuItemStyle(context), - itemBuilder: (context, value, _) => - menuItemBuilder?.call(context, value) ?? - Text(labelFor(value)), - onSelected: enabled ? onSelected : null, - child: - selectedBuilder?.call(context, selected) ?? - Text(labelFor(selected), overflow: TextOverflow.ellipsis), - ), - ), - if (trailingAction != null) ...[ - const SizedBox(width: BusyMaxSpacing.xs), - trailingAction!, + return LayoutBuilder( + builder: (context, constraints) { + final hasError = errorText?.isNotEmpty ?? false; + final subtitleWidget = hasError + ? Text( + errorText!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ) + : subtitle == null + ? null + : Text(subtitle!); + final styledSubtitle = subtitleWidget == null + ? null + : _busyMaxGroupedRowSubtitle( + context, + subtitleWidget, + enabled: enabled, + ); + final actionAllowance = trailingAction == null + ? 0.0 + : BusyMaxSizes.headerIconButton + BusyMaxSpacing.xs; + final availableWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : width + BusyMaxSpacing.md * 2 + actionAllowance; + final maximumValueWidth = + (availableWidth * BusyMaxFormLayout.comboInlineMaxFraction - + actionAllowance) + .clamp(0.0, width) + .toDouble(); + final menuButton = BusyMaxMenuButton( + tooltip: tooltip ?? title, + entries: [ + for (final value in values) + BusyMaxMenuEntry( + value: value, + label: labelFor(value), + selected: value == selected, + ), ], - ], - ), - enabled: enabled, - ); - - if (enabled || tooltip == null) { - return row; - } - - return Tooltip( - message: tooltip!, - child: Opacity(opacity: 0.6, child: IgnorePointer(child: row)), + onSelected: (value) { + if (value != selected) { + onSelected(value); + } + }, + enabled: enabled, + triggerBuilder: (context, trigger) { + final colors = BusyMaxSurfaceColors.of(context); + final valueForeground = enabled + ? colors.foreground + : colors.disabledForeground; + final value = ExcludeSemantics( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maximumValueWidth), + child: DefaultTextStyle.merge( + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: valueForeground), + child: IconTheme.merge( + data: IconThemeData( + color: valueForeground, + size: BusyMaxSizes.iconSm, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (selectorLeadingBuilder?.call(context, selected) + case final selectedLeading?) ...[ + selectedLeading, + const SizedBox(width: BusyMaxSpacing.sm), + ], + Flexible( + child: Text( + labelFor(selected), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: BusyMaxSpacing.sm), + trigger.anchor(child: const Icon(YaruIcons.pan_down)), + ], + ), + ), + ), + ), + ); + final row = YaruListTile.square( + leading: leading == null + ? null + : ExcludeSemantics(child: leading!), + title: ExcludeSemantics(child: Text(title)), + subtitle: styledSubtitle == null + ? null + : ExcludeSemantics(child: styledSubtitle), + trailing: value, + onTap: trigger.onPressed, + focusNode: trigger.focusNode, + hoverColor: busyMaxRowHoverColor(context), + enabled: enabled, + ); + final semanticRow = Semantics( + container: true, + button: true, + enabled: enabled, + expanded: trigger.isOpen, + onTap: enabled ? trigger.onPressed : null, + label: subtitle == null || subtitle!.isEmpty + ? title + : '$title, $subtitle', + value: labelFor(selected), + hint: hasError ? errorText : null, + liveRegion: hasError, + validationResult: hasError + ? ui.SemanticsValidationResult.invalid + : ui.SemanticsValidationResult.valid, + child: ExcludeSemantics(child: row), + ); + final interactionRow = trailingAction == null + ? semanticRow + : Row( + children: [ + Expanded(child: semanticRow), + Padding( + padding: const EdgeInsetsDirectional.only( + end: BusyMaxSpacing.md, + ), + child: trailingAction!, + ), + ], + ); + final statefulRow = ColoredBox( + color: trigger.isOpen + ? busyMaxRowHoverColor(context) + : Colors.transparent, + child: interactionRow, + ); + final boundedRow = constraints.hasBoundedWidth + ? statefulRow + : SizedBox(width: availableWidth, child: statefulRow); + return tooltip == null + ? boundedRow + : Tooltip( + message: tooltip!, + excludeFromSemantics: true, + child: boundedRow, + ); + }, + ); + return menuButton; + }, ); } } @@ -1695,14 +2310,20 @@ class BusyMaxSwitchRow extends StatelessWidget { @override Widget build(BuildContext context) { - return MergeSemantics( - child: YaruSwitchListTile( - value: value, - onChanged: enabled ? onChanged : null, - secondary: leading, - title: Text(title), - subtitle: subtitle == null ? null : Text(subtitle!), - ), + return YaruSwitchListTile( + value: value, + onChanged: enabled ? onChanged : null, + secondary: leading, + title: Text(title), + subtitle: subtitle == null + ? null + : _busyMaxGroupedRowSubtitle( + context, + Text(subtitle!), + enabled: enabled, + ), + shape: const RoundedRectangleBorder(), + hoverColor: busyMaxRowHoverColor(context), ); } } @@ -1712,8 +2333,10 @@ class BusyMaxMenuEntry { required this.value, required this.label, this.icon, + this.child, + this.shortcut, this.enabled = true, - this.checked = false, + this.selected = false, this.tooltip, this.destructive = false, }); @@ -1721,12 +2344,495 @@ class BusyMaxMenuEntry { final T value; final String label; final IconData? icon; + final Widget? child; + final String? shortcut; final bool enabled; - final bool checked; + final bool selected; final String? tooltip; final bool destructive; } +@immutable +final class BusyMaxMenuSelection { + const BusyMaxMenuSelection(this.value); + + final T value; +} + +/// Owns one native or Flutter fallback menu presentation. +/// +/// A session may be dismissed safely after its owner is disposed. Native +/// dismissal is identity-checked by the host, while fallback dismissal +/// removes only the exact popup route captured for this presentation. +final class BusyMaxMenuSession { + BusyMaxMenuSession() : _nativeSession = NativeMenuSession(); + + final NativeMenuSession _nativeSession; + final GlobalKey _fallbackRouteKey = GlobalKey(); + NativeMenuService _nativeMenuService = const NativeMenuService(); + Route? _fallbackRoute; + bool _started = false; + bool _dismissRequested = false; + + bool get _isDismissed => _dismissRequested; + + Future dismiss() async { + if (_dismissRequested) { + return; + } + _dismissRequested = true; + _removeFallbackRoute(); + await _nativeMenuService.dismiss(_nativeSession); + } + + void _beginPresentation(NativeMenuService nativeMenuService) { + if (_started) { + throw StateError('A BusyMaxMenuSession can present only one menu.'); + } + _started = true; + _nativeMenuService = nativeMenuService; + } + + void _captureFallbackRoute() { + final itemContext = _fallbackRouteKey.currentContext; + if (itemContext == null) { + return; + } + final route = ModalRoute.of(itemContext); + if (route == null) { + return; + } + _fallbackRoute = route; + if (_dismissRequested) { + _removeFallbackRoute(); + } + } + + void _releaseFallbackRoute() { + _fallbackRoute = null; + } + + void _removeFallbackRoute() { + final route = _fallbackRoute; + final navigator = route?.navigator; + if (route != null && navigator != null && route.isActive) { + navigator.removeRoute(route); + } + _fallbackRoute = null; + } +} + +/// Presents a semantic menu at [anchorRect], [anchorContext], or [anchorPoint]. +/// +/// Linux delegates the menu surface, rows, focus, keyboard navigation, and +/// dismissal to GTK. The Flutter route exists only for hosts where that +/// bridge is unavailable and inherits Yaru's popup-menu theme unchanged. +Future?> showBusyMaxMenu({ + required BuildContext context, + required List> entries, + BuildContext? anchorContext, + Offset? anchorPoint, + Rect? anchorRect, + NativeMenuService nativeMenuService = const NativeMenuService(), + BusyMaxMenuSession? session, + bool focusFirst = false, + bool preferAbove = false, +}) async { + assert( + anchorRect == null || (anchorContext == null && anchorPoint == null), + 'anchorRect cannot be combined with anchorContext or anchorPoint.', + ); + if (entries.isEmpty) { + return null; + } + final entrySnapshot = List>.unmodifiable(entries); + _validateBusyMaxMenuEntries(entrySnapshot); + final presentation = session ?? BusyMaxMenuSession(); + if (presentation._isDismissed) { + return null; + } + presentation._beginPresentation(nativeMenuService); + final anchor = + anchorRect ?? + _busyMaxMenuAnchorRect( + anchorContext ?? context, + anchorPoint: anchorPoint, + ); + final nativeResult = await nativeMenuService.show( + session: presentation._nativeSession, + anchor: anchor, + entries: _nativeMenuEntries(entrySnapshot), + focusFirst: focusFirst, + preferAbove: preferAbove, + ); + if (presentation._isDismissed) { + return null; + } + if (nativeResult.available) { + return _busyMaxMenuValueAt(entrySnapshot, nativeResult.selectedIndex); + } + if (!context.mounted) { + return null; + } + final selectedIndex = await _showBusyMaxFlutterMenu( + context: context, + anchor: anchor, + entries: entrySnapshot, + session: presentation, + focusFirst: focusFirst, + ); + if (presentation._isDismissed) { + return null; + } + return _busyMaxMenuValueAt(entrySnapshot, selectedIndex); +} + +void _validateBusyMaxMenuEntries(List> entries) { + final selectedCount = entries.where((entry) => entry.selected).length; + if (selectedCount > 1) { + throw ArgumentError.value( + entries, + 'entries', + 'A single-choice menu can have only one selected entry.', + ); + } + if (selectedCount == 1 && entries.any((entry) => !entry.enabled)) { + throw ArgumentError.value( + entries, + 'entries', + 'Single-choice menu entries must all be enabled.', + ); + } +} + +Rect _busyMaxMenuAnchorRect(BuildContext anchorContext, {Offset? anchorPoint}) { + if (anchorPoint != null) { + return Rect.fromLTWH(anchorPoint.dx, anchorPoint.dy, 0, 0); + } + final renderObject = anchorContext.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) { + return Rect.zero; + } + return renderObject.localToGlobal(Offset.zero) & renderObject.size; +} + +Rect _busyMaxMenuControlAnchorRect({ + required BuildContext triggerContext, + required BuildContext? affordanceContext, +}) { + final triggerRect = _busyMaxMenuAnchorRect(triggerContext); + if (affordanceContext == null) { + return triggerRect; + } + final affordanceRect = _busyMaxMenuAnchorRect(affordanceContext); + if (triggerRect.isEmpty || affordanceRect.isEmpty) { + return triggerRect; + } + return affordanceRect; +} + +List _nativeMenuEntries(List> entries) { + return [ + for (final entry in entries) + NativeMenuEntry( + label: entry.label, + iconName: BusyMaxGlyphs.nativeMenuIconName(entry.icon), + enabled: entry.enabled, + selected: entry.selected, + shortcut: entry.shortcut, + ), + ]; +} + +BusyMaxMenuSelection? _busyMaxMenuValueAt( + List> entries, + int? index, +) { + if (index == null || index < 0 || index >= entries.length) { + return null; + } + final entry = entries[index]; + return entry.enabled ? BusyMaxMenuSelection(entry.value) : null; +} + +Future _showBusyMaxFlutterMenu({ + required BuildContext context, + required Rect anchor, + required List> entries, + required BusyMaxMenuSession session, + required bool focusFirst, +}) async { + final navigator = Navigator.of(context); + final overlay = navigator.overlay?.context.findRenderObject(); + if (overlay is! RenderBox || !overlay.hasSize) { + return null; + } + final localAnchor = Rect.fromPoints( + overlay.globalToLocal(anchor.topLeft), + overlay.globalToLocal(anchor.bottomRight), + ); + final menuAnchor = Rect.fromLTWH( + localAnchor.left, + localAnchor.bottom, + localAnchor.width, + 0, + ); + final hasSelectedEntry = entries.any((entry) => entry.selected); + final selectedIndex = entries.indexWhere((entry) => entry.selected); + final firstEnabledIndex = entries.indexWhere((entry) => entry.enabled); + final firstEnabledKey = focusFirst && firstEnabledIndex >= 0 + ? GlobalKey() + : null; + final routeKey = session._fallbackRouteKey; + final menuHoverColor = BusyMaxSurfaceColors.of(context).controlHover; + final selection = showMenu( + context: context, + position: RelativeRect.fromRect(menuAnchor, Offset.zero & overlay.size), + items: [ + for (var index = 0; index < entries.length; index += 1) + _BusyMaxPopupMenuItem( + value: index, + enabled: entries[index].enabled, + hoverColor: menuHoverColor, + child: _busyMaxFocusableFallbackEntry( + context, + entries[index], + selectionIndicator: hasSelectedEntry + ? ExcludeSemantics( + child: IgnorePointer( + child: YaruRadio( + value: index, + groupValue: selectedIndex, + onChanged: (_) {}, + hasFocusBorder: false, + ), + ), + ) + : null, + focusKey: index == firstEnabledIndex ? firstEnabledKey : null, + routeKey: index == 0 ? routeKey : null, + ), + ), + ], + requestFocus: true, + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + session._captureFallbackRoute(); + if (firstEnabledKey != null && !session._isDismissed) { + final itemContext = firstEnabledKey.currentContext; + if (itemContext != null) { + Focus.of(itemContext).requestFocus(); + } + } + }); + try { + return await selection; + } finally { + session._releaseFallbackRoute(); + } +} + +/// A framework popup row with BusyMax's GTK menu-hover role in scope. +/// +/// [PopupMenuItem] otherwise reads the application-wide Material hover color, +/// which is intentionally much quieter than GTK's menu-row state. Subclassing +/// keeps the framework's menu semantics, focus handling, keyboard navigation, +/// geometry, and ink response intact; only the inherited semantic state color +/// is narrowed to this row. +class _BusyMaxPopupMenuItem extends PopupMenuItem { + const _BusyMaxPopupMenuItem({ + required super.value, + required super.enabled, + required this.hoverColor, + required super.child, + }); + + final Color hoverColor; + + @override + PopupMenuItemState> createState() => + _BusyMaxPopupMenuItemState(); +} + +class _BusyMaxPopupMenuItemState + extends PopupMenuItemState> { + @override + Widget build(BuildContext context) { + final menuTheme = Theme.of(context).copyWith(hoverColor: widget.hoverColor); + return Theme( + data: menuTheme, + child: Builder(builder: (context) => super.build(context)), + ); + } +} + +Widget _busyMaxFocusableFallbackEntry( + BuildContext context, + BusyMaxMenuEntry entry, { + required Widget? selectionIndicator, + required GlobalKey? focusKey, + required GlobalKey? routeKey, +}) { + Widget child = _busyMaxFallbackMenuEntry( + context, + entry, + selectionIndicator: selectionIndicator, + ); + if (selectionIndicator != null) { + child = Semantics( + selected: entry.selected, + inMutuallyExclusiveGroup: true, + child: child, + ); + } + if (focusKey != null) { + child = KeyedSubtree(key: focusKey, child: child); + } + if (routeKey != null) { + child = KeyedSubtree(key: routeKey, child: child); + } + return child; +} + +Widget _busyMaxFallbackMenuEntry( + BuildContext context, + BusyMaxMenuEntry entry, { + required Widget? selectionIndicator, +}) { + final foreground = entry.destructive + ? Theme.of(context).colorScheme.error + : null; + final label = + entry.child ?? + Text( + entry.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: foreground == null ? null : TextStyle(color: foreground), + ); + final shortcut = entry.shortcut == null || entry.shortcut!.isEmpty + ? null + : Directionality( + textDirection: TextDirection.ltr, + child: Text( + entry.shortcut!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall, + ), + ); + final content = + entry.icon == null && selectionIndicator == null && shortcut == null + ? label + : Row( + children: [ + if (selectionIndicator != null) ...[ + selectionIndicator, + const SizedBox(width: BusyMaxSpacing.sm), + ], + if (entry.icon != null) ...[ + Icon(entry.icon, color: foreground), + const SizedBox(width: BusyMaxSpacing.sm), + ], + Expanded(child: label), + if (shortcut != null) ...[ + const SizedBox(width: BusyMaxSpacing.md), + shortcut, + ], + ], + ); + if (entry.enabled || entry.tooltip == null) { + return content; + } + return Tooltip(message: entry.tooltip!, child: content); +} + +typedef BusyMaxMenuTriggerBuilder = + Widget Function(BuildContext context, BusyMaxMenuTriggerDetails trigger); + +/// Interaction and geometry supplied to a custom [BusyMaxMenuButton] trigger. +/// +/// The trigger may make a larger control clickable while marking the native +/// relative widget with [anchor]. Menus are positioned from that widget's +/// exact geometry, matching `GtkPopover.relative-to`. +@immutable +class BusyMaxMenuTriggerDetails { + const BusyMaxMenuTriggerDetails._({ + required this.onPressed, + required this.focusNode, + required this.isOpen, + required GlobalKey menuAnchorKey, + }) : _menuAnchorKey = menuAnchorKey; + + final VoidCallback? onPressed; + final FocusNode focusNode; + final bool isOpen; + final GlobalKey _menuAnchorKey; + + /// Marks the visual affordance used as the menu's native relative widget. + /// + /// A custom trigger should call this exactly once. When omitted, the shared + /// menu adapter safely falls back to the complete trigger bounds. + Widget anchor({required Widget child}) { + return KeyedSubtree(key: _menuAnchorKey, child: child); + } +} + +/// Controls keyboard-driven opening of a [BusyMaxMenuButton]. +/// +/// Pointer activation remains owned by the button so a mouse click does not +/// paint a keyboard focus ring. Commands and shortcuts use +/// [openForKeyboard], which transfers focus to the first enabled menu item. +class BusyMaxMenuController { + Object? _owner; + bool Function()? _openForKeyboard; + VoidCallback? _close; + bool Function()? _isOpen; + + bool get isAttached => _owner != null; + + bool get isOpen => _isOpen?.call() ?? false; + + /// Opens the attached menu in keyboard modality. + /// + /// Returns false when the menu is not attached, is disabled, or is already + /// open. + bool openForKeyboard() { + final open = _openForKeyboard; + if (open == null) { + return false; + } + return open(); + } + + void close() => _close?.call(); + + void _attach({ + required Object owner, + required bool Function() openForKeyboard, + required VoidCallback close, + required bool Function() isOpen, + }) { + // Flutter can mount a replacement responsive subtree before disposing its + // predecessor. Point commands at the newest attachment; the owner check + // in [_detach] prevents the retiring state from detaching its successor. + _owner = owner; + _openForKeyboard = openForKeyboard; + _close = close; + _isOpen = isOpen; + } + + void _detach(Object owner) { + if (!identical(_owner, owner)) { + return; + } + _owner = null; + _openForKeyboard = null; + _close = null; + _isOpen = null; + } +} + class BusyMaxMenuButton extends StatefulWidget { const BusyMaxMenuButton({ super.key, @@ -1734,110 +2840,199 @@ class BusyMaxMenuButton extends StatefulWidget { required this.entries, required this.onSelected, this.icon = const Icon(YaruIcons.view_more), - this.minMenuWidth = 180, + this.triggerBuilder, + this.controller, + this.enabled = true, + this.highlightWhenOpen = true, + this.nativeMenuService = const NativeMenuService(), }); final String tooltip; final Widget icon; final List> entries; final ValueChanged onSelected; - final double minMenuWidth; + final BusyMaxMenuTriggerBuilder? triggerBuilder; + final BusyMaxMenuController? controller; + final bool enabled; + final bool highlightWhenOpen; + final NativeMenuService nativeMenuService; @override State> createState() => _BusyMaxMenuButtonState(); } class _BusyMaxMenuButtonState extends State> { - final _controller = MenuController(); + final _triggerKey = GlobalKey(); + final _menuAnchorKey = GlobalKey(); + late final FocusNode _triggerFocusNode; + BusyMaxMenuSession? _activeMenuSession; + bool _menuOpen = false; @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return MenuAnchor( - controller: _controller, - crossAxisUnconstrained: false, - style: busyMaxDropdownMenuStyle(context, minWidth: widget.minMenuWidth), - builder: (context, controller, child) { - return YaruIconButton( - tooltip: widget.tooltip, - iconSize: BusyMaxSizes.headerIcon, - icon: IconTheme.merge( - data: IconThemeData( - color: colorScheme.onSurfaceVariant, - size: BusyMaxSizes.headerIcon, - ), - child: widget.icon, - ), - onPressed: () { - if (controller.isOpen) { - controller.close(); - return; - } - controller.open( - position: const Offset(0, BusyMaxSizes.headerIconButton), - ); - }, - style: busyMaxHeaderIconButtonStyle( - foregroundColor: colorScheme.onSurfaceVariant, - backgroundColor: busyMaxSubtleButtonBackground(context), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - ), - ); - }, - menuChildren: [ - for (final entry in widget.entries) - _BusyMaxMenuEntryButton( - entry: entry, - onSelected: (value) { - widget.onSelected(value); - _controller.close(); - }, - ), - ], + void initState() { + super.initState(); + _triggerFocusNode = FocusNode( + debugLabel: 'BusyMax menu trigger', + onKeyEvent: _handleTriggerKeyEvent, ); + _attachExternalController(); } -} -class _BusyMaxMenuEntryButton extends StatelessWidget { - const _BusyMaxMenuEntryButton({ - required this.entry, - required this.onSelected, - }); + @override + void didUpdateWidget(covariant BusyMaxMenuButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.controller, widget.controller)) { + oldWidget.controller?._detach(this); + _attachExternalController(); + } + if (oldWidget.enabled && !widget.enabled && _menuOpen) { + _closeMenu(); + } + } - final BusyMaxMenuEntry entry; - final ValueChanged onSelected; + @override + void dispose() { + widget.controller?._detach(this); + final session = _activeMenuSession; + _activeMenuSession = null; + if (session != null) { + unawaited(session.dismiss()); + } + _triggerFocusNode.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final foreground = entry.destructive ? colorScheme.error : null; - final iconData = entry.checked ? YaruIcons.checkmark : entry.icon; - final icon = iconData == null - ? const SizedBox.square(dimension: BusyMaxSizes.iconSm) - : Icon(iconData, size: BusyMaxSizes.iconSm, color: foreground); - final row = MenuItemButton( - leadingIcon: icon, - onPressed: entry.enabled ? () => onSelected(entry.value) : null, - style: busyMaxDropdownMenuItemStyle(context).copyWith( - foregroundColor: foreground == null - ? null - : WidgetStatePropertyAll(foreground), - ), - child: Text( - entry.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: foreground == null ? null : TextStyle(color: foreground), - ), + final triggerBuilder = widget.triggerBuilder; + final triggerDetails = BusyMaxMenuTriggerDetails._( + onPressed: widget.enabled ? _toggleMenu : null, + focusNode: _triggerFocusNode, + isOpen: _menuOpen, + menuAnchorKey: _menuAnchorKey, ); + final trigger = triggerBuilder != null + ? triggerBuilder(context, triggerDetails) + : triggerDetails.anchor( + child: YaruIconButton( + tooltip: widget.tooltip, + icon: widget.icon, + focusNode: _triggerFocusNode, + isSelected: widget.highlightWhenOpen && _menuOpen, + onPressed: widget.enabled ? _toggleMenu : null, + ), + ); + return KeyedSubtree( + key: _triggerKey, + child: triggerBuilder == null + ? Semantics(expanded: _menuOpen, child: trigger) + : trigger, + ); + } - if (entry.enabled || entry.tooltip == null) { - return row; + KeyEventResult _handleTriggerKeyEvent(FocusNode node, KeyEvent event) { + if (!widget.enabled || event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + final key = event.logicalKey; + if (key == LogicalKeyboardKey.arrowDown || + key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.space) { + if (!_menuOpen) { + _openForKeyboard(); + } + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.escape && _menuOpen) { + _closeMenu(); + return KeyEventResult.handled; } + return KeyEventResult.ignored; + } - return Tooltip( - message: entry.tooltip!, - child: Opacity(opacity: 0.55, child: row), + void _toggleMenu() { + if (_menuOpen) { + _closeMenu(); + return; + } + unawaited(_openMenu()); + } + + Future _openMenu({bool focusFirst = false}) async { + final triggerContext = _triggerKey.currentContext; + if (!widget.enabled || + _menuOpen || + triggerContext == null || + widget.entries.isEmpty) { + return; + } + final entries = List>.unmodifiable(widget.entries); + final onSelected = widget.onSelected; + final nativeMenuService = widget.nativeMenuService; + final session = BusyMaxMenuSession(); + _activeMenuSession = session; + setState(() { + _menuOpen = true; + }); + + BusyMaxMenuSelection? selection; + try { + final anchor = _busyMaxMenuControlAnchorRect( + triggerContext: triggerContext, + affordanceContext: _menuAnchorKey.currentContext, + ); + selection = await showBusyMaxMenu( + context: triggerContext, + anchorRect: anchor, + entries: entries, + nativeMenuService: nativeMenuService, + session: session, + focusFirst: focusFirst, + ); + } finally { + if (mounted && identical(_activeMenuSession, session)) { + setState(() { + _activeMenuSession = null; + _menuOpen = false; + }); + } + } + if (mounted && !session._isDismissed && selection != null) { + onSelected(selection.value); + } + } + + bool _openForKeyboard() { + if (!widget.enabled || _menuOpen || widget.entries.isEmpty) { + return false; + } + _triggerFocusNode.requestFocus(); + unawaited(_openMenu(focusFirst: true)); + return true; + } + + void _closeMenu() { + final session = _activeMenuSession; + if (session == null) { + return; + } + // Release the trigger synchronously. Native dismissal and the matching + // `show` response travel over separate platform messages, so waiting for + // that response would leave this button looking open and make a quick + // second click dismiss the same session again instead of reopening. + setState(() { + _activeMenuSession = null; + _menuOpen = false; + }); + unawaited(session.dismiss()); + } + + void _attachExternalController() { + widget.controller?._attach( + owner: this, + openForKeyboard: _openForKeyboard, + close: _closeMenu, + isOpen: () => _menuOpen, ); } } @@ -1967,7 +3162,7 @@ class BusyMaxToolbarButton extends StatelessWidget { required this.label, required this.tooltip, required this.onPressed, - this.primary = false, + this.suggested = false, this.compact = false, }); @@ -1975,7 +3170,7 @@ class BusyMaxToolbarButton extends StatelessWidget { final String label; final String tooltip; final VoidCallback? onPressed; - final bool primary; + final bool suggested; final bool compact; @override @@ -1996,9 +3191,9 @@ class BusyMaxToolbarButton extends StatelessWidget { Text(label), ], ); - final button = primary - ? BusyMaxPushButton.filled(onPressed: onPressed, child: child) - : BusyMaxPushButton.outlined(onPressed: onPressed, child: child); + final button = suggested + ? BusyMaxPushButton.suggested(onPressed: onPressed, child: child) + : BusyMaxPushButton.standard(onPressed: onPressed, child: child); return Tooltip(message: tooltip, child: button); } } @@ -2025,18 +3220,29 @@ class BusyMaxEditorHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final actionStyle = ButtonStyle( + textStyle: WidgetStatePropertyAll(Theme.of(context).textTheme.titleSmall), + ); return Padding( padding: const EdgeInsets.fromLTRB( BusyMaxSpacing.headerInset, BusyMaxSpacing.headerInset, BusyMaxSpacing.headerInset, - 0, + BusyMaxSpacing.headerInset, ), child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - BusyMaxHeaderPushButton.outlined( - onPressed: cancelEnabled ? onCancel : null, - child: Text(cancelLabel, overflow: TextOverflow.ellipsis), + Expanded( + child: Align( + alignment: AlignmentDirectional.centerStart, + heightFactor: 1, + child: BusyMaxPushButton.standard( + onPressed: cancelEnabled ? onCancel : null, + style: actionStyle, + child: Text(cancelLabel, overflow: TextOverflow.ellipsis), + ), + ), ), Expanded( child: Text( @@ -2047,14 +3253,23 @@ class BusyMaxEditorHeader extends StatelessWidget { style: Theme.of(context).textTheme.titleMedium, ), ), - BusyMaxHeaderPushButton.filled( - onPressed: onSave, - child: saving - ? const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(saveLabel, overflow: TextOverflow.ellipsis), + Expanded( + child: Align( + alignment: AlignmentDirectional.centerEnd, + heightFactor: 1, + child: BusyMaxPushButton.suggested( + onPressed: onSave, + style: actionStyle, + child: saving + ? const ExcludeSemantics( + child: SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : Text(saveLabel, overflow: TextOverflow.ellipsis), + ), + ), ), ], ), @@ -2062,6 +3277,118 @@ class BusyMaxEditorHeader extends StatelessWidget { } } +/// A controlled, theme-owned selector for mutually exclusive content modes. +/// +/// [YaruTabBar] owns the visual geometry and interaction treatment. This +/// adapter only validates the domain choices and keeps its controller +/// synchronized with the selected value. +class BusyMaxModeSwitcher extends StatefulWidget { + BusyMaxModeSwitcher({ + super.key, + required List values, + required this.selected, + required this.labelFor, + required this.onSelected, + }) : values = List.unmodifiable(values) { + if (this.values.length < 2) { + throw ArgumentError.value( + values, + 'values', + 'A mode switcher requires multiple values.', + ); + } + if (this.values.toSet().length != this.values.length) { + throw ArgumentError.value( + values, + 'values', + 'A mode switcher requires unique values.', + ); + } + if (!this.values.contains(selected)) { + throw ArgumentError.value( + selected, + 'selected', + 'The selected mode must be present in values.', + ); + } + } + + final List values; + final T selected; + final String Function(T value) labelFor; + final ValueChanged onSelected; + + @override + State> createState() => _BusyMaxModeSwitcherState(); +} + +class _BusyMaxModeSwitcherState extends State> + with TickerProviderStateMixin { + late TabController _controller; + + int get _selectedIndex => widget.values.indexOf(widget.selected); + + @override + void initState() { + super.initState(); + _controller = _newController(); + } + + @override + void didUpdateWidget(covariant BusyMaxModeSwitcher oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.values.length != widget.values.length) { + _controller.dispose(); + _controller = _newController(); + return; + } + if (_controller.index != _selectedIndex) { + _controller.index = _selectedIndex; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + TabController _newController() { + return TabController( + length: widget.values.length, + initialIndex: _selectedIndex, + vsync: this, + ); + } + + void _restoreExternalSelectionAfterInteraction() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _controller.index == _selectedIndex) { + return; + } + _controller.index = _selectedIndex; + }); + } + + @override + Widget build(BuildContext context) { + return YaruTabBar( + tabController: _controller, + tabs: [ + for (final value in widget.values) + YaruTab(label: widget.labelFor(value)), + ], + onTap: (index) { + final value = widget.values[index]; + if (value != widget.selected) { + _restoreExternalSelectionAfterInteraction(); + widget.onSelected(value); + } + }, + ); + } +} + class BusyMaxTimeModeRow extends StatelessWidget { const BusyMaxTimeModeRow({ super.key, @@ -2075,67 +3402,40 @@ class BusyMaxTimeModeRow extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - return Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.xs), - child: Row( - children: [ - Expanded( - child: _BusyMaxTimeModeButton( - label: l10n.allDay, - selected: allDay, - onPressed: () => onChanged(true), - ), - ), - const SizedBox(width: BusyMaxSpacing.xs), - Expanded( - child: _BusyMaxTimeModeButton( - label: l10n.timeSlot, - selected: !allDay, - onPressed: () => onChanged(false), - ), - ), - ], - ), + return BusyMaxModeSwitcher( + values: const [true, false], + selected: allDay, + labelFor: (value) => value ? l10n.allDay : l10n.timeSlot, + onSelected: onChanged, ); } } -class _BusyMaxTimeModeButton extends StatelessWidget { - const _BusyMaxTimeModeButton({ - required this.label, - required this.selected, - required this.onPressed, +class BusyMaxEditorScrollBody extends StatelessWidget { + const BusyMaxEditorScrollBody({ + super.key, + required this.child, + this.maxWidth = 640, }); - final String label; - final bool selected; - final VoidCallback onPressed; + final Widget child; + final double maxWidth; @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final surfaceColors = BusyMaxSurfaceColors.of(context); - final borderRadius = BorderRadius.circular(BusyMaxRadius.headerButton); - return Material( - color: selected ? surfaceColors.controlHover : Colors.transparent, - borderRadius: borderRadius, - child: InkWell( - borderRadius: borderRadius, - onTap: selected ? null : onPressed, - child: SizedBox( - height: BusyMaxSizes.pushButtonHeight, - child: Center( - child: Text( - label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: selected - ? colorScheme.onSurface - : colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ), + return YaruScrollViewUndershoot.builder( + endUndershoot: false, + builder: (context, controller) => BusyMaxClamp( + maxWidth: maxWidth, + margin: EdgeInsets.zero, + padding: const EdgeInsets.fromLTRB( + BusyMaxSpacing.lg, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.lg, + 0, ), + controller: controller, + child: child, ), ); } @@ -2167,74 +3467,33 @@ class BusyMaxModalEditorScaffold extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - BusyMaxEditorHeader( - title: title, - cancelLabel: cancelLabel, - saveLabel: saveLabel, - onCancel: onCancel, - onSave: onSave, - saving: saving, - cancelEnabled: cancelEnabled, - ), - const SizedBox(height: BusyMaxSpacing.headerInset), - Flexible( - child: SingleChildScrollView( - child: BusyMaxClamp( + return Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: title, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + BusyMaxEditorHeader( + title: title, + cancelLabel: cancelLabel, + saveLabel: saveLabel, + onCancel: onCancel, + onSave: onSave, + saving: saving, + cancelEnabled: cancelEnabled, + ), + Flexible( + child: BusyMaxEditorScrollBody( maxWidth: contentMaxWidth, - margin: EdgeInsets.zero, - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.lg, - ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: children, ), ), ), - ), - ], - ); - } -} - -class BusyMaxDialogCloseButton extends StatelessWidget { - const BusyMaxDialogCloseButton({ - super.key, - required this.tooltip, - required this.onPressed, - }); - - final String tooltip; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final surfaceColors = BusyMaxSurfaceColors.of(context); - return Tooltip( - message: tooltip, - child: Material( - color: surfaceColors.control, - shape: const CircleBorder(), - clipBehavior: Clip.antiAlias, - child: InkWell( - customBorder: const CircleBorder(), - hoverColor: surfaceColors.controlHover, - focusColor: surfaceColors.controlHover, - highlightColor: surfaceColors.controlActive, - splashColor: Colors.transparent, - onTap: onPressed, - child: SizedBox.square( - dimension: BusyMaxSizes.aboutCloseButton, - child: Icon( - Icons.close, - size: BusyMaxSizes.iconSm, - color: surfaceColors.mutedForeground, - ), - ), - ), + ], ), ); } @@ -2247,16 +3506,18 @@ class BusyMaxModalEditorSurface extends StatelessWidget { this.minWidth = 0, this.maxWidth = BusyMaxSizes.compactDetailsWidth, this.maxHeight, + this.insetPadding = EdgeInsets.zero, }); final Widget child; final double minWidth; final double maxWidth; final double? maxHeight; + final EdgeInsets insetPadding; @override Widget build(BuildContext context) { - final surfaceColors = BusyMaxSurfaceColors.of(context); + final editorSurface = Theme.of(context).scaffoldBackgroundColor; final effectiveMaxWidth = maxWidth.isFinite ? maxWidth.clamp(0.0, double.infinity).toDouble() : maxWidth; @@ -2270,49 +3531,30 @@ class BusyMaxModalEditorSurface extends StatelessWidget { ? double.infinity : maxHeight!.clamp(0.0, double.infinity).toDouble(); - return ConstrainedBox( - constraints: BoxConstraints( - minWidth: effectiveMinWidth, - maxWidth: effectiveMaxWidth, - maxHeight: effectiveMaxHeight, - ), - child: Material( - color: surfaceColors.dialog, - surfaceTintColor: Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMaxRadius.lg), - ), + return BusyMaxSurfaceScope( + role: BusyMaxSurfaceRole.window, + child: Dialog( + backgroundColor: editorSurface, + surfaceTintColor: editorSurface, + insetPadding: insetPadding, + insetAnimationDuration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : BusyMaxMotion.dialogInsets, + insetAnimationCurve: BusyMaxMotion.dialogInsetsCurve, clipBehavior: Clip.antiAlias, - child: child, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: effectiveMinWidth, + maxWidth: effectiveMaxWidth, + maxHeight: effectiveMaxHeight, + ), + child: child, + ), ), ); } } -class BusyMaxTooltipOnlyDisabled extends StatelessWidget { - const BusyMaxTooltipOnlyDisabled({ - super.key, - required this.enabled, - required this.tooltip, - required this.child, - }); - - final bool enabled; - final String tooltip; - final Widget child; - - @override - Widget build(BuildContext context) { - if (enabled) { - return child; - } - return Tooltip( - message: tooltip, - child: Opacity(opacity: 0.6, child: IgnorePointer(child: child)), - ); - } -} - class BusyMaxInlineBadge extends StatelessWidget { const BusyMaxInlineBadge({super.key, required this.label, this.tooltip}); @@ -2339,60 +3581,120 @@ class BusyMaxInlineBadge extends StatelessWidget { } } +/// Yaru dialog chrome bound to BusyMax's semantic dialog surface. +/// +/// Yaru otherwise falls back to [ThemeData.scaffoldBackgroundColor], which is +/// the application window role and can differ from [DialogThemeData.backgroundColor]. +/// Keeping this adapter shared prevents a dialog title bar and body from +/// resolving different native surface roles. +Color busyMaxDialogSurfaceColor(BuildContext context) { + return DialogTheme.of(context).backgroundColor ?? + BusyMaxSurfaceColors.of(context).dialog; +} + +class BusyMaxDialogTitleBar extends StatelessWidget { + const BusyMaxDialogTitleBar({ + super.key, + this.title, + this.centerTitle = true, + this.closeSemanticLabel, + this.showDividerInHighContrast = true, + }); + + final Widget? title; + final bool centerTitle; + final String? closeSemanticLabel; + final bool showDividerInHighContrast; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = BusyMaxSurfaceColors.of(context); + final dialogSurface = busyMaxDialogSurfaceColor(context); + return Theme( + data: theme.copyWith( + appBarTheme: theme.appBarTheme.copyWith( + backgroundColor: dialogSurface, + surfaceTintColor: dialogSurface, + shadowColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + ), + ), + child: YaruDialogTitleBar( + title: title, + centerTitle: centerTitle, + isActive: true, + backgroundColor: dialogSurface, + border: showDividerInHighContrast && theme.colorScheme.isHighContrast + ? BorderSide(color: colors.divider) + : BorderSide.none, + closeSemanticLabel: closeSemanticLabel, + heroTag: null, + ), + ); + } +} + class BusyMaxDialogShell extends StatelessWidget { const BusyMaxDialogShell({ super.key, required this.title, required this.children, this.maxWidth = 520, + this.header, this.actions = const [], }); final String title; final List children; final double maxWidth; + final Widget? header; final List actions; @override Widget build(BuildContext context) { - return Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMaxRadius.lg), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(BusyMaxRadius.lg), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxWidth), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - YaruDialogTitleBar(title: Text(title), centerTitle: true), - Flexible( - child: SingleChildScrollView( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, + final dialogSurface = busyMaxDialogSurfaceColor(context); + return Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: title, + child: BusyMaxSurfaceScope( + role: BusyMaxSurfaceRole.dialog, + child: Dialog( + backgroundColor: dialogSurface, + surfaceTintColor: dialogSurface, + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header ?? BusyMaxDialogTitleBar(title: Text(title)), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(BusyMaxSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), ), ), - ), - if (actions.isNotEmpty) - Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - for (final action in actions) ...[ - action, - if (action != actions.last) - const SizedBox(width: BusyMaxSpacing.sm), - ], - ], + if (actions.isNotEmpty) + Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.lg), + child: OverflowBar( + alignment: MainAxisAlignment.end, + spacing: BusyMaxSpacing.sm, + overflowSpacing: BusyMaxSpacing.sm, + children: actions, + ), ), - ), - ], + ], + ), ), ), ), @@ -2421,40 +3723,73 @@ class BusyMaxPromptDialog extends StatefulWidget { } class _BusyMaxPromptDialogState extends State { - late String _value; + late final TextEditingController _controller; + late bool _canSubmit; @override void initState() { super.initState(); - _value = widget.initialValue ?? ''; + final initialValue = widget.initialValue ?? ''; + _controller = TextEditingController(text: initialValue) + ..selection = TextSelection( + baseOffset: 0, + extentOffset: initialValue.length, + ) + ..addListener(_handleTextChanged); + _canSubmit = _hasValue; + } + + bool get _hasValue => _controller.text.trim().isNotEmpty; + + void _handleTextChanged() { + final canSubmit = _hasValue; + if (canSubmit != _canSubmit) { + setState(() => _canSubmit = canSubmit); + } + } + + void _submit() { + if (_hasValue) { + Navigator.of(context).pop(_controller.text); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { return BusyMaxDialogShell( title: widget.title, - maxWidth: 420, - actions: [ - BusyMaxPushButton.outlined( - onPressed: () => Navigator.of(context).pop(), - child: Text(context.l10n.cancel), - ), - BusyMaxPushButton.filled( - onPressed: () => Navigator.of(context).pop(_value), - child: Text(widget.actionLabel), - ), - ], + header: BusyMaxEditorHeader( + title: widget.title, + cancelLabel: context.l10n.cancel, + saveLabel: widget.actionLabel, + onCancel: () => Navigator.of(context).pop(), + onSave: _canSubmit ? _submit : null, + ), children: [ - if (widget.message != null && widget.message!.isNotEmpty) ...[ + if (widget.message != null && widget.message!.isNotEmpty) Text(widget.message!), - const SizedBox(height: BusyMaxSpacing.lg), - ], - ValidatedFormField( - initialValue: widget.initialValue, - autofocus: true, - labelText: widget.label, - onChanged: (value) => _value = value, - onEditingComplete: () => Navigator.of(context).pop(_value), + BusyMaxGroupedList( + filled: true, + children: [ + YaruListTile.square( + title: TextFormField( + controller: _controller, + autofocus: true, + textInputAction: TextInputAction.done, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: widget.label, + ), + onFieldSubmitted: (_) => _submit(), + ), + ), + ], ), ], ); @@ -2477,25 +3812,24 @@ class BusyMaxConfirmDialog extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; return BusyMaxDialogShell( title: title, - maxWidth: 460, actions: [ - BusyMaxPushButton.outlined( + BusyMaxPushButton.standard( onPressed: () => Navigator.of(context).pop(false), child: Text(context.l10n.cancel), ), - BusyMaxPushButton.filled( - style: destructive - ? FilledButton.styleFrom( - backgroundColor: colorScheme.error, - foregroundColor: colorScheme.onError, - ) - : null, - onPressed: () => Navigator.of(context).pop(true), - child: Text(confirmLabel), - ), + if (destructive) + BusyMaxPushButton.destructive( + context: context, + onPressed: () => Navigator.of(context).pop(true), + child: Text(confirmLabel), + ) + else + BusyMaxPushButton.suggested( + onPressed: () => Navigator.of(context).pop(true), + child: Text(confirmLabel), + ), ], children: [Text(message)], ); diff --git a/lib/src/app/busymax_dialog_identity.dart b/lib/src/app/busymax_dialog_identity.dart new file mode 100644 index 0000000..59fc4f5 --- /dev/null +++ b/lib/src/app/busymax_dialog_identity.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +import 'busymax_design.dart'; + +/// Native Yaru chrome shared by BusyMax's informational dialogs. +/// +/// The title bar deliberately lives outside the scroll viewport. This keeps +/// its window control fixed, fully opaque, and at Yaru's native metric while +/// the dialog content remains usable in compact windows and at large text +/// scales. +class BusyMaxInformationalDialog extends StatelessWidget { + const BusyMaxInformationalDialog({ + required this.closeLabel, + required this.maxWidth, + required this.child, + this.maxHeight, + super.key, + }); + + final String closeLabel; + final double maxWidth; + final double? maxHeight; + final Widget child; + + @override + Widget build(BuildContext context) { + final dialogSurface = busyMaxDialogSurfaceColor(context); + return BusyMaxSurfaceScope( + role: BusyMaxSurfaceRole.dialog, + child: Builder( + builder: (context) { + return Dialog( + backgroundColor: dialogSurface, + surfaceTintColor: dialogSurface, + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: maxHeight ?? double.infinity, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + BusyMaxDialogTitleBar(closeSemanticLabel: closeLabel), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(BusyMaxSpacing.lg), + child: child, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +/// Shared application identity treatment for informational dialogs. +/// +/// The 128-pixel visual follows libadwaita's large application-icon metric. +/// Dialogs provide their own visual while this widget keeps its geometry and +/// title hierarchy consistent. +class BusyMaxDialogIdentity extends StatelessWidget { + const BusyMaxDialogIdentity({ + required this.visual, + required this.title, + super.key, + }); + + static const visualExtent = 128.0; + static const titleWeight = FontWeight.bold; + + final Widget visual; + final String title; + + @override + Widget build(BuildContext context) { + final titleStyle = + Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: titleWeight) ?? + const TextStyle(fontWeight: titleWeight); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: BusyMaxSpacing.md), + child: SizedBox.square(dimension: visualExtent, child: visual), + ), + ), + const SizedBox(height: BusyMaxSpacing.md), + Text(title, textAlign: TextAlign.center, style: titleStyle), + ], + ); + } +} diff --git a/lib/src/app/busymax_dialogs.dart b/lib/src/app/busymax_dialogs.dart index 455c90b..1acfd79 100644 --- a/lib/src/app/busymax_dialogs.dart +++ b/lib/src/app/busymax_dialogs.dart @@ -1,46 +1,170 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../platform/linux_header_bar_service.dart'; +import '../platform/linux_header_bar_provider.dart'; import 'busymax_design.dart'; +import 'busymax_shortcuts.dart'; -Future showBusyMaxModalEditorDialog( +const _modalShortcuts = { + BusyMaxShortcutActivators.keyboardShortcuts: + DoNothingAndStopPropagationIntent(), + BusyMaxShortcutActivators.settings: DoNothingAndStopPropagationIntent(), +}; + +/// Prevents application-level navigation shortcuts from escaping a modal +/// surface while preserving shortcuts owned by that surface's descendants. +/// +/// Use this for modal UI that is not presented by [showBusyMaxModalDialog], +/// such as anchored popovers and in-page editor overlays. +class BusyMaxModalShortcutBoundary extends StatelessWidget { + const BusyMaxModalShortcutBoundary({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Shortcuts(shortcuts: _modalShortcuts, child: child); + } +} + +final _modalStates = + Map.identity(); +final _modalBarrierUpdateTails = + Map>.identity(); + +Future showBusyMaxModalDialog( BuildContext context, { required WidgetBuilder builder, LinuxHeaderBarService? headerBarService, - double maxWidth = BusyMaxSizes.compactDetailsWidth, - double? maxHeight = 760, + Color? barrierColor, + bool barrierDismissible = true, +}) async { + final effectiveHeaderBarService = + headerBarService ?? _headerBarServiceFrom(context); + return _coordinateBusyMaxModal( + context, + headerBarService: effectiveHeaderBarService, + shadesHeader: barrierColor == null || barrierColor.a > 0, + showSurface: () => _showBusyMaxFlutterDialog( + context, + builder: builder, + barrierColor: barrierColor, + barrierDismissible: barrierDismissible, + ), + ); +} + +Future _coordinateBusyMaxModal( + BuildContext context, { + required LinuxHeaderBarService? headerBarService, + required bool shadesHeader, + required Future Function() showSurface, }) async { - final service = headerBarService; - if (service != null) { - unawaited(service.setModalBarrierVisible(true)); + final previousFocus = FocusManager.instance.primaryFocus; + await acquireBusyMaxModalBarrier( + headerBarService, + shadesHeader: shadesHeader, + ); + if (!context.mounted) { + await releaseBusyMaxModalBarrier( + headerBarService, + shadesHeader: shadesHeader, + ); + return null; } + try { - return await showDialog( - context: context, - barrierColor: busyMaxModalBarrierColor(context), - builder: (dialogContext) { - return Dialog( - backgroundColor: Colors.transparent, - surfaceTintColor: Colors.transparent, - elevation: 0, - insetPadding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: BusyMaxModalEditorSurface( - maxWidth: maxWidth, - maxHeight: maxHeight, - child: builder(dialogContext), - ), - ); - }, - ); + return await showSurface(); } finally { - if (service != null) { - unawaited(service.setModalBarrierVisible(false)); + await releaseBusyMaxModalBarrier( + headerBarService, + shadesHeader: shadesHeader, + ); + if (previousFocus?.context != null && previousFocus!.canRequestFocus) { + previousFocus.requestFocus(); } } } +Future _showBusyMaxFlutterDialog( + BuildContext context, { + required WidgetBuilder builder, + Color? barrierColor, + bool barrierDismissible = true, +}) { + final navigator = Navigator.of(context, rootNavigator: true); + final themes = InheritedTheme.capture(from: context, to: navigator.context); + return navigator.push( + _BusyMaxDialogRoute( + context: context, + builder: builder, + themes: themes, + fixedBarrierColor: barrierColor, + initialBarrierColor: barrierColor ?? busyMaxModalBarrierColor(context), + barrierDismissible: barrierDismissible, + ), + ); +} + +class _BusyMaxDialogRoute extends DialogRoute { + _BusyMaxDialogRoute({ + required super.context, + required WidgetBuilder builder, + required CapturedThemes themes, + required Color? fixedBarrierColor, + required Color initialBarrierColor, + required super.barrierDismissible, + }) : _fixedBarrierColor = fixedBarrierColor, + _initialBarrierColor = initialBarrierColor, + super( + builder: (dialogContext) => + BusyMaxModalShortcutBoundary(child: builder(dialogContext)), + themes: themes, + barrierColor: initialBarrierColor, + traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, + ); + + final Color? _fixedBarrierColor; + final Color _initialBarrierColor; + + /// Unlike [DialogRoute]'s constructor value, this getter is reevaluated + /// when the Navigator's inherited theme changes. + @override + Color? get barrierColor { + final fixedColor = _fixedBarrierColor; + if (fixedColor != null) { + return fixedColor; + } + final navigatorContext = navigator?.context; + return navigatorContext == null + ? _initialBarrierColor + : busyMaxModalBarrierColor(navigatorContext); + } +} + +Future showBusyMaxModalEditorDialog( + BuildContext context, { + required WidgetBuilder builder, + LinuxHeaderBarService? headerBarService, + double maxWidth = BusyMaxSizes.compactDetailsWidth, + double? maxHeight = 760, +}) async { + return showBusyMaxModalDialog( + context, + headerBarService: headerBarService, + barrierDismissible: false, + builder: (dialogContext) { + return BusyMaxModalEditorSurface( + maxWidth: maxWidth, + maxHeight: maxHeight, + insetPadding: const EdgeInsets.all(BusyMaxSpacing.lg), + child: builder(dialogContext), + ); + }, + ); +} + Future showBusyMaxTextPrompt( BuildContext context, { required String title, @@ -49,11 +173,14 @@ Future showBusyMaxTextPrompt( String? initialValue, String? message, Color? barrierColor, + LinuxHeaderBarService? headerBarService, }) { - return showDialog( - context: context, + return showBusyMaxModalDialog( + context, + headerBarService: headerBarService, barrierColor: barrierColor, - builder: (context) => BusyMaxPromptDialog( + barrierDismissible: false, + builder: (dialogContext) => BusyMaxPromptDialog( title: title, label: label, actionLabel: actionLabel, @@ -70,11 +197,13 @@ Future showBusyMaxConfirm( required String confirmLabel, bool destructive = false, Color? barrierColor, + LinuxHeaderBarService? headerBarService, }) async { - final confirmed = await showDialog( - context: context, + final confirmed = await showBusyMaxModalDialog( + context, + headerBarService: headerBarService, barrierColor: barrierColor, - builder: (context) => BusyMaxConfirmDialog( + builder: (dialogContext) => BusyMaxConfirmDialog( title: title, message: message, confirmLabel: confirmLabel, @@ -83,3 +212,152 @@ Future showBusyMaxConfirm( ); return confirmed == true; } + +/// Acquires a reference-counted native header-bar modal barrier. +/// +/// Every call must be paired with [releaseBusyMaxModalBarrier]. In-page modal +/// surfaces should use this pair; route dialogs acquire it automatically. +/// Set [shadesHeader] to false when the matching Flutter barrier is fully +/// transparent but still blocks interaction. +Future acquireBusyMaxModalBarrier( + LinuxHeaderBarService? service, { + bool shadesHeader = true, +}) async { + if (service == null) { + return; + } + final state = _modalStates.putIfAbsent( + service, + _BusyMaxModalBarrierState.new, + ); + final previousVisible = state.modalDepth > 0; + final previousShadeDepth = state.shadeDepth; + state.modalDepth += 1; + if (shadesHeader) { + state.shadeDepth += 1; + } + final visible = state.modalDepth > 0; + final stateChanged = + visible != previousVisible || state.shadeDepth != previousShadeDepth; + final stateUpdate = stateChanged + ? _enqueueBusyMaxModalBarrierUpdate( + service, + visible: visible, + shadeDepth: state.shadeDepth, + ) + : _modalBarrierUpdateTails[service]; + if (stateUpdate == null) { + return; + } + + try { + // A nested transparent route shares the in-flight native state without + // adding a shade. A visibly shaded route increments only shadeDepth. + await stateUpdate; + } on Object catch (error, stackTrace) { + state.modalDepth -= 1; + if (shadesHeader) { + state.shadeDepth -= 1; + } + if (state.modalDepth == 0) { + _modalStates.remove(service); + } + try { + // The platform may have applied the state before its response failed. + // Restore the preceding native state while preserving the cause. + await _enqueueBusyMaxModalBarrierUpdate( + service, + visible: state.modalDepth > 0, + shadeDepth: state.shadeDepth, + ); + } on Object { + // Best-effort rollback cannot replace the causative exception. + } + Error.throwWithStackTrace(error, stackTrace); + } +} + +/// Releases a barrier acquired by [acquireBusyMaxModalBarrier]. +/// +/// [shadesHeader] must match the value used by the corresponding acquire. +Future releaseBusyMaxModalBarrier( + LinuxHeaderBarService? service, { + bool shadesHeader = true, +}) async { + if (service == null) { + return; + } + final state = _modalStates[service]; + if (state == null || state.modalDepth == 0) { + return; + } + final previousVisible = state.modalDepth > 0; + final previousShadeDepth = state.shadeDepth; + state.modalDepth -= 1; + if (shadesHeader && state.shadeDepth > 0) { + state.shadeDepth -= 1; + } + final visible = state.modalDepth > 0; + if (!visible) { + _modalStates.remove(service); + } + if (visible == previousVisible && state.shadeDepth == previousShadeDepth) { + final pending = _modalBarrierUpdateTails[service]; + if (pending != null) { + await pending; + } + return; + } + await _enqueueBusyMaxModalBarrierUpdate( + service, + visible: visible, + shadeDepth: state.shadeDepth, + ); +} + +Future _enqueueBusyMaxModalBarrierUpdate( + LinuxHeaderBarService service, { + required bool visible, + required int shadeDepth, +}) { + final previous = _modalBarrierUpdateTails[service] ?? Future.value(); + final ready = previous.then( + (_) {}, + // A failed update belongs to the caller that requested it. It must not + // poison the per-service queue and prevent a rollback or later retry. + onError: (Object _, StackTrace _) {}, + ); + late final Future update; + update = ready + .then( + (_) => service.setModalBarrierState( + visible: visible, + shadeDepth: shadeDepth, + ), + ) + .whenComplete(() { + if (identical(_modalBarrierUpdateTails[service], update)) { + _modalBarrierUpdateTails.remove(service); + } + }); + _modalBarrierUpdateTails[service] = update; + return update; +} + +final class _BusyMaxModalBarrierState { + int modalDepth = 0; + int shadeDepth = 0; +} + +LinuxHeaderBarService? _headerBarServiceFrom(BuildContext context) { + try { + return ProviderScope.containerOf( + context, + listen: false, + ).read(linuxHeaderBarServiceProvider); + } on StateError { + // Standalone widget hosts (including lightweight tests) do not + // necessarily install Riverpod. Explicit injection remains available. + return null; + } +} diff --git a/lib/src/app/busymax_glyphs.dart b/lib/src/app/busymax_glyphs.dart new file mode 100644 index 0000000..912ba00 --- /dev/null +++ b/lib/src/app/busymax_glyphs.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:yaru/yaru.dart'; + +/// Direction-aware glyphs used by BusyMax navigation and hierarchy controls. +/// +/// Yaru's directional glyphs do not opt in to Flutter's automatic mirroring, +/// so callers select the matching physical glyph explicitly. +abstract final class BusyMaxGlyphs { + const BusyMaxGlyphs._(); + + /// Maps Flutter menu glyphs to equivalent freedesktop themed icons. + /// + /// GTK menu models consume themed-icon names rather than Flutter font + /// glyphs, so the native bridge uses this catalog to retain menu artwork. + static String? nativeMenuIconName(IconData? icon) { + if (icon == null) { + return null; + } + if (icon == YaruIcons.refresh || icon == Icons.refresh) { + return 'view-refresh-symbolic'; + } + if (icon == YaruIcons.trash || icon == Icons.delete_outline) { + return 'user-trash-symbolic'; + } + if (icon == Icons.open_in_browser_outlined) { + return 'external-link-symbolic'; + } + if (icon == Icons.edit_outlined) { + return 'document-edit-symbolic'; + } + if (icon == Icons.calendar_view_day_outlined || + icon == YaruIcons.calendar_day) { + return 'calendar-app-symbolic'; + } + if (icon == Icons.view_week_outlined) { + return 'calendar-week-symbolic'; + } + if (icon == Icons.calendar_view_month) { + return 'calendar-month-symbolic'; + } + if (icon == Icons.calendar_today_outlined || icon == Icons.event_outlined) { + return 'x-office-calendar-symbolic'; + } + if (icon == Icons.view_agenda_outlined) { + return 'calendar-agenda-symbolic'; + } + if (icon == YaruIcons.settings || icon == Icons.settings_outlined) { + return 'preferences-system-symbolic'; + } + if (icon == Icons.keyboard_alt_outlined || + icon == YaruIcons.keyboard_shortcuts) { + return 'input-keyboard-symbolic'; + } + if (icon == YaruIcons.warning) { + return 'dialog-warning-symbolic'; + } + if (icon == Icons.info_outline) { + return 'help-about-symbolic'; + } + if (icon == Icons.task_alt_outlined) { + return 'checkbox-checked-symbolic'; + } + if (icon == YaruIcons.user) { + return 'avatar-default-symbolic'; + } + if (icon == YaruIcons.desktop) { + return 'video-display-symbolic'; + } + if (icon == YaruIcons.bell) { + return 'preferences-system-notifications-symbolic'; + } + if (icon == YaruIcons.shield_warning) { + return 'security-high-symbolic'; + } + if (icon == YaruIcons.monitor) { + return 'diagnostics-symbolic'; + } + return null; + } + + static IconData backFor(TextDirection direction) { + return direction == TextDirection.rtl + ? YaruIcons.arrow_right + : YaruIcons.arrow_left; + } + + static IconData previousFor(TextDirection direction) => backFor(direction); + + static IconData nextFor(TextDirection direction) { + return direction == TextDirection.rtl + ? YaruIcons.arrow_left + : YaruIcons.arrow_right; + } + + static IconData forwardFor(TextDirection direction) { + return direction == TextDirection.rtl + ? YaruIcons.go_previous + : YaruIcons.go_next; + } + + static IconData collapsedFor(TextDirection direction) { + return direction == TextDirection.rtl + ? YaruIcons.pan_start + : YaruIcons.pan_end; + } + + static IconData startFor(TextDirection direction) { + return direction == TextDirection.rtl + ? YaruIcons.pan_end + : YaruIcons.pan_start; + } + + static IconData endFor(TextDirection direction) { + return direction == TextDirection.rtl + ? YaruIcons.pan_start + : YaruIcons.pan_end; + } + + static IconData chevronForwardFor(TextDirection direction) { + return direction == TextDirection.rtl + ? Icons.chevron_left + : Icons.chevron_right; + } + + static IconData subdirectoryFor(TextDirection direction) { + return direction == TextDirection.rtl + ? Icons.subdirectory_arrow_left + : Icons.subdirectory_arrow_right; + } +} diff --git a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart index 9b69e1e..08583d4 100644 --- a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart +++ b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart @@ -1,29 +1,22 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; import '../l10n/l10n.dart'; import '../platform/linux_header_bar_service.dart'; import 'busymax_design.dart'; +import 'busymax_dialog_identity.dart'; +import 'busymax_dialogs.dart'; +import 'busymax_shortcuts.dart'; Future showBusyMaxKeyboardShortcutsDialog( BuildContext context, { LinuxHeaderBarService? headerBarService, }) async { - final service = headerBarService; - if (service != null) { - unawaited(service.setModalBarrierVisible(true)); - } - try { - await showDialog( - context: context, - builder: (context) => const BusyMaxKeyboardShortcutsDialog(), - ); - } finally { - if (service != null) { - unawaited(service.setModalBarrierVisible(false)); - } - } + await showBusyMaxModalDialog( + context, + headerBarService: headerBarService, + builder: (context) => const BusyMaxKeyboardShortcutsDialog(), + ); } class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { @@ -32,171 +25,172 @@ class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; - return Dialog( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 460, maxHeight: 560), - child: Stack( - children: [ - SingleChildScrollView( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Align( - alignment: Alignment.center, - child: Icon( - Icons.keyboard_alt_outlined, - size: 64, - color: colorScheme.primary, - ), - ), - const SizedBox(height: BusyMaxSpacing.md), - Text( - l10n.keyboardShortcuts, - textAlign: TextAlign.center, - style: textTheme.headlineSmall, - ), - const SizedBox(height: BusyMaxSpacing.lg), - BusyMaxGroupedList( - title: l10n.shortcutGroupGeneral, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.keyboardShortcuts, - subtitle: l10n.shortcutKeyboardShortcutsDescription, - leading: const Icon(Icons.keyboard_alt_outlined), - trailing: const _KeyboardShortcutBadge('Ctrl+/'), - ), - ], - ), - BusyMaxGroupedList( - title: l10n.shortcutGroupNavigation, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.shortcutNextPeriod, - subtitle: l10n.shortcutNextPeriodDescription, - leading: const Icon(Icons.arrow_forward), - trailing: const _KeyboardShortcutBadge('Shift+Right'), - ), - BusyMaxActionRow( - title: l10n.shortcutPreviousPeriod, - subtitle: l10n.shortcutPreviousPeriodDescription, - leading: const Icon(Icons.arrow_back), - trailing: const _KeyboardShortcutBadge('Shift+Left'), - ), - BusyMaxActionRow( - title: l10n.shortcutJumpToToday, - leading: const Icon(Icons.today_outlined), - trailing: const _KeyboardShortcutBadge('Shift+T'), - ), - ], - ), - BusyMaxGroupedList( - title: l10n.shortcutGroupCreateAndEdit, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.newEvent, - leading: const Icon(Icons.event_outlined), - trailing: const _KeyboardShortcutBadge('E'), - ), - BusyMaxActionRow( - title: l10n.newTask, - leading: const Icon(Icons.task_alt_outlined), - trailing: const _KeyboardShortcutBadge('T'), - ), - BusyMaxActionRow( - title: l10n.shortcutSaveItem, - leading: const Icon(Icons.save_outlined), - trailing: const _KeyboardShortcutBadge('Ctrl+S'), - ), - BusyMaxActionRow( - title: l10n.shortcutDeleteItem, - leading: const Icon(Icons.delete_outline), - trailing: const _KeyboardShortcutBadge( - 'Backspace / Delete', - ), - ), - ], - ), - BusyMaxGroupedList( - title: l10n.shortcutGroupTaskEditing, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.shortcutCancelEditing, - subtitle: l10n.shortcutCancelEditingDescription, - leading: const Icon(Icons.close), - trailing: const _KeyboardShortcutBadge('Esc'), - ), - ], - ), - BusyMaxGroupedList( - title: l10n.shortcutGroupView, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.shortcutDayView, - leading: const Icon(Icons.calendar_view_day_outlined), - trailing: const _KeyboardShortcutBadge('1 / D'), - ), - BusyMaxActionRow( - title: l10n.shortcutWeekView, - leading: const Icon(Icons.view_week_outlined), - trailing: const _KeyboardShortcutBadge('2 / W'), - ), - BusyMaxActionRow( - title: l10n.shortcutMonthView, - leading: const Icon(Icons.calendar_view_month), - trailing: const _KeyboardShortcutBadge('3 / M'), - ), - BusyMaxActionRow( - title: l10n.shortcutYearView, - leading: const Icon(Icons.calendar_today_outlined), - trailing: const _KeyboardShortcutBadge('4 / Y'), - ), - BusyMaxActionRow( - title: l10n.shortcutAgendaView, - leading: const Icon(Icons.view_agenda_outlined), - trailing: const _KeyboardShortcutBadge('0 / A'), - ), - ], - ), - BusyMaxGroupedList( - title: l10n.shortcutGroupCompactAgenda, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.compactAgendaRefresh, - subtitle: l10n.shortcutRefreshCompactAgendaDescription, - leading: const Icon(Icons.refresh), - trailing: const _KeyboardShortcutBadge('Ctrl+R'), - ), - BusyMaxActionRow( - title: l10n.compactAgendaHide, - subtitle: l10n.shortcutHideCompactAgendaDescription, - leading: const Icon(Icons.visibility_off_outlined), - trailing: const _KeyboardShortcutBadge('Esc'), - ), - ], - ), - ], - ), + return BusyMaxInformationalDialog( + closeLabel: l10n.close, + maxWidth: 460, + maxHeight: 560, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BusyMaxDialogIdentity( + visual: Icon( + YaruIcons.keyboard_shortcuts, + size: BusyMaxDialogIdentity.visualExtent, + color: colorScheme.primary, ), - PositionedDirectional( - top: BusyMaxSpacing.sm, - end: BusyMaxSpacing.sm, - child: BusyMaxDialogCloseButton( - tooltip: l10n.close, - onPressed: () => Navigator.of(context).pop(), + title: l10n.keyboardShortcuts, + ), + const SizedBox(height: BusyMaxSpacing.lg), + BusyMaxGroupedList( + title: l10n.shortcutGroupGeneral, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.keyboardShortcuts, + subtitle: l10n.shortcutKeyboardShortcutsDescription, + leading: const Icon(YaruIcons.keyboard_shortcuts), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.keyboardShortcuts, + ), ), - ), - ], - ), + BusyMaxActionRow( + title: l10n.settings, + leading: const Icon(Icons.settings_outlined), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.settings, + ), + ), + BusyMaxActionRow( + title: MaterialLocalizations.of(context).searchFieldLabel, + leading: const Icon(Icons.search), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.search, + ), + ), + ], + ), + BusyMaxGroupedList( + title: l10n.shortcutGroupNavigation, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.toggleSidebar, + leading: const Icon(Icons.vertical_split_outlined), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.sidebar, + ), + ), + BusyMaxActionRow( + title: l10n.shortcutNextPeriod, + subtitle: l10n.shortcutNextPeriodDescription, + leading: const Icon(Icons.arrow_forward), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.nextPeriod, + ), + ), + BusyMaxActionRow( + title: l10n.shortcutPreviousPeriod, + subtitle: l10n.shortcutPreviousPeriodDescription, + leading: const Icon(Icons.arrow_back), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.previousPeriod, + ), + ), + BusyMaxActionRow( + title: l10n.shortcutJumpToToday, + leading: const Icon(Icons.today_outlined), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.today, + ), + ), + ], + ), + BusyMaxGroupedList( + title: l10n.shortcutGroupCreateAndEdit, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.newEvent, + leading: const Icon(Icons.event_outlined), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.newEvent, + ), + ), + BusyMaxActionRow( + title: l10n.newTask, + leading: const Icon(Icons.task_alt_outlined), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.newTask, + ), + ), + BusyMaxActionRow( + title: l10n.shortcutSaveItem, + leading: const Icon(Icons.save_outlined), + trailing: const _KeyboardShortcutBadge('Ctrl+S'), + ), + BusyMaxActionRow( + title: l10n.shortcutDeleteItem, + leading: const Icon(Icons.delete_outline), + trailing: const _KeyboardShortcutBadge('Backspace / Delete'), + ), + ], + ), + BusyMaxGroupedList( + title: l10n.shortcutGroupTaskEditing, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.shortcutCancelEditing, + subtitle: l10n.shortcutCancelEditingDescription, + leading: const Icon(Icons.close), + trailing: const _KeyboardShortcutBadge('Esc'), + ), + ], + ), + BusyMaxGroupedList( + title: l10n.shortcutGroupView, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.shortcutDayView, + leading: const Icon(Icons.calendar_view_day_outlined), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.dayView, + ), + ), + BusyMaxActionRow( + title: l10n.shortcutWeekView, + leading: const Icon(Icons.view_week_outlined), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.weekView, + ), + ), + BusyMaxActionRow( + title: l10n.shortcutMonthView, + leading: const Icon(Icons.calendar_view_month), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.monthView, + ), + ), + BusyMaxActionRow( + title: l10n.shortcutYearView, + leading: const Icon(Icons.calendar_today_outlined), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.yearView, + ), + ), + BusyMaxActionRow( + title: l10n.shortcutAgendaView, + leading: const Icon(Icons.view_agenda_outlined), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.agendaView, + ), + ), + ], + ), + ], ), ); } @@ -210,22 +204,27 @@ class _KeyboardShortcutBadge extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return DecoratedBox( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(BusyMaxRadius.sm), - border: Border.all(color: colorScheme.outlineVariant), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.sm, - vertical: BusyMaxSpacing.xxs, - ), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium, + return Flexible( + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(BusyMaxRadius.sm), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.sm, + vertical: BusyMaxSpacing.xxs, + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium, + ), + ), ), ), ); diff --git a/lib/src/app/busymax_layout.dart b/lib/src/app/busymax_layout.dart index 7d12550..4b0f1ab 100644 --- a/lib/src/app/busymax_layout.dart +++ b/lib/src/app/busymax_layout.dart @@ -8,8 +8,13 @@ abstract final class BusyMaxLayoutRules { static const double detailsBreakpoint = kYaruMasterDetailBreakpoint * 2; static const double taskPageMinWidth = kYaruMasterDetailBreakpoint - BusyMaxSizes.sidebarWidth / 2; + static const double settingsSidebarBreakpoint = + BusyMaxSizes.sidebarWidth + 520; static bool showSidebar(double width) => width >= sidebarBreakpoint; static bool showPersistentDetails(double width) => width >= detailsBreakpoint; + + static bool showSettingsSidebar(double width) => + width >= settingsSidebarBreakpoint; } diff --git a/lib/src/app/busymax_shortcuts.dart b/lib/src/app/busymax_shortcuts.dart new file mode 100644 index 0000000..135461d --- /dev/null +++ b/lib/src/app/busymax_shortcuts.dart @@ -0,0 +1,48 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../schedule/schedule_view_mode.dart'; + +abstract final class BusyMaxShortcutActivators { + static const keyboardShortcuts = SingleActivator( + LogicalKeyboardKey.keyK, + control: true, + alt: true, + ); + static const settings = SingleActivator( + LogicalKeyboardKey.keyS, + control: true, + alt: true, + ); + static const search = SingleActivator(LogicalKeyboardKey.keyF, control: true); + static const sidebar = SingleActivator(LogicalKeyboardKey.f9); + static const dismiss = SingleActivator(LogicalKeyboardKey.escape); +} + +abstract final class BusyMaxShortcutLabels { + static const keyboardShortcuts = 'Ctrl+Alt+K'; + static const settings = 'Ctrl+Alt+S'; + static const search = 'Ctrl+F'; + static const sidebar = 'F9'; + static const previousPeriod = 'Shift+Left'; + static const nextPeriod = 'Shift+Right'; + static const today = 'Shift+T'; + static const newEvent = 'E'; + static const newTask = 'T'; + static const dayView = '1'; + static const weekView = '2'; + static const monthView = '3'; + static const yearView = '4'; + static const agendaView = '5'; + static const dismiss = 'Esc'; + + static String forViewMode(ScheduleViewMode mode) { + return switch (mode) { + ScheduleViewMode.day => dayView, + ScheduleViewMode.week => weekView, + ScheduleViewMode.month => monthView, + ScheduleViewMode.year => yearView, + ScheduleViewMode.agenda => agendaView, + }; + } +} diff --git a/lib/src/app/busymax_surface_colors.dart b/lib/src/app/busymax_surface_colors.dart index 23ee3ab..2ce32d9 100644 --- a/lib/src/app/busymax_surface_colors.dart +++ b/lib/src/app/busymax_surface_colors.dart @@ -10,6 +10,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { required this.headerbar, required this.headerbarFlat, required this.card, + required this.groupedSurface, required this.dialog, required this.popover, required this.control, @@ -21,7 +22,10 @@ class BusyMaxSurfaceColors extends ThemeExtension { required this.disabledForeground, required this.disabledControl, required this.border, - required this.subtleBorder, + required this.divider, + required this.cardShade, + required this.dialogOutline, + required this.floatingBorder, required this.sidebarBorder, required this.shade, }); @@ -33,6 +37,15 @@ class BusyMaxSurfaceColors extends ThemeExtension { final Color headerbar; final Color headerbarFlat; final Color card; + + /// Source layer for GTK's boxed-list/card role. + /// + /// Modern Yaru publishes this role as a translucent layer in dark mode. + /// It is retained for semantic color resolution, but must not be painted + /// directly by an elevated Flutter [Material]: the physical shadow would + /// show through the translucent fill. Shared grouped surfaces composite it + /// over their declared semantic parent before painting an opaque material. + final Color groupedSurface; final Color dialog; final Color popover; final Color control; @@ -44,7 +57,26 @@ class BusyMaxSurfaceColors extends ThemeExtension { final Color disabledForeground; final Color disabledControl; final Color border; - final Color subtleBorder; + final Color divider; + + /// Recessed separator used between rows inside a boxed-list/card surface. + /// + /// This is libadwaita's `card_shade_color`, which is intentionally distinct + /// from the generic GTK separator and outline roles. + final Color cardShade; + + /// Restrained inside outline for modal dialog surfaces. + /// + /// Modern libadwaita uses a low-opacity light outline for this role. It is + /// intentionally separate from both generic control borders and the darker + /// perimeter used by anchored menus and popovers. + final Color dialogOutline; + + /// Subtle perimeter for anchored menus and popovers. + /// + /// This is the current libadwaita/Yaru popover edge. Dialog decoration is a + /// separate native role and must not reuse this token. + final Color floatingBorder; final Color sidebarBorder; final Color shade; @@ -63,6 +95,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { Color? headerbar, Color? headerbarFlat, Color? card, + Color? groupedSurface, Color? dialog, Color? popover, Color? control, @@ -74,7 +107,10 @@ class BusyMaxSurfaceColors extends ThemeExtension { Color? disabledForeground, Color? disabledControl, Color? border, - Color? subtleBorder, + Color? divider, + Color? cardShade, + Color? dialogOutline, + Color? floatingBorder, Color? sidebarBorder, Color? shade, }) { @@ -86,6 +122,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { headerbar: headerbar ?? this.headerbar, headerbarFlat: headerbarFlat ?? this.headerbarFlat, card: card ?? this.card, + groupedSurface: groupedSurface ?? this.groupedSurface, dialog: dialog ?? this.dialog, popover: popover ?? this.popover, control: control ?? this.control, @@ -97,7 +134,10 @@ class BusyMaxSurfaceColors extends ThemeExtension { disabledForeground: disabledForeground ?? this.disabledForeground, disabledControl: disabledControl ?? this.disabledControl, border: border ?? this.border, - subtleBorder: subtleBorder ?? this.subtleBorder, + divider: divider ?? this.divider, + cardShade: cardShade ?? this.cardShade, + dialogOutline: dialogOutline ?? this.dialogOutline, + floatingBorder: floatingBorder ?? this.floatingBorder, sidebarBorder: sidebarBorder ?? this.sidebarBorder, shade: shade ?? this.shade, ); @@ -120,6 +160,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { headerbar: Color.lerp(headerbar, other.headerbar, t)!, headerbarFlat: Color.lerp(headerbarFlat, other.headerbarFlat, t)!, card: Color.lerp(card, other.card, t)!, + groupedSurface: Color.lerp(groupedSurface, other.groupedSurface, t)!, dialog: Color.lerp(dialog, other.dialog, t)!, popover: Color.lerp(popover, other.popover, t)!, control: Color.lerp(control, other.control, t)!, @@ -135,7 +176,10 @@ class BusyMaxSurfaceColors extends ThemeExtension { )!, disabledControl: Color.lerp(disabledControl, other.disabledControl, t)!, border: Color.lerp(border, other.border, t)!, - subtleBorder: Color.lerp(subtleBorder, other.subtleBorder, t)!, + divider: Color.lerp(divider, other.divider, t)!, + cardShade: Color.lerp(cardShade, other.cardShade, t)!, + dialogOutline: Color.lerp(dialogOutline, other.dialogOutline, t)!, + floatingBorder: Color.lerp(floatingBorder, other.floatingBorder, t)!, sidebarBorder: Color.lerp(sidebarBorder, other.sidebarBorder, t)!, shade: Color.lerp(shade, other.shade, t)!, ); @@ -143,52 +187,99 @@ class BusyMaxSurfaceColors extends ThemeExtension { } BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { + final window = switch (brightness) { + Brightness.light => const Color(0xFFFAFAFA), + Brightness.dark => const Color(0xFF2C2C2C), + }; + final view = switch (brightness) { + Brightness.light => const Color(0xFFFFFFFF), + Brightness.dark => const Color(0xFF272727), + }; + final foreground = switch (brightness) { + Brightness.light => const Color(0xFF3D3D3D), + Brightness.dark => const Color(0xFFF7F7F7), + }; + // Enabled 10–14 px labels consume this role. Keep it opaque so its contrast + // remains stable on every semantic surface instead of stacking alpha on an + // arbitrary view, dialog, or popover background. + final mutedForeground = switch (brightness) { + Brightness.light => const Color(0xFF666666), + Brightness.dark => const Color(0xFFB5B5B5), + }; + return switch (brightness) { - Brightness.light => const BusyMaxSurfaceColors( - window: Color(0xFFFAFAFB), - view: Color(0xFFFFFFFF), - sidebar: Color(0xFFEBEBED), - secondarySidebar: Color(0xFFF3F3F5), - headerbar: Color(0xFFFFFFFF), - headerbarFlat: Color(0xFFFFFFFF), + Brightness.light => BusyMaxSurfaceColors( + // Modern Yaru/libadwaita semantic surface roles. Application workspaces + // choose the window role explicitly; the view role remains available to + // editable and list content instead of being redefined globally. + window: window, + view: view, + sidebar: Color(0xFFEBEBEB), + secondarySidebar: Color(0xFFF0F0F0), + headerbar: Color(0xFFFAFAFA), + headerbarFlat: view, card: Color(0xFFFFFFFF), - dialog: Color(0xFFFAFAFB), - popover: Color(0xFFFFFFFF), - control: Color.fromRGBO(0, 0, 0, 0.06), - controlHover: Color.fromRGBO(0, 0, 0, 0.10), - controlActive: Color.fromRGBO(0, 0, 0, 0.16), + groupedSurface: Color(0xFFFFFFFF), + dialog: const Color(0xFFFAFAFA), + popover: const Color(0xFFFAFAFA), + // Match Yaru's contained-button ladder. A weaker resting layer makes + // standard controls look flat until their hover overlay appears. + control: Color.fromRGBO(0, 0, 0, 0.10), + controlHover: Color.fromRGBO(0, 0, 0, 0.14), + controlActive: Color.fromRGBO(0, 0, 0, 0.18), activeToggle: Color(0xFFFFFFFF), - foreground: Color.fromRGBO(0, 0, 6, 0.80), - mutedForeground: Color.fromRGBO(0, 0, 6, 0.56), - disabledForeground: Color.fromRGBO(0, 0, 6, 0.38), + foreground: foreground, + mutedForeground: mutedForeground, + // Yaru derives disabled content from the semantic foreground rather + // than from absolute black. Keep the shared fallback on that same role + // so native and Flutter controls resolve to one disabled color. + disabledForeground: foreground.withValues(alpha: 0.38), disabledControl: Color.fromRGBO(0, 0, 0, 0.04), - border: Color.fromRGBO(0, 0, 6, 0.18), - subtleBorder: Color.fromRGBO(0, 0, 6, 0.10), - sidebarBorder: Color.fromRGBO(0, 0, 6, 0.07), - shade: Color.fromRGBO(0, 0, 6, 0.07), + border: Color.fromRGBO(0, 0, 0, 0.18), + divider: Color.fromRGBO(0, 0, 0, 0.10), + cardShade: Color.fromRGBO(24, 24, 24, 0.08), + // libadwaita's modal/floating-sheet surface uses this restrained inset + // outline. Keep it independent of the black anchored-popover edge. + dialogOutline: Color.fromRGBO(255, 255, 255, 0.07), + // Current libadwaita/Yaru uses the same restrained edge in both modes. + floatingBorder: Color.fromRGBO(0, 0, 0, 0.14), + sidebarBorder: Color.fromRGBO(24, 24, 24, 0.08), + shade: Color.fromRGBO(0, 0, 0, 0.07), ), - Brightness.dark => const BusyMaxSurfaceColors( - window: Color(0xFF1D1D20), - view: Color(0xFF1D1D20), - sidebar: Color(0xFF2E2E32), - secondarySidebar: Color(0xFF2E2E32), - headerbar: Color(0xFF2E2E32), - headerbarFlat: Color(0xFF1D1D20), - card: Color(0xFF222226), - dialog: Color(0xFF222226), - popover: Color(0xFF383838), + Brightness.dark => BusyMaxSurfaceColors( + // Modern Yaru/libadwaita semantic surface roles. Floating surfaces are + // raised neutral gray rather than the near-black main content role. + window: window, + view: view, + sidebar: Color(0xFF393939), + secondarySidebar: Color(0xFF323232), + headerbar: Color(0xFF393939), + headerbarFlat: Color(0xFF272727), + card: Color(0xFF3D3D3D), + // libadwaita/Yaru's card role is a contextual layer, not a fixed dark + // gray. It resolves against the semantic surface that contains it. + groupedSurface: Color.fromRGBO(255, 255, 255, 0.08), + dialog: Color(0xFF3E3E3E), + popover: Color(0xFF3E3E3E), control: Color.fromRGBO(255, 255, 255, 0.10), controlHover: Color.fromRGBO(255, 255, 255, 0.14), controlActive: Color.fromRGBO(255, 255, 255, 0.18), activeToggle: Color.fromRGBO(255, 255, 255, 0.20), - foreground: Color(0xFFFFFFFF), - mutedForeground: Color.fromRGBO(255, 255, 255, 0.70), - disabledForeground: Color.fromRGBO(255, 255, 255, 0.38), + foreground: foreground, + mutedForeground: mutedForeground, + disabledForeground: foreground.withValues(alpha: 0.38), disabledControl: Color.fromRGBO(255, 255, 255, 0.06), - border: Color.fromRGBO(0, 0, 6, 0.75), - subtleBorder: Color.fromRGBO(255, 255, 255, 0.10), - sidebarBorder: Color.fromRGBO(0, 0, 6, 0.36), - shade: Color.fromRGBO(0, 0, 6, 0.25), + border: Color.fromRGBO(0, 0, 0, 0.75), + divider: Color.fromRGBO(255, 255, 255, 0.10), + // Modern Yaru uses a 36% near-black recessed edge for dark cards. + // Keep the neutral fallback free of the theme's slight blue component. + cardShade: Color.fromRGBO(0, 0, 0, 0.36), + dialogOutline: Color.fromRGBO(255, 255, 255, 0.07), + floatingBorder: Color.fromRGBO(0, 0, 0, 0.14), + // A sidebar boundary is recessed in Yaru, not highlighted. This exact + // fallback mirrors its named semantic role when GTK 3 omits that role. + sidebarBorder: Color.fromRGBO(16, 16, 16, 0.35), + shade: Color.fromRGBO(0, 0, 0, 0.25), ), }; } diff --git a/lib/src/app/busymax_yaru_theme.dart b/lib/src/app/busymax_yaru_theme.dart index af45e9b..22c05fc 100644 --- a/lib/src/app/busymax_yaru_theme.dart +++ b/lib/src/app/busymax_yaru_theme.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:yaru/theme.dart'; @@ -10,36 +8,14 @@ import 'busymax_surface_colors.dart'; export 'busymax_surface_colors.dart'; +const _minimumControlSurfaceContrast = 1.02; + abstract final class BusyMaxLinuxPalette { - static const blueAccent = Color(0xFF3584E4); - static const ubuntuBlueAccent = Color(0xFF0073E5); - static const ubuntuTealAccent = Color(0xFF2190A4); - static const ubuntuGreenAccent = Color(0xFF3A944A); - static const ubuntuYellowAccent = Color(0xFFC88800); - static const ubuntuOrangeAccent = Color(0xFFED5B00); - static const ubuntuRedAccent = Color(0xFFDA3450); - static const ubuntuPinkAccent = Color(0xFFD56199); - static const ubuntuPurpleAccent = Color(0xFF7764D8); - static const ubuntuSlateAccent = Color(0xFF6F8396); - static const ubuntuBrownAccent = Color(0xFF986A44); - static const ubuntuMagentaAccent = Color(0xFFB34CB3); - static const ubuntuOliveAccent = Color(0xFF4B8501); - static const ubuntuPrussianGreenAccent = Color(0xFF308280); - static const ubuntuSageAccent = Color(0xFF657B69); - static const ubuntuWartyBrownAccent = Color(0xFFB39169); static const red3 = Color(0xFFE01B24); static const red5 = Color(0xFFA51D2D); - static const light1 = Color(0xFFFFFFFF); static const light2 = Color(0xFFF6F5F4); - static const light3 = Color(0xFFDEDDDA); static const light4 = Color(0xFFC0BFBC); - static const light5 = Color(0xFF9A9996); - static const dark1 = Color(0xFF77767B); - static const dark2 = Color(0xFF5E5C64); - static const dark3 = Color(0xFF3D3846); - static const dark4 = Color(0xFF241F31); static const dark5 = Color(0xFF000000); - static const darkElevatedSurface = Color(0xFF383838); } class BusyMaxYaruTheme { @@ -51,20 +27,33 @@ class BusyMaxYaruTheme { String? gtkFontFamily, double? gtkFontSize, GtkThemeColors? gtkThemeColors, + bool highContrast = false, }) { final base = switch (brightness) { - Brightness.light => createYaruLightTheme( - primaryColor: BusyMaxLinuxPalette.light4, - ), + Brightness.light => createYaruLightTheme(primaryColor: accentColor), Brightness.dark => createYaruDarkTheme( - primaryColor: BusyMaxLinuxPalette.light2, + primaryColor: accentColor, + highContrast: highContrast, ), }; - final colors = _BusyMaxResolvedSurfaceColors( + final resolvedColors = _BusyMaxResolvedSurfaceColors( brightness, gtkThemeColors: gtkThemeColors, ).colors; - final onAccent = contrastColor(accentColor); + final colors = highContrast + ? _highContrastSurfaceColors(brightness) + : resolvedColors; + final surfaceContainers = _surfaceContainerLadder(colors, brightness); + final sampledAccentForeground = + gtkThemeColors?.brightness == brightness && + gtkThemeColors?.accent == accentColor + ? gtkThemeColors?.accentForeground + : null; + final onAccent = + sampledAccentForeground != null && + _contrastRatio(sampledAccentForeground, accentColor) >= 4.5 + ? sampledAccentForeground + : contrastColor(accentColor); final accentContainer = Color.alphaBlend( accentColor.withValues( alpha: brightness == Brightness.dark ? 0.24 : 0.14, @@ -78,118 +67,79 @@ class BusyMaxYaruTheme { primaryContainer: accentContainer, onPrimaryContainer: contrastColor(accentContainer), secondary: accentColor, - error: brightness == Brightness.dark + error: highContrast + ? base.colorScheme.error + : brightness == Brightness.dark ? BusyMaxLinuxPalette.red3 : BusyMaxLinuxPalette.red5, surface: colors.view, onSurface: colors.foreground, onSurfaceVariant: colors.mutedForeground, - surfaceContainerLowest: colors.window, - surfaceContainerLow: colors.view, - surfaceContainer: colors.card, - surfaceContainerHigh: colors.control, - surfaceContainerHighest: colors.controlHover, + // Generic Material surfaces need opaque elevation roles. The translucent + // control ladder belongs exclusively to interactive widget states. + surfaceContainerLowest: surfaceContainers.lowest, + surfaceContainerLow: surfaceContainers.low, + surfaceContainer: surfaceContainers.container, + surfaceContainerHigh: surfaceContainers.high, + surfaceContainerHighest: surfaceContainers.highest, outline: colors.border, - outlineVariant: colors.subtleBorder, + outlineVariant: colors.divider, scrim: BusyMaxLinuxPalette.dark5, ); - final buttonShape = RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ); - final inputBorder = OutlineInputBorder( - borderSide: BorderSide(color: colors.border), - borderRadius: BorderRadius.circular(6), - ); - final focusedInputBorder = OutlineInputBorder( - borderSide: BorderSide(color: accentColor, width: 2), - borderRadius: BorderRadius.circular(6), - ); final normalizer = _TextStyleNormalizer( gtkFontFamily: gtkFontFamily, gtkFontSize: gtkFontSize, ); final textTheme = _busyMaxTextTheme( base.textTheme, - brightness: brightness, colors: colors, normalizer: normalizer, ); - final inputDecorationTheme = base.inputDecorationTheme.copyWith( - filled: true, - fillColor: colors.control, - border: inputBorder, - enabledBorder: inputBorder, - focusedBorder: focusedInputBorder, - focusedErrorBorder: inputBorder.copyWith( - borderSide: BorderSide(color: colorScheme.error, width: 2), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - labelStyle: normalizer.apply( - base.inputDecorationTheme.labelStyle, - fallback: textTheme.bodyMedium, - ), - floatingLabelStyle: normalizer.apply( - base.inputDecorationTheme.floatingLabelStyle, - fallback: textTheme.bodyMedium, - color: accentColor, - ), - hintStyle: normalizer.apply( - base.inputDecorationTheme.hintStyle, - fallback: textTheme.bodyMedium, - color: colors.mutedForeground, - ), - helperStyle: normalizer.apply( - base.inputDecorationTheme.helperStyle, - fallback: textTheme.bodySmall, - ), - errorStyle: normalizer.apply( - base.inputDecorationTheme.errorStyle, - fallback: textTheme.bodySmall, - color: colorScheme.error, - ), - counterStyle: normalizer.apply( - base.inputDecorationTheme.counterStyle, - fallback: textTheme.bodySmall, - ), + final inputDecorationTheme = _semanticInputDecorationTheme( + base.inputDecorationTheme, + colors: colors, + accentColor: accentColor, + errorColor: colorScheme.error, + normalizer: normalizer, + textTheme: textTheme, + ); + final dropdownInputDecorationTheme = _semanticInputDecorationTheme( + base.dropdownMenuTheme.inputDecorationTheme ?? base.inputDecorationTheme, + colors: colors, + accentColor: accentColor, + errorColor: colorScheme.error, + normalizer: normalizer, + textTheme: textTheme, ); - final outlinedButtonStyle = _buttonStyle( - base.outlinedButtonTheme.style, - shape: buttonShape, + final outlinedButtonStyle = _semanticButtonStyle( + _yaruDesktopButtonStyle(base.outlinedButtonTheme.style), foreground: colors.foreground, - background: colors.control, + background: Colors.transparent, disabledForeground: colors.disabledForeground, - disabledBackground: colors.disabledControl, + disabledBackground: Colors.transparent, textStyle: _normalizeTextStyleProperty( base.outlinedButtonTheme.style?.textStyle, normalizer: normalizer, fallback: textTheme.labelLarge, ), - side: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.focused)) { - return BorderSide(color: accentColor); - } - return BorderSide.none; - }), - ); - final filledButtonStyle = _buttonStyle( - base.filledButtonTheme.style, - shape: buttonShape, - foreground: onAccent, - background: accentColor, + ); + final filledButtonStyle = _semanticButtonStyle( + _yaruDesktopButtonStyle(base.filledButtonTheme.style), + foreground: colors.foreground, + background: colors.control, + selectedBackground: colors.controlActive, disabledForeground: colors.disabledForeground, disabledBackground: colors.disabledControl, - overlayColor: _onAccentOverlay(onAccent), textStyle: _normalizeTextStyleProperty( base.filledButtonTheme.style?.textStyle, normalizer: normalizer, fallback: textTheme.labelLarge, ), ); - final elevatedButtonStyle = _buttonStyle( - base.elevatedButtonTheme.style, - shape: buttonShape, - foreground: colors.foreground, - background: colors.control, + final elevatedButtonStyle = _semanticButtonStyle( + _yaruDesktopButtonStyle(base.elevatedButtonTheme.style), + foreground: onAccent, + background: accentColor, disabledForeground: colors.disabledForeground, disabledBackground: colors.disabledControl, textStyle: _normalizeTextStyleProperty( @@ -197,77 +147,80 @@ class BusyMaxYaruTheme { normalizer: normalizer, fallback: textTheme.labelLarge, ), - ).copyWith(elevation: const WidgetStatePropertyAll(0)); - final textButtonStyle = _buttonStyle( - base.textButtonTheme.style, - shape: buttonShape, + ); + final textButtonStyle = _semanticButtonStyle( + _yaruDesktopButtonStyle(base.textButtonTheme.style), foreground: accentColor, background: Colors.transparent, disabledForeground: colors.disabledForeground, disabledBackground: Colors.transparent, - overlayColor: _accentOverlay(accentColor), textStyle: _normalizeTextStyleProperty( base.textButtonTheme.style?.textStyle, normalizer: normalizer, fallback: textTheme.labelLarge, ), ); - final segmentedButtonStyle = - _buttonStyle( - base.segmentedButtonTheme.style, - shape: buttonShape, - foreground: colors.foreground, - background: colors.control, - disabledForeground: colors.disabledForeground, - disabledBackground: colors.disabledControl, - overlayColor: _accentOverlay(accentColor), - textStyle: _normalizeTextStyleProperty( - base.segmentedButtonTheme.style?.textStyle, - normalizer: normalizer, - fallback: textTheme.labelLarge, + final popoverSurfaceSide = BorderSide( + color: highContrast ? colors.border : colors.floatingBorder, + ); + final dialogSurfaceSide = BorderSide( + color: highContrast ? colors.border : colors.dialogOutline, + ); + final menuStyle = _semanticMenuSurfaceStyle( + base.menuTheme.style, + color: colors.popover, + shadowColor: colorScheme.shadow, + side: popoverSurfaceSide, + ); + final dropdownMenuStyle = _semanticMenuSurfaceStyle( + base.dropdownMenuTheme.menuStyle, + color: colors.popover, + shadowColor: colorScheme.shadow, + side: popoverSurfaceSide, + ); + final cardTheme = base.cardTheme.copyWith( + // Filled Flutter surfaces must be opaque. [colors.card] is the semantic + // GTK layer precomposited over the window/editor surface by the resolver. + // Card and Yaru continue to own elevation and shadow geometry. + color: colors.card, + surfaceTintColor: Colors.transparent, + shadowColor: colorScheme.shadow, + elevation: BusyMaxElevation.groupedCard, + shape: + base.cardTheme.shape ?? + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BusyMaxRadius.md), ), - side: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return BorderSide(color: accentColor); - } - return BorderSide(color: colors.border); - }), - ).copyWith( - foregroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return colors.disabledForeground; - } - if (states.contains(WidgetState.selected)) { - return accentColor; - } - return colors.foreground; - }), - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return accentContainer; - } - return colors.control; - }), - ); + ); + final tooltipTheme = base.tooltipTheme.copyWith( + decoration: BoxDecoration( + color: BusyMaxTooltipStyle.background, + border: Border.all(color: BusyMaxTooltipStyle.border), + borderRadius: BusyMaxTooltipStyle.borderRadius, + ), + textStyle: textTheme.bodyMedium?.copyWith( + color: BusyMaxTooltipStyle.foreground, + ), + padding: BusyMaxTooltipStyle.padding, + constraints: BusyMaxTooltipStyle.constraints, + waitDuration: BusyMaxMotion.tooltipWait, + ); return base.copyWith( brightness: brightness, colorScheme: colorScheme, primaryColor: accentColor, + shadowColor: colorScheme.shadow, scaffoldBackgroundColor: colors.window, canvasColor: colors.window, cardColor: colors.card, + cardTheme: cardTheme, extensions: [ for (final extension in base.extensions.values) if (extension is! BusyMaxSurfaceColors) extension, colors, ], - dividerColor: colors.subtleBorder, - visualDensity: VisualDensity.compact, - splashFactory: NoSplash.splashFactory, - focusColor: accentColor.withValues(alpha: 0.18), - hoverColor: colors.controlHover, - splashColor: accentColor.withValues(alpha: 0.12), + dividerColor: colors.divider, appBarTheme: base.appBarTheme.copyWith( elevation: 0, scrolledUnderElevation: 0, @@ -286,10 +239,14 @@ class BusyMaxYaruTheme { dialogTheme: base.dialogTheme.copyWith( backgroundColor: colors.dialog, surfaceTintColor: colors.dialog, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: colors.border), - ), + // Material 3 makes its default dialog shadow transparent. Restore the + // semantic theme shadow while retaining the framework-owned elevation + // and Yaru-owned dialog geometry. + shadowColor: colorScheme.shadow, + // Retain Yaru's dialog radius and geometry, but use the modern + // libadwaita dialog outline instead of Yaru Flutter's conspicuous + // dark-mode white outline. Popovers have a separate perimeter role. + shape: _withOutlineSide(base.dialogTheme.shape, dialogSurfaceSide), titleTextStyle: normalizer.apply( base.dialogTheme.titleTextStyle, fallback: textTheme.titleLarge, @@ -324,14 +281,12 @@ class BusyMaxYaruTheme { elevatedButtonTheme: ElevatedButtonThemeData(style: elevatedButtonStyle), textButtonTheme: TextButtonThemeData(style: textButtonStyle), iconButtonTheme: IconButtonThemeData( - style: _buttonStyle( + style: _semanticButtonStyle( base.iconButtonTheme.style, - shape: buttonShape, - foreground: colors.mutedForeground, + foreground: colors.foreground, background: Colors.transparent, disabledForeground: colors.disabledForeground, disabledBackground: Colors.transparent, - overlayColor: _accentOverlay(accentColor), textStyle: _normalizeTextStyleProperty( base.iconButtonTheme.style?.textStyle, normalizer: normalizer, @@ -365,8 +320,7 @@ class BusyMaxYaruTheme { return colors.border; }), ), - checkboxTheme: CheckboxThemeData( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(3)), + checkboxTheme: base.checkboxTheme.copyWith( fillColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) { return colors.disabledControl; @@ -390,23 +344,30 @@ class BusyMaxYaruTheme { return colors.mutedForeground; }), ), - segmentedButtonTheme: SegmentedButtonThemeData( - style: segmentedButtonStyle, - ), popupMenuTheme: base.popupMenuTheme.copyWith( color: colors.popover, surfaceTintColor: colors.popover, - elevation: BusyMaxElevation.popover, - shadowColor: colors.shade, - menuPadding: const EdgeInsets.symmetric(vertical: 4), - iconColor: colors.mutedForeground, - iconSize: 16, + shadowColor: colorScheme.shadow, + elevation: menuStyle.elevation?.resolve(const {}), textStyle: normalizer.apply( base.popupMenuTheme.textStyle, fallback: textTheme.bodyMedium, color: colors.foreground, ), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + labelTextStyle: WidgetStateProperty.resolveWith((states) { + return normalizer.apply( + base.popupMenuTheme.labelTextStyle?.resolve(states), + fallback: textTheme.bodyMedium, + color: states.contains(WidgetState.disabled) + ? colors.disabledForeground + : colors.foreground, + ); + }), + shape: _withOutlineSide(base.popupMenuTheme.shape, popoverSurfaceSide), + ), + menuTheme: MenuThemeData( + style: menuStyle, + submenuIcon: base.menuTheme.submenuIcon, ), chipTheme: base.chipTheme.copyWith( labelStyle: normalizer.apply( @@ -418,18 +379,7 @@ class BusyMaxYaruTheme { fallback: textTheme.labelLarge, ), ), - tooltipTheme: base.tooltipTheme.copyWith( - decoration: BoxDecoration( - color: colors.popover, - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - boxShadow: BusyMaxShadow.tooltipShadows(colors.shade), - ), - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.tooltipHorizontal, - vertical: BusyMaxSpacing.tooltipVertical, - ), - textStyle: textTheme.bodyMedium?.copyWith(color: colors.foreground), - ), + tooltipTheme: tooltipTheme, snackBarTheme: base.snackBarTheme.copyWith( contentTextStyle: normalizer.apply( base.snackBarTheme.contentTextStyle, @@ -460,7 +410,8 @@ class BusyMaxYaruTheme { base.dropdownMenuTheme.textStyle, fallback: textTheme.bodyMedium, ), - inputDecorationTheme: inputDecorationTheme, + inputDecorationTheme: dropdownInputDecorationTheme, + menuStyle: dropdownMenuStyle, ), tabBarTheme: base.tabBarTheme.copyWith( labelStyle: normalizer.apply( @@ -512,12 +463,9 @@ class BusyMaxYaruTheme { static TextTheme _busyMaxTextTheme( TextTheme base, { - required Brightness brightness, required BusyMaxSurfaceColors colors, required _TextStyleNormalizer normalizer, }) { - assert(brightness == Brightness.light || brightness == Brightness.dark); - TextStyle? apply(TextStyle? style, {Color? color}) => normalizer.apply(style, color: color); @@ -530,17 +478,123 @@ class BusyMaxYaruTheme { headlineSmall: apply(base.headlineSmall, color: colors.foreground), titleLarge: apply(base.titleLarge, color: colors.foreground), titleMedium: apply(base.titleMedium, color: colors.foreground), - titleSmall: apply(base.titleSmall, color: colors.mutedForeground), + titleSmall: apply(base.titleSmall, color: colors.foreground), bodyLarge: apply(base.bodyLarge, color: colors.foreground), bodyMedium: apply(base.bodyMedium, color: colors.foreground), - bodySmall: apply(base.bodySmall, color: colors.mutedForeground), - labelLarge: apply(base.labelLarge, color: colors.mutedForeground), - labelMedium: apply(base.labelMedium, color: colors.mutedForeground), - labelSmall: apply(base.labelSmall, color: colors.mutedForeground), + bodySmall: apply(base.bodySmall, color: colors.foreground), + labelLarge: apply(base.labelLarge, color: colors.foreground), + labelMedium: apply(base.labelMedium, color: colors.foreground), + labelSmall: apply(base.labelSmall, color: colors.foreground), ); } } +({Color lowest, Color low, Color container, Color high, Color highest}) +_surfaceContainerLadder(BusyMaxSurfaceColors colors, Brightness brightness) { + var ladder = [ + colors.view, + colors.window, + colors.secondarySidebar, + colors.secondarySidebar, + colors.sidebar, + ]; + if (!_isOpaqueMonotonicLadder(ladder, brightness)) { + var candidates = [ + colors.view, + colors.window, + colors.secondarySidebar, + colors.sidebar, + ]; + if (candidates.any((color) => color.a < 1)) { + final fallback = busyMaxFallbackSurfaceColors(brightness); + candidates = [ + fallback.view, + fallback.window, + fallback.secondarySidebar, + fallback.sidebar, + ]; + } + candidates.sort((first, second) { + final luminanceOrder = first.computeLuminance().compareTo( + second.computeLuminance(), + ); + if (luminanceOrder != 0) { + return brightness == Brightness.dark ? luminanceOrder : -luminanceOrder; + } + return first.toARGB32().compareTo(second.toARGB32()); + }); + ladder = [ + candidates[0], + candidates[1], + candidates[2], + candidates[2], + candidates[3], + ]; + } + + return ( + lowest: ladder[0], + low: ladder[1], + container: ladder[2], + high: ladder[3], + highest: ladder[4], + ); +} + +bool _isOpaqueMonotonicLadder(List ladder, Brightness brightness) { + if (ladder.any((color) => color.a < 1)) { + return false; + } + for (var index = 0; index < ladder.length - 1; index++) { + final current = ladder[index].computeLuminance(); + final next = ladder[index + 1].computeLuminance(); + if (brightness == Brightness.light ? current < next : current > next) { + return false; + } + } + return true; +} + +BusyMaxSurfaceColors _highContrastSurfaceColors(Brightness brightness) { + final background = brightness == Brightness.dark + ? Colors.black + : Colors.white; + final foreground = contrastColor(background); + Color layer(double opacity) => + Color.alphaBlend(foreground.withValues(alpha: opacity), background); + + // High contrast is an accessibility contract, so it intentionally uses one + // coherent palette instead of retaining GTK surfaces that may have mixed + // luminance. Normal themes continue to preserve every valid GTK role. + return BusyMaxSurfaceColors( + window: background, + view: background, + sidebar: background, + secondarySidebar: background, + headerbar: background, + headerbarFlat: background, + card: background, + groupedSurface: background, + dialog: background, + popover: background, + control: layer(0.10), + controlHover: layer(0.18), + controlActive: layer(0.28), + activeToggle: layer(0.22), + foreground: foreground, + mutedForeground: foreground, + disabledForeground: layer(0.55), + disabledControl: layer(0.06), + border: foreground, + divider: foreground, + cardShade: foreground, + dialogOutline: foreground, + floatingBorder: foreground, + sidebarBorder: foreground, + shade: Colors.black.withValues(alpha: 0.50), + ); +} + class _TextStyleNormalizer { _TextStyleNormalizer({String? gtkFontFamily, double? gtkFontSize}) : _gtkFontFamily = _validFontFamily(gtkFontFamily), @@ -619,84 +673,200 @@ class _BusyMaxResolvedSurfaceColors { return fallback; } - final window = - _runtimeSurfaceColor(runtime.window, brightness: brightness) ?? + // GTK surface colors may be translucent CSS layers. Resolve them against + // their semantic parent instead of rejecting valid native theme colors or + // trying to infer whether a theme's hue is aesthetically acceptable. + final sampledWindow = + _runtimeSurfaceColor(runtime.window, over: fallback.window) ?? fallback.window; + final sampledView = + _runtimeSurfaceColor(runtime.view, over: sampledWindow) ?? + fallback.view; final runtimeSidebar = _runtimeSurfaceColor( runtime.sidebar, - brightness: brightness, + over: sampledWindow, ); + final sampledSidebar = runtimeSidebar ?? fallback.sidebar; final runtimeSecondarySidebar = _runtimeSurfaceColor( runtime.secondarySidebar, - brightness: brightness, - ); - final runtimeView = _runtimeSurfaceColor( - runtime.view, - brightness: brightness, + over: sampledWindow, ); + final sampledSecondarySidebar = + runtimeSecondarySidebar ?? fallback.secondarySidebar; final runtimeHeaderbar = _runtimeSurfaceColor( runtime.headerbar, - brightness: brightness, + over: sampledWindow, ); - final runtimeHeaderbarFlat = _runtimeSurfaceColor( - runtime.headerbarFlat, - brightness: brightness, + final sampledHeaderbar = runtimeHeaderbar ?? fallback.headerbar; + final sampledHeaderbarFlat = + _runtimeSurfaceColor(runtime.headerbarFlat, over: sampledView) ?? + sampledView; + final runtimeCard = _runtimeSurfaceColor(runtime.card, over: sampledWindow); + final sampledCard = runtimeCard ?? fallback.card; + final runtimeDialog = _runtimeSurfaceColor( + runtime.dialog, + over: sampledWindow, ); - final view = runtimeView ?? fallback.view; - final sidebar = - _firstDistinctSemanticColor( - [runtimeSidebar, runtimeSecondarySidebar, runtimeHeaderbar], - from: [view, window], + final sampledDialog = runtimeDialog ?? fallback.dialog; + final runtimePopover = _runtimeSurfaceColor( + runtime.popover, + over: sampledWindow, + ); + final sampledPopover = runtimePopover ?? fallback.popover; + final sampledBackgrounds = [ + sampledWindow, + sampledView, + sampledSidebar, + sampledSecondarySidebar, + sampledHeaderbar, + sampledHeaderbarFlat, + sampledCard, + sampledDialog, + sampledPopover, + ]; + final foreground = + _runtimeReadableColor( + runtime.foreground, + backgrounds: sampledBackgrounds, + requireOpaque: true, ) ?? - _derivedSidebarColor(brightness, view); - final readableBackgrounds = [window, view, sidebar]; + fallback.foreground; + + Color readableSurface(Color sampled, Color fallbackSurface) { + return _contrastRatio(foreground, sampled) >= 4.5 + ? sampled + : fallbackSurface; + } + + // The Linux bridge only publishes named GTK semantic roles here; legacy + // widget-class samples are omitted instead of being mislabeled as modern + // surface roles. Preserve every supplied role when the shared foreground + // remains readable. Inferring validity from luminance ordering would + // reject legitimate custom GTK palettes. + final window = readableSurface(sampledWindow, fallback.window); + final view = readableSurface(sampledView, fallback.view); + final sidebar = readableSurface(sampledSidebar, fallback.sidebar); + final secondarySidebar = readableSurface( + sampledSecondarySidebar, + fallback.secondarySidebar, + ); + final headerbar = readableSurface(sampledHeaderbar, fallback.headerbar); + final headerbarFlat = readableSurface( + sampledHeaderbarFlat, + fallback.headerbarFlat, + ); + final card = readableSurface(sampledCard, fallback.card); + final dialog = readableSurface(sampledDialog, fallback.dialog); + final popover = readableSurface(sampledPopover, fallback.popover); + // Preserve GTK's semantic card layer as source data. Modern Yaru makes + // this translucent, while [card] above is its opaque window/editor + // composition. Elevated Flutter cards must paint that opaque role so + // their physical shadow cannot bleed through the fill. + final groupedSurface = _resolvedGroupedSurfaceLayer( + runtime.card, + fallback: fallback.groupedSurface, + foreground: foreground, + backgrounds: [window, view, dialog, popover], + ); + final effectiveGroupedSurfaces = [ + for (final background in [window, view, dialog, popover]) + _surfaceColorOver(groupedSurface, over: background), + ]; + // Sidebar boundaries, inset separators, and floating-surface outlines are + // distinct named native roles. Preserve their GTK values directly instead + // of rejecting a legitimate recessed edge by its luminance polarity. + final runtimeSidebarBorder = _runtimeColor(runtime.sidebarBorder); + final runtimeDivider = _runtimeColor(runtime.divider); + final runtimeCardShade = _runtimeColor(runtime.cardShade); + final runtimeFloatingBorder = _runtimeColor(runtime.floatingBorder); + final readableBackgrounds = [ + window, + view, + sidebar, + secondarySidebar, + headerbar, + headerbarFlat, + card, + dialog, + popover, + ]; + final mutedForeground = _resolvedReadableColor( + runtime.mutedForeground, + fallback: fallback.mutedForeground, + guaranteed: foreground, + backgrounds: readableBackgrounds, + minContrast: 4.5, + requireOpaque: true, + ); + final disabledForeground = _resolvedReadableColor( + runtime.disabledForeground, + fallback: fallback.disabledForeground, + guaranteed: foreground, + backgrounds: readableBackgrounds, + minContrast: 1.5, + ); + final controlLadder = _resolvedControlLadder( + runtimeControl: runtime.control, + runtimeHover: runtime.controlHover, + runtimeActive: runtime.controlActive, + fallback: fallback, + backgrounds: [ + view, + sidebar, + dialog, + popover, + ...effectiveGroupedSurfaces, + ], + ); return fallback.copyWith( window: window, view: view, sidebar: sidebar, - secondarySidebar: runtimeSecondarySidebar, - headerbar: runtimeHeaderbar, - headerbarFlat: runtimeHeaderbarFlat ?? view, - card: _runtimeSurfaceColor(runtime.card, brightness: brightness), - dialog: _runtimeSurfaceColor(runtime.dialog, brightness: brightness), - popover: _runtimeElevatedSurfaceColor( - runtime.popover, - brightness: brightness, - ), - control: _runtimeControlColor(runtime.control, brightness: brightness), - controlHover: _runtimeControlColor( - runtime.controlHover, - brightness: brightness, - ), - controlActive: _runtimeControlColor( - runtime.controlActive, - brightness: brightness, - ), - activeToggle: _runtimeColor(runtime.activeToggle), - foreground: _runtimeReadableColor( - runtime.foreground, - backgrounds: readableBackgrounds, - ), - mutedForeground: _runtimeReadableColor( - runtime.mutedForeground, - backgrounds: readableBackgrounds, - minContrast: 3.0, - ), - disabledForeground: _runtimeReadableColor( - runtime.disabledForeground, - backgrounds: readableBackgrounds, - minContrast: 1.5, - ), - disabledControl: _runtimeColor(runtime.disabledControl), + secondarySidebar: secondarySidebar, + headerbar: headerbar, + headerbarFlat: headerbarFlat, + card: card, + groupedSurface: groupedSurface, + dialog: dialog, + popover: popover, + control: controlLadder.control, + controlHover: controlLadder.hover, + controlActive: controlLadder.active, + activeToggle: _runtimeOverlayColor(runtime.activeToggle), + foreground: foreground, + mutedForeground: mutedForeground, + disabledForeground: disabledForeground, + disabledControl: _runtimeOverlayColor(runtime.disabledControl), border: _runtimeColor(runtime.border), - subtleBorder: _runtimeColor(runtime.subtleBorder), - sidebarBorder: _runtimeColor(runtime.sidebarBorder), - shade: _runtimeShadeColor(runtime.shade), + divider: runtimeDivider, + cardShade: runtimeCardShade, + floatingBorder: runtimeFloatingBorder, + sidebarBorder: runtimeSidebarBorder, + shade: _runtimeShadeColor(runtime.shade, over: popover), ); } } +Color _resolvedGroupedSurfaceLayer( + Color? runtimeCard, { + required Color fallback, + required Color foreground, + required Iterable backgrounds, +}) { + final candidate = _runtimeColor(runtimeCard); + if (candidate == null) { + return fallback; + } + for (final background in backgrounds) { + final effective = _surfaceColorOver(candidate, over: background); + if (_contrastRatio(foreground, effective) < 4.5) { + return fallback; + } + } + return candidate; +} + Color? _runtimeColor(Color? color) { if (color == null || color.a <= 0) { return null; @@ -704,24 +874,91 @@ Color? _runtimeColor(Color? color) { return color; } -Color? _runtimeShadeColor(Color? color) { - final runtime = _runtimeColor(color); - if (runtime == null) { +Color? _runtimeOverlayColor(Color? color) { + final candidate = _runtimeColor(color); + if (candidate == null || candidate.a >= 1) { return null; } - if (runtime.computeLuminance() > 0.35) { + return candidate; +} + +({Color control, Color hover, Color active}) _resolvedControlLadder({ + required Color? runtimeControl, + required Color? runtimeHover, + required Color? runtimeActive, + required BusyMaxSurfaceColors fallback, + required Iterable backgrounds, +}) { + final control = _runtimeOverlayColor(runtimeControl); + final hover = _runtimeOverlayColor(runtimeHover); + final active = _runtimeOverlayColor(runtimeActive); + if (control == null || hover == null || active == null) { + return ( + control: fallback.control, + hover: fallback.controlHover, + active: fallback.controlActive, + ); + } + + // GTK 3 themes can report only a nearly transparent background-color while + // painting the actual button through a background image. Such samples are + // not usable as Flutter control roles. Require at least Yaru's semantic + // strength and validate the complete state ladder against every surface on + // which a shared control may appear. + if (control.a < fallback.control.a || + hover.a < fallback.controlHover.a || + active.a < fallback.controlActive.a) { + return ( + control: fallback.control, + hover: fallback.controlHover, + active: fallback.controlActive, + ); + } + + for (final background in backgrounds) { + final controlContrast = _contrastRatio( + Color.alphaBlend(control, background), + background, + ); + final hoverContrast = _contrastRatio( + Color.alphaBlend(hover, background), + background, + ); + final activeContrast = _contrastRatio( + Color.alphaBlend(active, background), + background, + ); + if (controlContrast < _minimumControlSurfaceContrast || + hoverContrast < controlContrast || + activeContrast < hoverContrast) { + return ( + control: fallback.control, + hover: fallback.controlHover, + active: fallback.controlActive, + ); + } + } + + return (control: control, hover: hover, active: active); +} + +Color? _runtimeShadeColor(Color? color, {required Color over}) { + final runtime = _runtimeColor(color); + if (runtime == null) { return null; } - return runtime; + final shaded = Color.alphaBlend(runtime, over); + return shaded.computeLuminance() < over.computeLuminance() ? runtime : null; } Color? _runtimeReadableColor( Color? color, { required Iterable backgrounds, double minContrast = 4.5, + bool requireOpaque = false, }) { final runtime = _runtimeColor(color); - if (runtime == null) { + if (runtime == null || (requireOpaque && runtime.a < 1)) { return null; } for (final background in backgrounds) { @@ -732,6 +969,29 @@ Color? _runtimeReadableColor( return runtime; } +Color _resolvedReadableColor( + Color? runtime, { + required Color fallback, + required Color guaranteed, + required Iterable backgrounds, + required double minContrast, + bool requireOpaque = false, +}) { + return _runtimeReadableColor( + runtime, + backgrounds: backgrounds, + minContrast: minContrast, + requireOpaque: requireOpaque, + ) ?? + _runtimeReadableColor( + fallback, + backgrounds: backgrounds, + minContrast: minContrast, + requireOpaque: requireOpaque, + ) ?? + guaranteed; +} + double _contrastRatio(Color foreground, Color background) { final effectiveForeground = foreground.a < 1 ? Color.alphaBlend(foreground, background) @@ -747,122 +1007,16 @@ double _contrastRatio(Color foreground, Color background) { return (lighter + 0.05) / (darker + 0.05); } -Color? _runtimeSurfaceColor(Color? color, {Brightness? brightness}) { +Color? _runtimeSurfaceColor(Color? color, {required Color over}) { final runtime = _runtimeColor(color); if (runtime == null) { return null; } - if (runtime.a < 0.98 || _isNearBlackSurface(runtime)) { - return null; - } - if (brightness == Brightness.dark && _isTintedDarkSurface(runtime)) { - return null; - } - return runtime; -} - -Color? _runtimeControlColor(Color? color, {required Brightness brightness}) { - final runtime = _runtimeColor(color); - if (runtime == null) { - return null; - } - if (_isChromaticControlColor(runtime)) { - return null; - } - if (brightness == Brightness.dark && - runtime.a >= 0.98 && - _isTintedDarkSurface(runtime)) { - return null; - } - return runtime; -} - -Color? _runtimeElevatedSurfaceColor( - Color? color, { - required Brightness brightness, -}) { - final runtime = _runtimeSurfaceColor(color, brightness: brightness); - if (runtime == null) { - return null; - } - if (brightness == Brightness.dark && _isTooDarkElevatedSurface(runtime)) { - return null; - } - return runtime; -} - -bool _isTooDarkElevatedSurface(Color color) { - final value = color.toARGB32(); - final red = (value >> 16) & 0xff; - final green = (value >> 8) & 0xff; - final blue = value & 0xff; - return red < 0x38 || green < 0x38 || blue < 0x38; -} - -bool _isNearBlackSurface(Color color) { - final value = color.toARGB32(); - final red = (value >> 16) & 0xff; - final green = (value >> 8) & 0xff; - final blue = value & 0xff; - return red <= 24 && green <= 24 && blue <= 24; -} - -bool _isTintedDarkSurface(Color color) { - return _isBlueDominantColor(color); -} - -bool _isBlueDominantColor(Color color) { - final value = color.toARGB32(); - final red = (value >> 16) & 0xff; - final green = (value >> 8) & 0xff; - final blue = value & 0xff; - final maxChannel = math.max(red, math.max(green, blue)); - final minChannel = math.min(red, math.min(green, blue)); - return blue == maxChannel && maxChannel - minChannel >= 10; -} - -bool _isChromaticControlColor(Color color) { - final value = color.toARGB32(); - final red = (value >> 16) & 0xff; - final green = (value >> 8) & 0xff; - final blue = value & 0xff; - final maxChannel = math.max(red, math.max(green, blue)); - final minChannel = math.min(red, math.min(green, blue)); - return maxChannel - minChannel >= 10; -} - -Color? _firstDistinctSemanticColor( - Iterable candidates, { - required Iterable from, -}) { - for (final candidate in candidates) { - if (candidate == null) { - continue; - } - final duplicatesExisting = from.any( - (existing) => _sameSemanticColor(candidate, existing), - ); - if (!duplicatesExisting) { - return candidate; - } - } - return null; + return _surfaceColorOver(runtime, over: over); } -Color _derivedSidebarColor(Brightness brightness, Color base) { - final overlay = brightness == Brightness.dark - ? Colors.white.withValues(alpha: 0.055) - : Colors.black.withValues(alpha: 0.045); - return Color.alphaBlend(overlay, base); -} - -bool _sameSemanticColor(Color left, Color right) { - final leftValue = left.toARGB32(); - final rightValue = right.toARGB32(); - return ((leftValue >> 24) & 0xff) == ((rightValue >> 24) & 0xff) && - ((leftValue >> 16) & 0xff) == ((rightValue >> 16) & 0xff) && - ((leftValue >> 8) & 0xff) == ((rightValue >> 8) & 0xff) && - (leftValue & 0xff) == (rightValue & 0xff); +Color _surfaceColorOver(Color color, {required Color over}) { + return color.a < 1 ? Color.alphaBlend(color, over) : color; } String? _validFontFamily(String? family) { @@ -887,80 +1041,163 @@ WidgetStateProperty _normalizeTextStyleProperty( }); } -ButtonStyle _buttonStyle( +/// Applies semantic colors and GTK typography while retaining the input +/// geometry supplied by Yaru, including component-specific constraints. +InputDecorationThemeData _semanticInputDecorationTheme( + InputDecorationThemeData base, { + required BusyMaxSurfaceColors colors, + required Color accentColor, + required Color errorColor, + required _TextStyleNormalizer normalizer, + required TextTheme textTheme, +}) { + final disabledBorderColor = colors.border.withValues( + alpha: colors.border.a * 0.6, + ); + + InputBorder? borderWithColor(InputBorder? border, Color color) { + return border?.copyWith( + borderSide: border.borderSide.copyWith(color: color), + ); + } + + return base.copyWith( + border: borderWithColor(base.border, colors.border), + enabledBorder: borderWithColor(base.enabledBorder, colors.border), + focusedBorder: borderWithColor(base.focusedBorder, accentColor), + errorBorder: borderWithColor(base.errorBorder, errorColor), + focusedErrorBorder: borderWithColor(base.focusedErrorBorder, errorColor), + disabledBorder: borderWithColor(base.disabledBorder, disabledBorderColor), + activeIndicatorBorder: base.activeIndicatorBorder?.copyWith( + color: accentColor, + ), + outlineBorder: base.outlineBorder?.copyWith(color: colors.border), + iconColor: colors.foreground, + labelStyle: normalizer.apply( + base.labelStyle, + fallback: textTheme.bodyMedium, + ), + floatingLabelStyle: normalizer.apply( + base.floatingLabelStyle, + fallback: textTheme.bodyMedium, + color: accentColor, + ), + hintStyle: normalizer.apply( + base.hintStyle, + fallback: textTheme.bodyMedium, + color: colors.mutedForeground, + ), + helperStyle: normalizer.apply( + base.helperStyle, + fallback: textTheme.bodySmall, + ), + errorStyle: normalizer.apply( + base.errorStyle, + fallback: textTheme.bodySmall, + color: errorColor, + ), + counterStyle: normalizer.apply( + base.counterStyle, + fallback: textTheme.bodySmall, + ), + ); +} + +ShapeBorder? _withOutlineSide(ShapeBorder? shape, BorderSide side) { + return switch (shape) { + final InputBorder input => input.copyWith(borderSide: side), + final OutlinedBorder outlined => outlined.copyWith(side: side), + _ => shape, + }; +} + +/// Applies only the semantic floating-surface roles and retains Yaru's menu +/// geometry, item states, padding, and motion. +MenuStyle _semanticMenuSurfaceStyle( + MenuStyle? base, { + required Color color, + required Color shadowColor, + required BorderSide side, +}) { + return (base ?? const MenuStyle()).copyWith( + backgroundColor: WidgetStatePropertyAll(color), + surfaceTintColor: WidgetStatePropertyAll(color), + shadowColor: WidgetStatePropertyAll(shadowColor), + side: WidgetStatePropertyAll(side), + ); +} + +/// Keeps Yaru's horizontal breathing room while allowing its own minimum +/// button height to remain the control height. +/// +/// Yaru 10.2 applies its common padding on every edge. For a single-line +/// desktop action that vertical padding grows the control beyond Yaru's +/// declared button-height token. GTK-style buttons use the height token as +/// their metric, so normalize only that incompatible axis here, once, instead +/// of constraining individual buttons. +ButtonStyle? _yaruDesktopButtonStyle(ButtonStyle? base) { + final sourcePadding = base?.padding; + if (base == null || sourcePadding == null) { + return base; + } + return base.copyWith( + // Flutter's Linux-wide compact density would otherwise reduce Yaru's + // declared 34 px button minimum to 26 px. Yaru already defines the native + // control metric, so do not apply a second density reduction to buttons. + visualDensity: VisualDensity.standard, + // BusyMax is a desktop app. This is also Flutter's Linux default, but + // declaring it on the shared button style keeps widget tests and fallback + // shells from adding a mobile-only 48 px tap-target wrapper. + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: WidgetStateProperty.resolveWith((states) { + return switch (sourcePadding.resolve(states)) { + final EdgeInsets padding => EdgeInsets.only( + left: padding.left, + right: padding.right, + ), + final EdgeInsetsDirectional padding => EdgeInsetsDirectional.only( + start: padding.start, + end: padding.end, + ), + final padding => padding, + }; + }), + ); +} + +/// Applies runtime semantic colors and typography without replacing Yaru's +/// geometry, focus treatment, hover/press overlays, or motion defaults. +ButtonStyle _semanticButtonStyle( ButtonStyle? base, { - required OutlinedBorder shape, required Color foreground, required Color background, + Color? selectedBackground, required Color disabledForeground, required Color disabledBackground, - WidgetStateProperty? overlayColor, - WidgetStateProperty? side, WidgetStateProperty? textStyle, }) { return (base ?? const ButtonStyle()).copyWith( - visualDensity: const VisualDensity(horizontal: -1, vertical: -1), textStyle: textStyle, - shape: WidgetStatePropertyAll(shape), foregroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) { return disabledForeground; } return foreground; }), + iconColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return disabledForeground; + } + return foreground; + }), backgroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) { return disabledBackground; } + if (selectedBackground != null && states.contains(WidgetState.selected)) { + return selectedBackground; + } return background; }), - overlayColor: overlayColor ?? _controlOverlay(foreground), - side: side ?? const WidgetStatePropertyAll(BorderSide.none), - elevation: const WidgetStatePropertyAll(0), ); } - -WidgetStateProperty _controlOverlay(Color foreground) { - return WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) { - return foreground.withValues(alpha: 0.14); - } - if (states.contains(WidgetState.hovered)) { - return foreground.withValues(alpha: 0.08); - } - if (states.contains(WidgetState.focused)) { - return foreground.withValues(alpha: 0.10); - } - return null; - }); -} - -WidgetStateProperty _accentOverlay(Color accentColor) { - return WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) { - return accentColor.withValues(alpha: 0.14); - } - if (states.contains(WidgetState.hovered)) { - return accentColor.withValues(alpha: 0.08); - } - if (states.contains(WidgetState.focused)) { - return accentColor.withValues(alpha: 0.12); - } - return null; - }); -} - -WidgetStateProperty _onAccentOverlay(Color accentForeground) { - return WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) { - return accentForeground.withValues(alpha: 0.14); - } - if (states.contains(WidgetState.hovered)) { - return accentForeground.withValues(alpha: 0.08); - } - if (states.contains(WidgetState.focused)) { - return accentForeground.withValues(alpha: 0.12); - } - return null; - }); -} diff --git a/lib/src/app/system_accent.dart b/lib/src/app/system_accent.dart index 1fc9d1b..61bfff8 100644 --- a/lib/src/app/system_accent.dart +++ b/lib/src/app/system_accent.dart @@ -1,35 +1,29 @@ +import 'dart:async'; import 'dart:io'; import 'package:dbus/dbus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'busymax_yaru_theme.dart'; - -const busyMaxDefaultAccentColor = BusyMaxLinuxPalette.blueAccent; - -final initialSystemAccentColorProvider = Provider( - (ref) => busyMaxDefaultAccentColor, -); +import 'package:yaru/theme.dart'; final linuxPortalAppearanceProvider = Provider( (ref) => const LinuxPortalAppearance(), ); final ubuntuSystemAccentColorProvider = StreamProvider((ref) async* { - final fallback = ref.watch(initialSystemAccentColorProvider); - yield fallback; - if (!Platform.isLinux) { return; } final appearance = ref.watch(linuxPortalAppearanceProvider); - final initial = await appearance.readAccentColor(); - if (initial != null && initial != fallback) { - yield initial; + Color? current; + await for (final color in appearance.accentColorChanges()) { + if (color == current) { + continue; + } + current = color; + yield color; } - yield* appearance.accentColorChanges().distinct(); }); class LinuxPortalAppearance { @@ -46,8 +40,10 @@ class LinuxPortalAppearance { final client = DBusClient.session(); try { final object = _portalObject(client); - return await _readFreedesktopAccent(object) ?? - await _readGnomeAccentName(object); + return await readPreferredLinuxAccentColor( + readFreedesktop: () => _readFreedesktopAccent(object), + readGnome: () => _readGnomeAccentName(object), + ); } on Object { return null; } finally { @@ -65,22 +61,19 @@ class LinuxPortalAppearance { name: 'SettingChanged', signature: DBusSignature('ssv'), ); - await for (final signal in signals) { - final namespace = signal.values[0].asString(); - final key = signal.values[1].asString(); - if (key != _accentColor) { - continue; - } - final value = signal.values[2].asVariant(); - final color = namespace == _freedesktopAppearance - ? colorFromPortalAccentValue(value) - : namespace == _gnomeInterface - ? colorFromUbuntuAccentNameValue(value) - : null; - if (color != null) { - yield color; - } - } + final changes = signals + .where((signal) => signal.values[1].asString() == _accentColor) + .map( + (signal) => ( + namespace: signal.values[0].asString(), + value: signal.values[2].asVariant(), + ), + ); + yield* watchPreferredLinuxAccentColors( + readFreedesktop: () => _readFreedesktopAccent(object), + readGnome: () => _readGnomeAccentName(object), + changes: changes, + ); } on Object { return; } finally { @@ -123,6 +116,99 @@ class LinuxPortalAppearance { } } +/// Reads the exact freedesktop RGB value first, while keeping the Ubuntu +/// named-accent setting as an independent fallback for older portals. +@visibleForTesting +Future readPreferredLinuxAccentColor({ + required Future Function() readFreedesktop, + required Future Function() readGnome, +}) async { + final freedesktopAccent = await _readAccentSafely(readFreedesktop); + if (freedesktopAccent != null) { + return freedesktopAccent; + } + return _readAccentSafely(readGnome); +} + +Future _readAccentSafely(Future Function() read) async { + try { + return await read(); + } on Object { + return null; + } +} + +typedef LinuxAccentSettingChange = ({String namespace, DBusValue value}); + +/// Watches the modern exact accent and the legacy named fallback as one +/// ordered source. +/// +/// The signal subscription is established before the initial snapshot is +/// read. This prevents a portal update from being lost in the read/subscribe +/// gap and ensures the same resolver carries exact-RGB authority from the +/// initial value into all later signals. +@visibleForTesting +Stream watchPreferredLinuxAccentColors({ + required Future Function() readFreedesktop, + required Future Function() readGnome, + required Stream changes, +}) async* { + final iterator = StreamIterator(changes); + var nextChange = iterator.moveNext(); + try { + final freedesktopAccent = await _readAccentSafely(readFreedesktop); + final resolver = LinuxAccentChangeResolver( + freedesktopAuthoritative: freedesktopAccent != null, + ); + if (freedesktopAccent != null) { + yield freedesktopAccent; + } else { + final gnomeAccent = await _readAccentSafely(readGnome); + if (gnomeAccent != null) { + yield gnomeAccent; + } + } + + while (await nextChange) { + final change = iterator.current; + // Resume the subscription before yielding so subsequent portal updates + // are buffered even while the consumer processes this color. + nextChange = iterator.moveNext(); + final color = resolver.resolve(change.namespace, change.value); + if (color != null) { + yield color; + } + } + } finally { + await iterator.cancel(); + } +} + +/// Resolves portal change signals without allowing an approximate named color +/// to replace an exact RGB value once the modern freedesktop key is available. +@visibleForTesting +class LinuxAccentChangeResolver { + LinuxAccentChangeResolver({bool freedesktopAuthoritative = false}) + : _freedesktopAuthoritative = freedesktopAuthoritative; + + bool _freedesktopAuthoritative; + + Color? resolve(String namespace, DBusValue value) { + if (namespace == LinuxPortalAppearance._freedesktopAppearance) { + final color = colorFromPortalAccentValue(value); + if (color != null) { + _freedesktopAuthoritative = true; + } + return color; + } + if (namespace == LinuxPortalAppearance._gnomeInterface && + !_freedesktopAuthoritative) { + return colorFromUbuntuAccentNameValue(value); + } + return null; + } +} + Color? colorFromPortalAccentValue(DBusValue value) { final resolved = value.signature == DBusSignature('v') ? value.asVariant() @@ -152,21 +238,21 @@ Color? colorFromUbuntuAccentNameValue(DBusValue value) { Color? ubuntuAccentNameColor(String name) { return switch (name) { - 'blue' => BusyMaxLinuxPalette.ubuntuBlueAccent, - 'teal' => BusyMaxLinuxPalette.ubuntuTealAccent, - 'green' => BusyMaxLinuxPalette.ubuntuGreenAccent, - 'yellow' => BusyMaxLinuxPalette.ubuntuYellowAccent, - 'orange' => BusyMaxLinuxPalette.ubuntuOrangeAccent, - 'red' => BusyMaxLinuxPalette.ubuntuRedAccent, - 'pink' => BusyMaxLinuxPalette.ubuntuPinkAccent, - 'purple' => BusyMaxLinuxPalette.ubuntuPurpleAccent, - 'slate' => BusyMaxLinuxPalette.ubuntuSlateAccent, - 'brown' => BusyMaxLinuxPalette.ubuntuBrownAccent, - 'magenta' => BusyMaxLinuxPalette.ubuntuMagentaAccent, - 'olive' => BusyMaxLinuxPalette.ubuntuOliveAccent, - 'prussiangreen' => BusyMaxLinuxPalette.ubuntuPrussianGreenAccent, - 'sage' => BusyMaxLinuxPalette.ubuntuSageAccent, - 'wartybrown' => BusyMaxLinuxPalette.ubuntuWartyBrownAccent, + 'blue' => YaruVariant.blue.color, + 'teal' => YaruVariant.adwaitaTeal.color, + 'green' => YaruVariant.adwaitaGreen.color, + 'yellow' => YaruVariant.adwaitaYellow.color, + 'orange' => YaruVariant.orange.color, + 'red' => YaruVariant.red.color, + 'pink' => YaruVariant.magenta.color, + 'purple' => YaruVariant.purple.color, + 'slate' => YaruVariant.adwaitaSlate.color, + 'brown' => YaruVariant.wartyBrown.color, + 'magenta' => YaruVariant.magenta.color, + 'olive' => YaruVariant.olive.color, + 'prussiangreen' => YaruVariant.prussianGreen.color, + 'sage' => YaruVariant.sage.color, + 'wartybrown' => YaruVariant.wartyBrown.color, _ => null, }; } diff --git a/lib/src/config/build_config.dart b/lib/src/config/build_config.dart index f0dae00..10227b0 100644 --- a/lib/src/config/build_config.dart +++ b/lib/src/config/build_config.dart @@ -1,5 +1,7 @@ import 'package:flutter/foundation.dart'; +enum BusyMaxDemoTheme { system, light, dark } + class BuildConfig { const BuildConfig({ required this.googleOAuthClientId, @@ -13,43 +15,65 @@ class BuildConfig { required this.oauthAuthorizationEndpoint, required this.oauthTokenEndpoint, required this.oauthRevocationEndpoint, + this.useFakeProviderData = false, + this.demoTheme = BusyMaxDemoTheme.system, }) : apiBaseUrl = apiBaseUrl ?? googleApiBaseUrl; - factory BuildConfig.fromEnvironment() => const BuildConfig( - googleOAuthClientId: String.fromEnvironment('GOOGLE_OAUTH_CLIENT_ID'), - googleOAuthClientSecret: String.fromEnvironment( - 'GOOGLE_OAUTH_CLIENT_SECRET', - ), - microsoftOAuthClientId: String.fromEnvironment('MICROSOFT_OAUTH_CLIENT_ID'), - microsoftOAuthAuthorityTenant: String.fromEnvironment( - 'MICROSOFT_OAUTH_AUTHORITY_TENANT', - defaultValue: 'common', - ), - microsoftGraphBaseUrl: String.fromEnvironment( - 'MICROSOFT_GRAPH_BASE_URL', - defaultValue: 'https://graph.microsoft.com/v1.0', - ), - googleApiBaseUrl: String.fromEnvironment( - 'GOOGLE_API_BASE_URL', - defaultValue: 'https://www.googleapis.com', - ), - feedbackEndpoint: String.fromEnvironment( - 'BUSYSTACK_FEEDBACK_ENDPOINT', - defaultValue: 'https://busystack.org/api/feedback', - ), - oauthAuthorizationEndpoint: String.fromEnvironment( - 'GOOGLE_OAUTH_AUTHORIZATION_ENDPOINT', - defaultValue: 'https://accounts.google.com/o/oauth2/v2/auth', - ), - oauthTokenEndpoint: String.fromEnvironment( - 'GOOGLE_OAUTH_TOKEN_ENDPOINT', - defaultValue: 'https://oauth2.googleapis.com/token', - ), - oauthRevocationEndpoint: String.fromEnvironment( - 'GOOGLE_OAUTH_REVOCATION_ENDPOINT', - defaultValue: 'https://oauth2.googleapis.com/revoke', - ), - ); + factory BuildConfig.fromEnvironment() { + const fakeProviderDataRequested = bool.fromEnvironment('BUSYMAX_FAKE_DATA'); + final useFakeProviderData = busyMaxDemoModeEnabled( + requested: fakeProviderDataRequested, + releaseMode: kReleaseMode, + ); + return BuildConfig( + googleOAuthClientId: const String.fromEnvironment( + 'GOOGLE_OAUTH_CLIENT_ID', + ), + googleOAuthClientSecret: const String.fromEnvironment( + 'GOOGLE_OAUTH_CLIENT_SECRET', + ), + microsoftOAuthClientId: const String.fromEnvironment( + 'MICROSOFT_OAUTH_CLIENT_ID', + ), + microsoftOAuthAuthorityTenant: const String.fromEnvironment( + 'MICROSOFT_OAUTH_AUTHORITY_TENANT', + defaultValue: 'common', + ), + microsoftGraphBaseUrl: const String.fromEnvironment( + 'MICROSOFT_GRAPH_BASE_URL', + defaultValue: 'https://graph.microsoft.com/v1.0', + ), + googleApiBaseUrl: const String.fromEnvironment( + 'GOOGLE_API_BASE_URL', + defaultValue: 'https://www.googleapis.com', + ), + feedbackEndpoint: const String.fromEnvironment( + 'BUSYSTACK_FEEDBACK_ENDPOINT', + defaultValue: 'https://busystack.org/api/feedback', + ), + oauthAuthorizationEndpoint: const String.fromEnvironment( + 'GOOGLE_OAUTH_AUTHORIZATION_ENDPOINT', + defaultValue: 'https://accounts.google.com/o/oauth2/v2/auth', + ), + oauthTokenEndpoint: const String.fromEnvironment( + 'GOOGLE_OAUTH_TOKEN_ENDPOINT', + defaultValue: 'https://oauth2.googleapis.com/token', + ), + oauthRevocationEndpoint: const String.fromEnvironment( + 'GOOGLE_OAUTH_REVOCATION_ENDPOINT', + defaultValue: 'https://oauth2.googleapis.com/revoke', + ), + useFakeProviderData: useFakeProviderData, + demoTheme: useFakeProviderData + ? parseBusyMaxDemoTheme( + const String.fromEnvironment( + 'BUSYMAX_FAKE_THEME', + defaultValue: 'system', + ), + ) + : BusyMaxDemoTheme.system, + ); + } final String googleOAuthClientId; final String googleOAuthClientSecret; @@ -62,10 +86,13 @@ class BuildConfig { final String oauthAuthorizationEndpoint; final String oauthTokenEndpoint; final String oauthRevocationEndpoint; + final bool useFakeProviderData; + final BusyMaxDemoTheme demoTheme; - bool get hasGoogleOAuthClientId => googleOAuthClientId.trim().isNotEmpty; + bool get hasGoogleOAuthClientId => + useFakeProviderData || googleOAuthClientId.trim().isNotEmpty; bool get hasMicrosoftOAuthClientId => - microsoftOAuthClientId.trim().isNotEmpty; + !useFakeProviderData && microsoftOAuthClientId.trim().isNotEmpty; bool get hasAnyProviderConfigured => hasGoogleOAuthClientId || hasMicrosoftOAuthClientId; @@ -87,3 +114,19 @@ class BuildConfig { 'Set MICROSOFT_OAUTH_CLIENT_ID.'; } } + +@visibleForTesting +bool busyMaxDemoModeEnabled({ + required bool requested, + required bool releaseMode, +}) { + return requested && !releaseMode; +} + +BusyMaxDemoTheme parseBusyMaxDemoTheme(String value) { + return switch (value.trim().toLowerCase()) { + 'light' => BusyMaxDemoTheme.light, + 'dark' => BusyMaxDemoTheme.dark, + _ => BusyMaxDemoTheme.system, + }; +} diff --git a/lib/src/core/logging/redacting_logger.dart b/lib/src/core/logging/redacting_logger.dart index 880b074..8261685 100644 --- a/lib/src/core/logging/redacting_logger.dart +++ b/lib/src/core/logging/redacting_logger.dart @@ -7,6 +7,9 @@ final _sensitivePatterns = [ RegExp(r'Bearer\s+[A-Za-z0-9._~+/=-]+', caseSensitive: false), RegExp(r'(^|[?&\s])access_token=[^&\s]+', caseSensitive: false), RegExp(r'(^|[?&\s])refresh_token=[^&\s]+', caseSensitive: false), + // RFC 7009 uses the generic `token` field. Restrict this pattern to URL + // query syntax so ordinary prose such as "token=value" remains readable. + RegExp(r'([?&])token=[^&\s]+', caseSensitive: false), RegExp(r'(^|[?&\s])client_secret=[^&\s]+', caseSensitive: false), RegExp(r'"client_secret"\s*:\s*"[^"]*"', caseSensitive: false), RegExp(r'client_secret\s*:\s*[^,\n\s]+', caseSensitive: false), diff --git a/lib/src/core/time/linux_gweather_location_source.dart b/lib/src/core/time/linux_gweather_location_source.dart new file mode 100644 index 0000000..9beb878 --- /dev/null +++ b/lib/src/core/time/linux_gweather_location_source.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:flutter/foundation.dart'; + +@immutable +class BusyMaxSystemTimeZoneLocation { + const BusyMaxSystemTimeZoneLocation({ + required this.name, + required this.englishName, + required this.countryCode, + required this.timeZoneId, + }); + + final String name; + final String englishName; + final String? countryCode; + final String timeZoneId; + + bool matches(String normalizedQuery) { + return name.toLowerCase().contains(normalizedQuery) || + englishName.toLowerCase().contains(normalizedQuery); + } +} + +Future> +loadLinuxSystemTimeZoneLocations() async { + if (!Platform.isLinux) { + return const []; + } + + try { + return await Isolate.run(_loadGWeatherLocations); + } on Object { + // libgweather is an optional runtime integration. The IANA catalog remains + // available when the library or its location database is not installed. + return const []; + } +} + +List _loadGWeatherLocations() { + final api = _GWeatherApi(); + final world = api.getWorld(); + if (world == nullptr) { + return const []; + } + + final locations = []; + try { + _collectCities(api, world, locations); + } finally { + api.unref(world.cast()); + } + + locations.sort((a, b) { + final nameOrder = a.name.compareTo(b.name); + if (nameOrder != 0) { + return nameOrder; + } + final countryOrder = (a.countryCode ?? '').compareTo(b.countryCode ?? ''); + return countryOrder != 0 + ? countryOrder + : a.timeZoneId.compareTo(b.timeZoneId); + }); + return List.unmodifiable(locations); +} + +void _collectCities( + _GWeatherApi api, + Pointer<_GWeatherLocation> parent, + List locations, +) { + var child = nullptr.cast<_GWeatherLocation>(); + while (true) { + child = api.nextChild(parent, child); + if (child == nullptr) { + return; + } + + if (api.getLevel(child) == _GWeatherLocationLevel.city) { + final name = _readUtf8(api.getName(child)); + final englishName = _readUtf8(api.getEnglishName(child)); + final timeZoneId = _readUtf8(api.getTimeZoneId(child)); + if (name != null && englishName != null && timeZoneId != null) { + locations.add( + BusyMaxSystemTimeZoneLocation( + name: name, + englishName: englishName, + countryCode: _readUtf8(api.getCountryCode(child)), + timeZoneId: timeZoneId, + ), + ); + } + continue; + } + + _collectCities(api, child, locations); + } +} + +String? _readUtf8(Pointer value) { + if (value == nullptr) { + return null; + } + + final bytes = value.cast(); + var length = 0; + while (length < 65536 && (bytes + length).value != 0) { + length += 1; + } + if (length == 0 || length == 65536) { + return null; + } + return utf8.decode(bytes.asTypedList(length), allowMalformed: true); +} + +abstract final class _GWeatherLocationLevel { + static const city = 4; +} + +final class _GWeatherLocation extends Opaque {} + +typedef _GetWorldNative = Pointer<_GWeatherLocation> Function(); +typedef _GetWorldDart = Pointer<_GWeatherLocation> Function(); +typedef _NextChildNative = + Pointer<_GWeatherLocation> Function( + Pointer<_GWeatherLocation>, + Pointer<_GWeatherLocation>, + ); +typedef _NextChildDart = + Pointer<_GWeatherLocation> Function( + Pointer<_GWeatherLocation>, + Pointer<_GWeatherLocation>, + ); +typedef _GetLevelNative = Int32 Function(Pointer<_GWeatherLocation>); +typedef _GetLevelDart = int Function(Pointer<_GWeatherLocation>); +typedef _GetStringNative = Pointer Function(Pointer<_GWeatherLocation>); +typedef _GetStringDart = Pointer Function(Pointer<_GWeatherLocation>); +typedef _UnrefNative = Void Function(Pointer); +typedef _UnrefDart = void Function(Pointer); + +final class _GWeatherApi { + _GWeatherApi() { + final library = DynamicLibrary.open('libgweather-4.so.0'); + final gObjectLibrary = DynamicLibrary.open('libgobject-2.0.so.0'); + + getWorld = library.lookupFunction<_GetWorldNative, _GetWorldDart>( + 'gweather_location_get_world', + ); + nextChild = library.lookupFunction<_NextChildNative, _NextChildDart>( + 'gweather_location_next_child', + ); + getLevel = library.lookupFunction<_GetLevelNative, _GetLevelDart>( + 'gweather_location_get_level', + ); + getName = library.lookupFunction<_GetStringNative, _GetStringDart>( + 'gweather_location_get_name', + ); + getEnglishName = library.lookupFunction<_GetStringNative, _GetStringDart>( + 'gweather_location_get_english_name', + ); + getCountryCode = library.lookupFunction<_GetStringNative, _GetStringDart>( + 'gweather_location_get_country', + ); + getTimeZoneId = library.lookupFunction<_GetStringNative, _GetStringDart>( + 'gweather_location_get_timezone_str', + ); + unref = gObjectLibrary.lookupFunction<_UnrefNative, _UnrefDart>( + 'g_object_unref', + ); + } + + late final _GetWorldDart getWorld; + late final _NextChildDart nextChild; + late final _GetLevelDart getLevel; + late final _GetStringDart getName; + late final _GetStringDart getEnglishName; + late final _GetStringDart getCountryCode; + late final _GetStringDart getTimeZoneId; + late final _UnrefDart unref; +} diff --git a/lib/src/core/time/time_zone_catalog.dart b/lib/src/core/time/time_zone_catalog.dart new file mode 100644 index 0000000..b59f4e6 --- /dev/null +++ b/lib/src/core/time/time_zone_catalog.dart @@ -0,0 +1,315 @@ +import 'package:flutter/foundation.dart' show immutable, visibleForTesting; +import 'package:timezone/data/latest_all.dart' as time_zone_data; +import 'package:timezone/timezone.dart' as time_zone; + +import 'linux_gweather_location_source.dart'; + +@immutable +class BusyMaxTimeZoneLocation { + const BusyMaxTimeZoneLocation({ + required this.id, + required this.region, + required this.name, + required this.code, + required this.offset, + }); + + final String id; + final String region; + final String name; + final String code; + final Duration offset; + + String get displayLabel => '$id ($code)'; + + bool matches(String query) { + final normalized = query.trim().toLowerCase(); + return normalized.isEmpty || + id.toLowerCase().contains(normalized) || + region.toLowerCase().contains(normalized) || + name.toLowerCase().contains(normalized) || + code.toLowerCase().contains(normalized); + } +} + +@immutable +class BusyMaxTimeZoneSearchResult { + const BusyMaxTimeZoneSearchResult({ + required this.location, + required this.name, + this.englishName, + this.countryCode, + }); + + final BusyMaxTimeZoneLocation location; + final String name; + final String? englishName; + final String? countryCode; + + String get title => '$name (${location.code})'; + + String get subtitle { + final country = countryCode; + return country == null ? location.id : '${location.id} - $country'; + } + + String get searchText => { + name, + if (englishName != null) englishName!, + if (countryCode != null) countryCode!, + if (englishName == null) ...[ + location.id, + location.region, + location.name, + location.code, + ], + }.join('\n'); +} + +abstract final class BusyMaxTimeZoneCatalog { + static List? _locations; + static Map? _locationsById; + static List? _systemLocations; + static Future>? _systemLocationLoad; + static List? _locationSearchOptions; + + static List get locations { + return _locations ??= _buildLocations(); + } + + static BusyMaxTimeZoneLocation location(String id) { + final normalized = id == 'UTC' ? 'Etc/UTC' : id; + final knownLocation = _locationMap[normalized]; + if (knownLocation != null) { + return knownLocation; + } + final parts = normalized.split('/'); + return BusyMaxTimeZoneLocation( + id: normalized, + region: _readableSegment(parts.first), + name: parts.skip(1).map(_readableSegment).join(' / '), + code: normalized, + offset: Duration.zero, + ); + } + + static List search(String query) { + if (query.trim().isEmpty) { + return const []; + } + return locations.where((location) => location.matches(query)).toList(); + } + + static bool get isLocationSearchReady => _systemLocations != null; + + static Future prepareLocationSearch() async { + final load = _systemLocationLoad ??= loadLinuxSystemTimeZoneLocations(); + _systemLocations ??= await load; + } + + static Future> searchLocations( + String query, + ) async { + await prepareLocationSearch(); + return searchPreparedLocations(query); + } + + static List get preparedLocationOptions { + return _locationSearchOptions ??= _buildLocationSearchOptions(); + } + + static List searchPreparedLocations( + String query, + ) { + final normalized = query.trim().toLowerCase(); + if (normalized.isEmpty) { + return const []; + } + + final results = []; + final seen = {}; + + if (normalized.length >= 2) { + for (final systemLocation + in _systemLocations ?? const []) { + if (!systemLocation.matches(normalized)) { + continue; + } + final result = BusyMaxTimeZoneSearchResult( + location: location(systemLocation.timeZoneId), + name: systemLocation.name, + englishName: systemLocation.englishName, + countryCode: systemLocation.countryCode, + ); + if (seen.add(_resultKey(result))) { + results.add(result); + } + } + } + + for (final timeZoneLocation in search(query)) { + final result = BusyMaxTimeZoneSearchResult( + location: timeZoneLocation, + name: timeZoneLocation.name, + ); + if (seen.add(_resultKey(result))) { + results.add(result); + } + } + + results.sort((a, b) { + final matchOrder = _matchRank( + a, + normalized, + ).compareTo(_matchRank(b, normalized)); + if (matchOrder != 0) { + return matchOrder; + } + final regionOrder = a.location.region.compareTo(b.location.region); + if (regionOrder != 0) { + return regionOrder; + } + final nameOrder = a.name.compareTo(b.name); + return nameOrder != 0 + ? nameOrder + : a.location.id.compareTo(b.location.id); + }); + return results; + } + + static Map get _locationMap { + return _locationsById ??= { + for (final location in locations) location.id: location, + }; + } + + static String _resultKey(BusyMaxTimeZoneSearchResult result) { + return '${result.location.id}\u0000${result.name.toLowerCase()}'; + } + + static int _matchRank( + BusyMaxTimeZoneSearchResult result, + String normalizedQuery, + ) { + final names = { + result.name.toLowerCase(), + if (result.englishName != null) result.englishName!.toLowerCase(), + }; + if (names.contains(normalizedQuery)) { + return 0; + } + if (names.any((name) => name.startsWith(normalizedQuery))) { + return 1; + } + if (names.any((name) => name.contains(normalizedQuery))) { + return 2; + } + return 3; + } + + static List _buildLocationSearchOptions() { + final results = []; + final seen = {}; + + for (final systemLocation + in _systemLocations ?? const []) { + final result = BusyMaxTimeZoneSearchResult( + location: location(systemLocation.timeZoneId), + name: systemLocation.name, + englishName: systemLocation.englishName, + countryCode: systemLocation.countryCode, + ); + if (seen.add(_resultKey(result))) { + results.add(result); + } + } + + for (final timeZoneLocation in locations) { + final result = BusyMaxTimeZoneSearchResult( + location: timeZoneLocation, + name: timeZoneLocation.name, + ); + if (seen.add(_resultKey(result))) { + results.add(result); + } + } + + results.sort((a, b) { + final regionOrder = a.location.region.compareTo(b.location.region); + if (regionOrder != 0) { + return regionOrder; + } + final nameOrder = a.name.compareTo(b.name); + return nameOrder != 0 + ? nameOrder + : a.location.id.compareTo(b.location.id); + }); + return List.unmodifiable(results); + } + + static List _buildLocations() { + time_zone_data.initializeTimeZones(); + final instant = DateTime.now().millisecondsSinceEpoch; + final locations = [ + const BusyMaxTimeZoneLocation( + id: 'Etc/UTC', + region: 'UTC', + name: 'UTC', + code: 'UTC', + offset: Duration.zero, + ), + ]; + + for (final entry in time_zone.timeZoneDatabase.locations.entries) { + final id = entry.key; + if (!_isUserFacingLocation(id)) { + continue; + } + final parts = id.split('/'); + final zone = entry.value.timeZone(instant); + locations.add( + BusyMaxTimeZoneLocation( + id: id, + region: _readableSegment(parts.first), + name: parts.skip(1).map(_readableSegment).join(' / '), + code: zone.abbreviation, + offset: zone.offset, + ), + ); + } + + locations.sort((a, b) { + if (a.id == 'Etc/UTC') { + return -1; + } + if (b.id == 'Etc/UTC') { + return 1; + } + final regionOrder = a.region.compareTo(b.region); + return regionOrder != 0 ? regionOrder : a.name.compareTo(b.name); + }); + return List.unmodifiable(locations); + } + + static bool _isUserFacingLocation(String id) { + return id.contains('/') && + id != 'Etc/UTC' && + !id.startsWith('Etc/') && + !id.startsWith('SystemV/'); + } + + static String _readableSegment(String value) { + return value.replaceAll('_', ' '); + } + + @visibleForTesting + static void setSystemLocationsForTesting( + List? locations, + ) { + _systemLocations = locations == null ? null : List.unmodifiable(locations); + _locationSearchOptions = null; + _systemLocationLoad = locations == null + ? null + : Future.value(_systemLocations); + } +} diff --git a/lib/src/demo/demo_profile.dart b/lib/src/demo/demo_profile.dart new file mode 100644 index 0000000..92c9194 --- /dev/null +++ b/lib/src/demo/demo_profile.dart @@ -0,0 +1,225 @@ +import 'dart:async'; + +import 'package:desktop_notifications/desktop_notifications.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; + +import '../app/app_bootstrap.dart'; +import '../config/build_config.dart'; +import '../db/app_database.dart'; +import '../features/feedback/data/feedback_api_client.dart'; +import '../features/feedback/data/feedback_submission.dart'; +import '../features/notifications/desktop_notification_service.dart'; +import '../features/notifications/notification_scheduler.dart'; +import '../features/sync/account_sync_operations.dart'; +import '../features/sync/all_accounts_sync_scheduler.dart'; +import '../google_tasks/api/google_tasks_api_surface.dart'; +import '../google_tasks/oauth/oauth_models.dart'; +import '../google_tasks/oauth/oauth_service.dart'; +import '../google_tasks/oauth/oauth_token_store.dart'; +import 'demo_seed.dart'; + +AppSettings busyMaxDemoSettings(BusyMaxDemoTheme theme) { + final themeModePreference = switch (theme) { + BusyMaxDemoTheme.system => BusyMaxThemeModePreference.system, + BusyMaxDemoTheme.light => BusyMaxThemeModePreference.light, + BusyMaxDemoTheme.dark => BusyMaxThemeModePreference.dark, + }; + return AppSettings.defaults().copyWith( + themeModePreference: themeModePreference, + notifySyncFailures: false, + notifyConflicts: false, + notifyDueToday: false, + notifyEventReminders: false, + notifyTaskReminders: false, + runInBackgroundWhenClosed: false, + showTrayIcon: true, + startMinimizedToTray: false, + quitExitsCompletely: true, + ); +} + +class InMemoryLocalSettingsStore implements LocalSettingsStore { + InMemoryLocalSettingsStore([Map initial = const {}]) + : _value = Map.from(initial); + + Map _value; + + Map get snapshot => Map.unmodifiable(_value); + + @override + Future> load() async { + return Map.from(_value); + } + + @override + Future save(Map json) async { + _value = Map.from(json); + } +} + +class BusyMaxDemoProfile { + BusyMaxDemoProfile._(this.database); + + static Future create({DateTime? now}) async { + final database = AppDatabase.memoryForTests(); + try { + await seedBusyMaxDemoData(database, now: now); + return BusyMaxDemoProfile._(database); + } on Object { + await database.close(); + rethrow; + } + } + + final AppDatabase database; + + List get overrides { + return [ + databaseProvider.overrideWith((ref) { + ref.onDispose(database.close); + return database; + }), + baseHttpClientProvider.overrideWith((ref) { + final client = _BlockedDemoHttpClient(); + ref.onDispose(client.close); + return client; + }), + oAuthTokenStoreProvider.overrideWithValue(InMemoryOAuthTokenStore()), + applicationOAuthGatewayProvider.overrideWithValue( + DemoOAuthGateway(activeAccountId: busyMaxDemoAccountId), + ), + applicationMicrosoftOAuthServiceProvider.overrideWithValue(null), + accountSyncOperationsProvider.overrideWithValue( + const DisabledAccountSyncOperations(), + ), + taskRemoteApiClientForAccountProvider.overrideWith( + (ref, accountId) => null, + ), + pendingOpResolutionServiceProvider.overrideWithValue(null), + feedbackSubmissionServiceProvider.overrideWithValue( + const DemoFeedbackSubmissionService(), + ), + desktopNotificationBackendProvider.overrideWithValue( + const DemoDesktopNotificationBackend(), + ), + syncSchedulerProvider.overrideWith((ref) { + final scheduler = AllAccountsSyncScheduler( + listSignedInAccounts: () async => const [], + syncAccount: (_) async {}, + onSyncFailure: (_) async {}, + interval: Duration.zero, + ); + ref.onDispose(scheduler.stop); + return scheduler; + }), + notificationSchedulerProvider.overrideWith((ref) { + final scheduler = NotificationScheduler( + database: ref.watch(databaseProvider), + notifications: ref.watch(desktopNotificationServiceProvider), + ); + ref.onDispose(scheduler.stop); + return scheduler; + }), + ]; + } +} + +class DemoOAuthGateway implements OAuthGateway { + DemoOAuthGateway({String? activeAccountId}) + : _activeAccountId = activeAccountId; + + String? _activeAccountId; + + OAuthTokenSet get _tokenSet => OAuthTokenSet( + accessToken: 'busymax-demo-access-token', + refreshToken: 'busymax-demo-refresh-token', + expiresAtUtc: DateTime.utc(2100), + tokenType: 'Bearer', + scopes: Set.of(googleBusyMaxOAuthScopes), + ); + + @override + Future get activeAccountId async => _activeAccountId; + + @override + Future cancelSignIn() async {} + + @override + Future clearLocalSession({String? accountId}) async { + if (accountId == null || accountId == _activeAccountId) { + _activeAccountId = null; + } + } + + @override + Future fetchUserInfo(OAuthTokenSet tokenSet) async { + return const GoogleUserInfo( + subject: 'demo-user', + name: 'Alex Morgan', + email: 'alex@example.com', + rawJson: { + 'sub': 'demo-user', + 'name': 'Alex Morgan', + 'email': 'alex@example.com', + }, + ); + } + + @override + Future readActiveTokenSet() async { + return _activeAccountId == null ? null : _tokenSet; + } + + @override + Future refreshActiveToken() async => _tokenSet; + + @override + Future revokeAndSignOutAccount(String accountId) { + return clearLocalSession(accountId: accountId); + } + + @override + Future revokeAuthorization(String accountId) async {} + + @override + Future signIn({String? loginHint}) async { + _activeAccountId = busyMaxDemoAccountId; + return OAuthSignInResult( + accountId: busyMaxDemoAccountId, + tokenSet: _tokenSet, + ); + } +} + +class DemoFeedbackSubmissionService implements FeedbackSubmissionService { + const DemoFeedbackSubmissionService(); + + @override + Future submit(FeedbackSubmission submission) async { + return FeedbackReceipt(id: 'demo-${submission.submissionId}'); + } +} + +class DemoDesktopNotificationBackend implements DesktopNotificationBackend { + const DemoDesktopNotificationBackend(); + + @override + Future close() async {} + + @override + Future notify( + String summary, { + String body = '', + List hints = const [], + List actions = const [], + DesktopNotificationActionHandler? onAction, + }) async {} +} + +class _BlockedDemoHttpClient extends http.BaseClient { + @override + Future send(http.BaseRequest request) { + throw StateError('Network access is disabled in BusyMax demo mode.'); + } +} diff --git a/lib/src/demo/demo_seed.dart b/lib/src/demo/demo_seed.dart new file mode 100644 index 0000000..23c60ac --- /dev/null +++ b/lib/src/demo/demo_seed.dart @@ -0,0 +1,395 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; + +import '../db/app_database.dart'; +import '../features/accounts/data/accounts_repository.dart'; +import '../google_tasks/api/google_tasks_api_surface.dart'; + +const busyMaxDemoAccountId = 'demo-google-account'; +const busyMaxDemoWorkCalendarId = 'demo-calendar-work'; +const busyMaxDemoPersonalCalendarId = 'demo-calendar-personal'; +const busyMaxDemoInboxId = 'demo-list-inbox'; +const busyMaxDemoPersonalTasksId = 'demo-list-personal'; + +Future seedBusyMaxDemoData(AppDatabase database, {DateTime? now}) async { + final current = now ?? DateTime.now(); + final today = DateTime(current.year, current.month, current.day); + final timestamp = current.toUtc().toIso8601String(); + final localTimestamp = current.millisecondsSinceEpoch; + + await database.transaction(() async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: busyMaxDemoAccountId, + provider: const Value('google'), + providerAccountId: const Value('demo-user'), + displayName: const Value('Alex Morgan'), + email: const Value('alex@example.com'), + authState: const Value(accountAuthStateSignedIn), + grantedScopes: Value(googleBusyMaxOAuthScopes.join(' ')), + createdAtUtc: timestamp, + updatedAtUtc: timestamp, + lastSuccessfulSyncAtUtc: Value(timestamp), + lastFullSyncAtUtc: Value(timestamp), + ), + ); + + for (final source in [ + ( + id: busyMaxDemoWorkCalendarId, + providerId: 'work@example.com', + summary: 'Work', + primary: true, + color: '#3584E4', + ), + ( + id: busyMaxDemoPersonalCalendarId, + providerId: 'personal@example.com', + summary: 'Personal', + primary: false, + color: '#9141AC', + ), + ]) { + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: source.id, + accountId: busyMaxDemoAccountId, + provider: 'google', + providerCalendarId: source.providerId, + summary: source.summary, + primaryCalendar: Value(source.primary), + backgroundColor: Value(source.color), + foregroundColor: const Value('#FFFFFF'), + accessRole: const Value('owner'), + rawJson: Value( + jsonEncode({ + 'id': source.providerId, + 'summary': source.summary, + 'backgroundColor': source.color, + 'foregroundColor': '#FFFFFF', + 'accessRole': 'owner', + }), + ), + createdAtLocal: localTimestamp, + updatedAtLocal: localTimestamp, + ), + ); + } + + final events = <_DemoEvent>[ + _DemoEvent.timed( + id: 'demo-event-planning', + sourceId: busyMaxDemoWorkCalendarId, + providerCalendarId: 'work@example.com', + title: 'Product planning', + day: today, + startHour: 9, + duration: const Duration(hours: 1), + description: 'Review the roadmap and agree on this week’s priorities.', + location: 'Meeting room Cedar', + ), + _DemoEvent.timed( + id: 'demo-event-design-review', + sourceId: busyMaxDemoWorkCalendarId, + providerCalendarId: 'work@example.com', + title: 'Design review', + day: today, + startHour: 11, + startMinute: 30, + duration: const Duration(minutes: 45), + description: 'Final accessibility and interaction review.', + ), + _DemoEvent.allDay( + id: 'demo-event-focus', + sourceId: busyMaxDemoPersonalCalendarId, + providerCalendarId: 'personal@example.com', + title: 'Focus day', + day: today, + ), + _DemoEvent.timed( + id: 'demo-event-customer', + sourceId: busyMaxDemoWorkCalendarId, + providerCalendarId: 'work@example.com', + title: 'Customer check-in', + day: _calendarDay(today, 1), + startHour: 14, + duration: const Duration(minutes: 30), + description: 'Walk through the new scheduling workflow.', + ), + _DemoEvent.timed( + id: 'demo-event-gym', + sourceId: busyMaxDemoPersonalCalendarId, + providerCalendarId: 'personal@example.com', + title: 'Gym', + day: _calendarDay(today, 2), + startHour: 18, + duration: const Duration(hours: 1), + ), + _DemoEvent.allDay( + id: 'demo-event-release', + sourceId: busyMaxDemoWorkCalendarId, + providerCalendarId: 'work@example.com', + title: 'Release milestone', + day: _calendarDay(today, 6), + ), + ]; + for (final event in events) { + await database + .into(database.calendarEvents) + .insert(event.toCompanion(localTimestamp)); + } + + for (final list in [ + (id: busyMaxDemoInboxId, title: 'Inbox', kind: 'tasks#taskList'), + ( + id: busyMaxDemoPersonalTasksId, + title: 'Personal', + kind: 'tasks#taskList', + ), + ]) { + await database + .into(database.taskLists) + .insert( + TaskListsCompanion.insert( + accountId: busyMaxDemoAccountId, + id: list.id, + kind: Value(list.kind), + title: list.title, + rawJson: jsonEncode({ + 'id': list.id, + 'kind': list.kind, + 'title': list.title, + }), + lastSyncedAtUtc: Value(timestamp), + createdLocalAtUtc: timestamp, + updatedLocalAtUtc: timestamp, + ), + ); + } + + final tasks = <_DemoTask>[ + _DemoTask( + id: 'demo-task-prototype', + listId: busyMaxDemoInboxId, + title: 'Polish calendar prototype', + notes: 'Check keyboard navigation and both color schemes.', + due: today, + position: '0001', + ), + _DemoTask( + id: 'demo-task-notes', + listId: busyMaxDemoInboxId, + title: 'Send meeting notes', + due: today, + position: '0002', + ), + _DemoTask( + id: 'demo-task-release', + listId: busyMaxDemoInboxId, + title: 'Prepare release checklist', + due: _calendarDay(today, 2), + position: '0003', + ), + _DemoTask( + id: 'demo-task-expenses', + listId: busyMaxDemoPersonalTasksId, + title: 'Submit expenses', + due: _calendarDay(today, -1), + position: '0001', + ), + const _DemoTask( + id: 'demo-task-reading', + listId: busyMaxDemoPersonalTasksId, + title: 'Choose next book', + position: '0002', + ), + _DemoTask( + id: 'demo-task-groceries', + listId: busyMaxDemoPersonalTasksId, + title: 'Pick up groceries', + due: _calendarDay(today, 1), + position: '0003', + completed: true, + ), + ]; + for (final task in tasks) { + await database.into(database.tasks).insert(task.toCompanion(timestamp)); + } + }); +} + +class _DemoEvent { + const _DemoEvent._({ + required this.id, + required this.sourceId, + required this.providerCalendarId, + required this.title, + required this.allDay, + this.start, + this.end, + this.startDate, + this.endDate, + this.description, + this.location, + }); + + factory _DemoEvent.timed({ + required String id, + required String sourceId, + required String providerCalendarId, + required String title, + required DateTime day, + required int startHour, + required Duration duration, + int startMinute = 0, + String? description, + String? location, + }) { + final start = DateTime( + day.year, + day.month, + day.day, + startHour, + startMinute, + ); + return _DemoEvent._( + id: id, + sourceId: sourceId, + providerCalendarId: providerCalendarId, + title: title, + allDay: false, + start: start, + end: start.add(duration), + description: description, + location: location, + ); + } + + factory _DemoEvent.allDay({ + required String id, + required String sourceId, + required String providerCalendarId, + required String title, + required DateTime day, + }) { + return _DemoEvent._( + id: id, + sourceId: sourceId, + providerCalendarId: providerCalendarId, + title: title, + allDay: true, + startDate: _date(day), + endDate: _date(_calendarDay(day, 1)), + ); + } + + final String id; + final String sourceId; + final String providerCalendarId; + final String title; + final bool allDay; + final DateTime? start; + final DateTime? end; + final String? startDate; + final String? endDate; + final String? description; + final String? location; + + CalendarEventsCompanion toCompanion(int timestamp) { + return CalendarEventsCompanion.insert( + id: id, + accountId: busyMaxDemoAccountId, + calendarSourceId: sourceId, + provider: 'google', + providerCalendarId: providerCalendarId, + providerEventId: id, + title: title, + status: const Value('confirmed'), + description: Value(description), + location: Value(location), + allDay: Value(allDay), + startDate: Value(startDate), + startDateTime: Value(start?.toIso8601String()), + endDate: Value(endDate), + endDateTime: Value(end?.toIso8601String()), + remindersJson: const Value('{"useDefault":false,"overrides":[]}'), + rawJson: Value( + jsonEncode({ + 'id': id, + 'summary': title, + 'status': 'confirmed', + 'start': allDay + ? {'date': startDate} + : {'dateTime': start?.toIso8601String()}, + 'end': allDay + ? {'date': endDate} + : {'dateTime': end?.toIso8601String()}, + }), + ), + createdAtLocal: timestamp, + updatedAtLocal: timestamp, + ); + } +} + +class _DemoTask { + const _DemoTask({ + required this.id, + required this.listId, + required this.title, + required this.position, + this.notes, + this.due, + this.completed = false, + }); + + final String id; + final String listId; + final String title; + final String position; + final String? notes; + final DateTime? due; + final bool completed; + + TasksCompanion toCompanion(String timestamp) { + final dueDate = due == null ? null : _date(due!); + return TasksCompanion.insert( + accountId: busyMaxDemoAccountId, + taskListId: listId, + id: id, + kind: const Value('tasks#task'), + title: title, + position: Value(position), + notes: Value(notes), + status: Value(completed ? 'completed' : 'needsAction'), + dueUtc: Value(dueDate), + completedUtc: Value(completed ? timestamp : null), + rawJson: jsonEncode({ + 'id': id, + 'kind': 'tasks#task', + 'title': title, + if (notes != null) 'notes': notes, + if (dueDate != null) 'due': dueDate, + 'status': completed ? 'completed' : 'needsAction', + }), + lastSyncedAtUtc: Value(timestamp), + createdLocalAtUtc: timestamp, + updatedLocalAtUtc: timestamp, + ); + } +} + +DateTime _calendarDay(DateTime day, int offset) { + return DateTime(day.year, day.month, day.day + offset); +} + +String _date(DateTime value) { + return '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; +} diff --git a/lib/src/features/accounts/data/accounts_repository.dart b/lib/src/features/accounts/data/accounts_repository.dart index 20b2ec2..eadfa6b 100644 --- a/lib/src/features/accounts/data/accounts_repository.dart +++ b/lib/src/features/accounts/data/accounts_repository.dart @@ -6,7 +6,6 @@ import '../../../db/app_database.dart'; import '../../../task_providers/task_provider.dart'; const accountAuthStateSignedIn = 'signed_in'; -const accountAuthStateSignedOut = 'signed_out'; const accountAuthStateReauthRequired = 'reauth_required'; class AccountEntity { @@ -195,17 +194,6 @@ class AccountsRepository { )..where((account) => account.id.equals(id))).write(companion); } - Future markSignedOut(String accountId) { - return (_database.update( - _database.accounts, - )..where((account) => account.id.equals(accountId))).write( - AccountsCompanion( - authState: const Value(accountAuthStateSignedOut), - updatedAtUtc: Value(_now()), - ), - ); - } - Future markReconnectRequired(String accountId) { return (_database.update( _database.accounts, diff --git a/lib/src/features/auth/data/auth_repository.dart b/lib/src/features/auth/data/auth_repository.dart index dbf8cff..9f63a29 100644 --- a/lib/src/features/auth/data/auth_repository.dart +++ b/lib/src/features/auth/data/auth_repository.dart @@ -62,6 +62,28 @@ class AuthSessionState { bool get isSignedIn => status == AuthSessionStatus.signedIn; } +enum AccountAuthorizationRevocationStatus { notRequested, succeeded, failed } + +@immutable +class AccountRemovalResult { + const AccountRemovalResult({ + required this.authorizationRevocationStatus, + this.alreadyRemoved = false, + }); + + const AccountRemovalResult.alreadyRemoved() + : authorizationRevocationStatus = + AccountAuthorizationRevocationStatus.notRequested, + alreadyRemoved = true; + + final AccountAuthorizationRevocationStatus authorizationRevocationStatus; + final bool alreadyRemoved; + + bool get authorizationRevocationFailed => + authorizationRevocationStatus == + AccountAuthorizationRevocationStatus.failed; +} + class AuthRepository { AuthRepository({ required OAuthGateway oAuth, @@ -80,6 +102,7 @@ class AuthRepository { final AppDatabase _database; final AccountsRepository _accountsRepository; final MicrosoftOAuthService? _microsoftOAuth; + final RedactingLogger _logger = RedactingLogger(Logger('AuthRepository')); Future loadSession() async { final accounts = await _accountsRepository.listSignedInAccounts(); @@ -94,7 +117,10 @@ class AuthRepository { final result = await _oAuth.signIn(); final missingScopes = _missingRequiredGoogleApiScopes(result.tokenSet); if (missingScopes.isNotEmpty) { - await _oAuth.revokeAndSignOutAccount(result.accountId); + await _bestEffortInsufficientScopeCleanup( + 'Google', + () => _oAuth.revokeAndSignOutAccount(result.accountId), + ); throw OAuthException( 'OAuthMissingRequiredScope', _googleMissingScopesMessage(missingScopes), @@ -115,7 +141,10 @@ class AuthRepository { } final result = await microsoftOAuth.signIn(); if (!_hasRequiredMicrosoftScopes(result.tokenSet)) { - await microsoftOAuth.signOutAccount(result.accountId); + await _bestEffortInsufficientScopeCleanup( + 'Microsoft', + () => microsoftOAuth.signOutAccount(result.accountId), + ); throw const OAuthException( 'MicrosoftOAuthMissingRequiredScope', 'Required Microsoft To Do permission was not granted.', @@ -134,73 +163,70 @@ class AuthRepository { return AuthSessionState.signedIn(result.accountId); } - Future signOut({String? accountId}) async { - final targetAccountId = accountId ?? await _oAuth.activeAccountId; - if (targetAccountId == null) { - return; - } - await _deactivateAccount(targetAccountId, reconnectRequired: false); - if (targetAccountId.startsWith('microsoft:')) { - await _microsoftOAuth?.signOutAccount(targetAccountId); - } else { - await _oAuth.signOutAccount(targetAccountId); + Future _bestEffortInsufficientScopeCleanup( + String provider, + Future Function() cleanup, + ) async { + try { + await cleanup(); + } on Object catch (error) { + _logger.warning( + '$provider authorization cleanup failed after insufficient ' + 'permissions: $error', + ); } } Future markReconnectRequired(String accountId) async { - await _deactivateAccount(accountId, reconnectRequired: true); - if (accountId.startsWith('microsoft:')) { - await _microsoftOAuth?.signOutAccount(accountId); - } else { - await _oAuth.signOutAccount(accountId); - } - } - - Future revokeAndSignOut({String? accountId}) async { - final targetAccountId = accountId ?? await _oAuth.activeAccountId; - if (targetAccountId == null) { + final account = await _accountsRepository.accountById(accountId); + if (account == null) { return; } - await _deactivateAccount(targetAccountId, reconnectRequired: false); - if (targetAccountId.startsWith('microsoft:')) { - await _microsoftOAuth?.signOutAccount(targetAccountId); - } else { - await _oAuth.revokeAndSignOutAccount(targetAccountId); + await _database.transaction(() async { + await _accountsRepository.markReconnectRequired(accountId); + await _deleteScheduledNotifications(accountId); + }); + switch (account.provider) { + case TaskProvider.microsoft: + await _microsoftOAuth?.signOutAccount(accountId); + case TaskProvider.google: + await _oAuth.clearLocalSession(accountId: accountId); } } - Future deleteLocalAccountData({String? accountId}) async { - final targetAccountId = accountId ?? await _oAuth.activeAccountId; - if (targetAccountId == null) { - return; + Future removeAccount({ + required String accountId, + bool revokeAuthorization = false, + }) async { + final account = await _accountsRepository.accountById(accountId); + if (account == null) { + return const AccountRemovalResult.alreadyRemoved(); } - await _database.transaction(() async { - await _deleteScheduledNotifications(targetAccountId); - await (_database.delete( - _database.accounts, - )..where((row) => row.id.equals(targetAccountId))).go(); - }); + var revocationStatus = AccountAuthorizationRevocationStatus.notRequested; + if (revokeAuthorization && account.provider == TaskProvider.google) { + try { + await _oAuth.revokeAuthorization(accountId); + revocationStatus = AccountAuthorizationRevocationStatus.succeeded; + } on Object catch (error) { + _logger.warning( + 'Google authorization revocation failed during account removal: ' + '$error', + ); + revocationStatus = AccountAuthorizationRevocationStatus.failed; + } + } - if (targetAccountId.startsWith('microsoft:')) { - await _microsoftOAuth?.signOutAccount(targetAccountId); + if (account.provider == TaskProvider.microsoft) { + await _microsoftOAuth?.signOutAccount(accountId); } else { - await _oAuth.signOutAccount(targetAccountId); + await _oAuth.clearLocalSession(accountId: accountId); } - } + await _accountsRepository.deleteAccount(accountId); - Future _deactivateAccount( - String accountId, { - required bool reconnectRequired, - }) { - return _database.transaction(() async { - if (reconnectRequired) { - await _accountsRepository.markReconnectRequired(accountId); - } else { - await _accountsRepository.markSignedOut(accountId); - } - await _deleteScheduledNotifications(accountId); - }); + return AccountRemovalResult( + authorizationRevocationStatus: revocationStatus, + ); } Future _deleteScheduledNotifications(String accountId) { @@ -410,21 +436,6 @@ class AuthSessionController extends StateNotifier { } } - Future revokeAndSignOut() async { - await _repository.revokeAndSignOut(accountId: state.accountId); - state = const AuthSessionState.signedOut(); - } - - Future signOut() async { - await _repository.signOut(accountId: state.accountId); - state = const AuthSessionState.signedOut(); - } - - Future deleteLocalAccountData() async { - await _repository.deleteLocalAccountData(accountId: state.accountId); - state = const AuthSessionState.signedOut(); - } - Future cancelSignIn() async { _signInGeneration += 1; await _repository.cancelSignIn(); diff --git a/lib/src/features/auth/presentation/sign_in_screen.dart b/lib/src/features/auth/presentation/sign_in_screen.dart index 5a7d1de..ec113c5 100644 --- a/lib/src/features/auth/presentation/sign_in_screen.dart +++ b/lib/src/features/auth/presentation/sign_in_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -10,6 +11,7 @@ import 'package:yaru/yaru.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_glyphs.dart'; import '../../../app/busymax_keyboard_shortcuts_dialog.dart'; import '../../../app/busymax_yaru_theme.dart'; import '../../accounts/data/accounts_repository.dart'; @@ -40,16 +42,22 @@ class _SignInScreenState extends ConsumerState { var _nativeHeaderBarAvailable = false; var _finishingSetup = false; var _headerBarUpdateGeneration = 0; + late final LinuxHeaderBarSession _headerBarSession; StreamSubscription? _headerBarActions; @override void initState() { super.initState(); + _headerBarSession = ref.read(linuxHeaderBarServiceProvider).claimSession(); + _headerBarActions = _headerBarSession.actions.listen( + _handleHeaderBarAction, + ); unawaited(_initializeHeaderBar()); } @override void dispose() { + _headerBarSession.dispose(); unawaited(_headerBarActions?.cancel()); super.dispose(); } @@ -76,85 +84,101 @@ class _SignInScreenState extends ConsumerState { ), _OnboardingStep.preferences => true, }; - _updateHeaderBar( - canGoBack: canGoBack, - canContinue: canContinue, - backLabel: backLabel, - continueLabel: continueLabel, - ); - return Scaffold( body: ColoredBox( - color: BusyMaxSurfaceColors.of(context).view, + color: BusyMaxSurfaceColors.of(context).window, child: SafeArea( top: !Platform.isLinux || !_nativeHeaderBarAvailable, child: LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxWidth < 720; + final horizontalPadding = compact + ? BusyMaxSpacing.md + : BusyMaxSpacing.xxl; + final verticalPadding = compact + ? BusyMaxSpacing.md + : BusyMaxSpacing.xxl; + final availableWidth = math.max( + 0.0, + constraints.maxWidth - horizontalPadding * 2, + ); + final shadowGutter = math.min( + BusyMaxSpacing.sm, + availableWidth / 2, + ); + final contentRailWidth = math.min( + busyMaxOnboardingContentMaxWidth.toDouble(), + math.max(0.0, availableWidth - shadowGutter * 2), + ); + final scrollViewportWidth = contentRailWidth + shadowGutter * 2; + _updateHeaderBar( + canGoBack: canGoBack, + canContinue: canContinue, + backLabel: backLabel, + continueLabel: continueLabel, + contentWidth: contentRailWidth.round(), + ); + return Padding( padding: EdgeInsets.symmetric( - horizontal: compact ? BusyMaxSpacing.md : BusyMaxSpacing.xl, - vertical: compact ? BusyMaxSpacing.md : BusyMaxSpacing.xl, + horizontal: horizontalPadding, + vertical: verticalPadding, ), child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 900), - child: BusyMaxSurface( - filled: false, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Flexible( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.xl, - BusyMaxSpacing.xxl, - BusyMaxSpacing.xl, - BusyMaxSpacing.xxl, - ), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 480, + child: SizedBox( + width: scrollViewportWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + child: SingleChildScrollView( + key: const ValueKey('onboarding-scroll-viewport'), + padding: EdgeInsets.symmetric( + horizontal: shadowGutter, + ), + child: SizedBox( + key: const ValueKey('onboarding-content-rail'), + width: contentRailWidth, + child: switch (_step) { + _OnboardingStep.accounts => + _AccountsOnboardingStep( + accounts: accounts, + googleConfigured: + config.hasGoogleOAuthClientId, + microsoftConfigured: + config.hasMicrosoftOAuthClientId, + isGoogleSigningIn: + _signingInProvider == + _OnboardingProvider.google, + isMicrosoftSigningIn: + _signingInProvider == + _OnboardingProvider.microsoft, + errorMessage: _errorMessage, + missingConfigMessage: kReleaseMode + ? l10n.providerNotConfigured + : config.missingClientIdMessage, + onAddGoogle: () => + _signIn(_OnboardingProvider.google), + onAddMicrosoft: () => + _signIn(_OnboardingProvider.microsoft), + onCancelSignIn: _cancelSignIn, ), - child: switch (_step) { - _OnboardingStep.accounts => - _AccountsOnboardingStep( - accounts: accounts, - googleConfigured: - config.hasGoogleOAuthClientId, - microsoftConfigured: - config.hasMicrosoftOAuthClientId, - isGoogleSigningIn: - _signingInProvider == - _OnboardingProvider.google, - isMicrosoftSigningIn: - _signingInProvider == - _OnboardingProvider.microsoft, - errorMessage: _errorMessage, - missingConfigMessage: kReleaseMode - ? l10n.providerNotConfigured - : config.missingClientIdMessage, - onAddGoogle: () => - _signIn(_OnboardingProvider.google), - onAddMicrosoft: () => _signIn( - _OnboardingProvider.microsoft, - ), - onCancelSignIn: _cancelSignIn, - ), - _OnboardingStep.preferences => - _PreferencesOnboardingStep( - settings: settings, - settingsController: settingsController, - ), - }, - ), - ), + _OnboardingStep.preferences => + _PreferencesOnboardingStep( + settings: settings, + settingsController: settingsController, + ), + }, ), ), - if (_showFlutterFooterFallback) - _OnboardingFooter( + ), + if (_showFlutterFooterFallback) + Padding( + padding: EdgeInsets.symmetric( + horizontal: shadowGutter, + ), + child: _OnboardingFooter( canGoBack: canGoBack, canContinue: canContinue, backLabel: backLabel, @@ -162,8 +186,8 @@ class _SignInScreenState extends ConsumerState { onBack: _previousStep, onContinue: _nextStep, ), - ], - ), + ), + ], ), ), ), @@ -176,15 +200,13 @@ class _SignInScreenState extends ConsumerState { } Future _initializeHeaderBar() async { - final service = ref.read(linuxHeaderBarServiceProvider); - await service.initialize(); + await _headerBarSession.initialize(); if (!mounted) { return; } - _headerBarActions = service.actions.listen(_handleHeaderBarAction); setState(() { _headerBarReady = true; - _nativeHeaderBarAvailable = service.isAvailable; + _nativeHeaderBarAvailable = _headerBarSession.isAvailable; }); } @@ -200,6 +222,7 @@ class _SignInScreenState extends ConsumerState { required bool canContinue, required String backLabel, required String continueLabel, + required int contentWidth, }) { if (_finishingSetup) { return; @@ -215,39 +238,44 @@ class _SignInScreenState extends ConsumerState { generation != _headerBarUpdateGeneration) { return; } - final service = ref.read(linuxHeaderBarServiceProvider); unawaited(() async { - await service.initialize(); - if (!mounted || - _finishingSetup || - generation != _headerBarUpdateGeneration) { - return; - } - await service.setScheduleControlsVisible(false); - await service.setBackVisible(false); - await service.setSidebarVisible(false); - await service.setTitleRange(title); + await _headerBarSession.updateState( + BusyMaxHeaderBarState( + title: title, + viewMode: ref.read(appSettingsControllerProvider).scheduleViewMode, + canRefresh: false, + canCreateEvent: false, + canCreateTask: false, + searchActive: false, + searchQuery: '', + canShowSidebar: false, + sidebarVisible: false, + navigationVisible: false, + scheduleControlsVisible: false, + backVisible: false, + ), + ); if (!mounted || _finishingSetup || generation != _headerBarUpdateGeneration) { return; } - await service.setOnboardingControls( + await _headerBarSession.setOnboardingControls( visible: true, canGoBack: canGoBack, canContinue: canContinue, backLabel: backLabel, continueLabel: continueLabel, + contentWidth: contentWidth, ); - await service.setCanRefresh(false); - await service.setCanCreate(false); - await service.setSearchActive(false); - await service.setModalBarrierVisible(false); }()); }); } void _handleHeaderBarAction(BusyMaxHeaderBarAction action) { + if (!_headerBarSession.isCurrent) { + return; + } if (action == BusyMaxHeaderBarAction.back) { _previousStep(); return; @@ -357,9 +385,8 @@ class _SignInScreenState extends ConsumerState { } Future _clearOnboardingHeaderBar() async { - final service = ref.read(linuxHeaderBarServiceProvider); - await service.initialize(); - await service.setOnboardingControls( + await _headerBarSession.initialize(); + await _headerBarSession.setOnboardingControls( visible: false, canGoBack: false, canContinue: false, @@ -367,7 +394,6 @@ class _SignInScreenState extends ConsumerState { continueLabel: '', force: true, ); - await service.setBackVisible(false); } } @@ -454,7 +480,7 @@ class _AccountsOnboardingStep extends StatelessWidget { ], if (isSigningIn) ...[ const SizedBox(height: BusyMaxSpacing.md), - BusyMaxPushButton.outlined( + BusyMaxPushButton.standard( onPressed: onCancelSignIn, child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -587,13 +613,13 @@ class _PreferencesOnboardingStep extends StatelessWidget { onSelected: settingsController.setThemeModePreference, ), BusyMaxSwitchRow( - title: 'Run in background when window is closed', + title: l10n.runInBackgroundWhenClosed, value: settings.runInBackgroundWhenClosed, onChanged: settingsController.setRunInBackgroundWhenClosed, leading: const Icon(YaruIcons.window), ), BusyMaxSwitchRow( - title: 'Show tray icon', + title: l10n.showTrayIcon, value: settings.showTrayIcon, onChanged: settingsController.setShowTrayIcon, leading: const Icon(YaruIcons.pin), @@ -605,13 +631,13 @@ class _PreferencesOnboardingStep extends StatelessWidget { filled: true, children: [ BusyMaxSwitchRow( - title: 'Event reminders', + title: l10n.eventReminders, value: settings.notifyEventReminders, onChanged: settingsController.setNotifyEventReminders, leading: const Icon(YaruIcons.calendar_day), ), BusyMaxSwitchRow( - title: 'Task reminders', + title: l10n.taskReminders, value: settings.notifyTaskReminders, onChanged: settingsController.setNotifyTaskReminders, leading: const Icon(YaruIcons.checkmark), @@ -628,11 +654,13 @@ class _PreferencesOnboardingStep extends StatelessWidget { title: l10n.privacy, filled: true, children: [ - BusyMaxSwitchRow( - title: l10n.detailedNotifications, - value: settings.detailedNotifications, - onChanged: settingsController.setDetailedNotifications, + BusyMaxComboRow( + title: l10n.notificationDetailLevel, leading: const Icon(YaruIcons.eye), + values: NotificationDetailLevel.values, + selected: settings.notificationDetailLevel, + labelFor: (value) => _notificationDetailLabel(context, value), + onSelected: settingsController.setNotificationDetailLevel, ), BusyMaxSwitchRow( title: l10n.redactTaskContentInDiagnostics, @@ -706,7 +734,10 @@ class _ProviderSignInButton extends StatelessWidget { dimension: 18, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Icon(YaruIcons.pan_end, size: BusyMaxSizes.iconSm), + : Icon( + BusyMaxGlyphs.collapsedFor(Directionality.of(context)), + size: BusyMaxSizes.iconSm, + ), enabled: enabled, tooltip: effectiveTooltip, onTap: onPressed, @@ -736,19 +767,20 @@ class _OnboardingFooter extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.lg, - vertical: BusyMaxSpacing.md, - ), + padding: const EdgeInsets.only(top: BusyMaxSpacing.xl), child: Row( children: [ - BusyMaxPushButton.outlined( + TextButton( + key: const ValueKey('onboarding-back-button'), onPressed: canGoBack ? onBack : null, + style: _onboardingTextButtonStyle(context), child: Text(backLabel), ), const Spacer(), - BusyMaxPushButton.filled( + TextButton( + key: const ValueKey('onboarding-continue-button'), onPressed: canContinue ? onContinue : null, + style: _onboardingTextButtonStyle(context), child: Text(continueLabel), ), ], @@ -757,6 +789,27 @@ class _OnboardingFooter extends StatelessWidget { } } +ButtonStyle _onboardingTextButtonStyle(BuildContext context) { + final labelStyle = Theme.of(context).textTheme.labelLarge; + return ButtonStyle( + padding: const WidgetStatePropertyAll(EdgeInsets.zero), + minimumSize: const WidgetStatePropertyAll(Size.zero), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + backgroundColor: const WidgetStatePropertyAll(Colors.transparent), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + elevation: const WidgetStatePropertyAll(0), + textStyle: WidgetStateProperty.resolveWith((states) { + final emphasize = + !states.contains(WidgetState.disabled) && + (states.contains(WidgetState.hovered) || + states.contains(WidgetState.focused)); + return labelStyle?.copyWith( + decoration: emphasize ? TextDecoration.underline : TextDecoration.none, + ); + }), + ); +} + String _onboardingErrorMessage(BuildContext context, Object error) { if (error is OAuthException) { if (error.code == 'OAuthMissingRequiredScope') { @@ -797,3 +850,14 @@ String _themeModeLabel( BusyMaxThemeModePreference.dark => l10n.themeDark, }; } + +String _notificationDetailLabel( + BuildContext context, + NotificationDetailLevel level, +) { + final l10n = context.l10n; + return switch (level) { + NotificationDetailLevel.private => l10n.notificationDetailPrivate, + NotificationDetailLevel.normal => l10n.notificationDetailNormal, + }; +} diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index 80ac05d..dc8509f 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -64,6 +64,74 @@ class CalendarSourceEntity { final String? colorId; final String? timeZone; final String? accessRole; + + CalendarSourceCapabilities get capabilities => + CalendarSourceCapabilities.fromSource(this); +} + +/// The event operations currently permitted by a calendar source. +/// +/// Keeping this policy with the source model gives every presentation surface +/// and mutation entry point the same answer. Visibility is deliberately not a +/// write capability: a hidden calendar can still own an event that is opened +/// from a notification or deep link. +class CalendarSourceCapabilities { + const CalendarSourceCapabilities({ + required this.canCreateEvents, + required this.canEditEvents, + required this.canDeleteEvents, + }); + + factory CalendarSourceCapabilities.fromSource(CalendarSourceEntity source) { + final writable = !source.readOnly && !source.isDeleted; + return CalendarSourceCapabilities( + canCreateEvents: writable, + canEditEvents: writable, + canDeleteEvents: writable, + ); + } + + static const unavailable = CalendarSourceCapabilities( + canCreateEvents: false, + canEditEvents: false, + canDeleteEvents: false, + ); + + final bool canCreateEvents; + final bool canEditEvents; + final bool canDeleteEvents; +} + +enum CalendarMutationOperation { + createEvent, + editEvent, + deleteEvent, + renameCalendar, + deleteCalendar, +} + +class CalendarMutationNotAllowed implements Exception { + const CalendarMutationNotAllowed({ + required this.operation, + required this.sourceId, + }); + + final CalendarMutationOperation operation; + final String sourceId; + + @override + String toString() { + return 'CalendarMutationNotAllowed(${operation.name}, source: $sourceId)'; + } +} + +List writableCalendarSources( + Iterable sources, +) { + return [ + for (final source in sources) + if (source.capabilities.canCreateEvents) source, + ]; } class CalendarRepository { @@ -89,7 +157,9 @@ class CalendarRepository { return Stream.value(const []); } final query = _database.select(_database.calendarSources) - ..where((row) => row.accountId.isIn(accountIds)) + ..where( + (row) => row.accountId.isIn(accountIds) & row.isDeleted.equals(false), + ) ..orderBy([ (row) => OrderingTerm.asc(row.accountId), (row) => OrderingTerm.asc(row.summary), @@ -139,6 +209,10 @@ class CalendarRepository { final source = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).getSingle(); + _requireWritableSource( + source, + operation: CalendarMutationOperation.renameCalendar, + ); final now = _now().millisecondsSinceEpoch; final nowUtc = DateTime.now().toUtc().toIso8601String(); await _database.transaction(() async { @@ -173,6 +247,10 @@ class CalendarRepository { final source = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).getSingle(); + _requireWritableSource( + source, + operation: CalendarMutationOperation.deleteCalendar, + ); final now = _now().millisecondsSinceEpoch; final nowUtc = DateTime.now().toUtc().toIso8601String(); await _database.transaction(() async { @@ -415,6 +493,17 @@ class CalendarRepository { final source = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(draft.sourceId))).getSingle(); + _requireWritableSource( + source, + operation: CalendarMutationOperation.createEvent, + ); + if (source.accountId != draft.accountId || + source.providerCalendarId != draft.providerCalendarId) { + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.createEvent, + sourceId: source.id, + ); + } final now = _now().millisecondsSinceEpoch; final provider = TaskProviderParsing.fromStorageValue(source.provider); final localEventId = 'local:${const Uuid().v4()}'; @@ -521,6 +610,10 @@ class CalendarRepository { final existing = await (_database.select( _database.calendarEvents, )..where((row) => row.id.equals(eventId))).getSingle(); + _requireWritableSource( + source, + operation: CalendarMutationOperation.editEvent, + ); final sourceChanged = draft.accountId != existing.accountId || draft.sourceId != existing.calendarSourceId || @@ -669,6 +762,13 @@ class CalendarRepository { final existing = await (_database.select( _database.calendarEvents, )..where((row) => row.id.equals(eventId))).getSingle(); + final source = await (_database.select( + _database.calendarSources, + )..where((row) => row.id.equals(existing.calendarSourceId))).getSingle(); + _requireWritableSource( + source, + operation: CalendarMutationOperation.deleteEvent, + ); final now = _now().millisecondsSinceEpoch; await _database.transaction(() async { await (_database.update( @@ -851,6 +951,16 @@ class CalendarRepository { } } +void _requireWritableSource( + CalendarSource source, { + required CalendarMutationOperation operation, +}) { + if (!source.readOnly && !source.isDeleted) { + return; + } + throw CalendarMutationNotAllowed(operation: operation, sourceId: source.id); +} + String? _json(Object? value) => value == null ? null : jsonEncode(value); Map _eventRequest( diff --git a/lib/src/features/calendar/presentation/event_description_editor.dart b/lib/src/features/calendar/presentation/event_description_editor.dart index 30a38a0..92f09d5 100644 --- a/lib/src/features/calendar/presentation/event_description_editor.dart +++ b/lib/src/features/calendar/presentation/event_description_editor.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_yaru_theme.dart'; @@ -102,8 +101,8 @@ class _EventDescriptionEditorState extends State { child: Row( children: [ _FormatButton( - label: 'B', - tooltip: 'Bold', + label: context.l10n.formatBoldShortLabel, + tooltip: context.l10n.formatBoldTooltip, active: _controller.selectionHasStyle( CalendarDescriptionInlineStyle.bold, ), @@ -112,8 +111,8 @@ class _EventDescriptionEditorState extends State { ), const SizedBox(width: BusyMaxSpacing.xs), _FormatButton( - label: 'I', - tooltip: 'Italic', + label: context.l10n.formatItalicShortLabel, + tooltip: context.l10n.formatItalicTooltip, active: _controller.selectionHasStyle( CalendarDescriptionInlineStyle.italic, ), @@ -123,8 +122,8 @@ class _EventDescriptionEditorState extends State { ), const SizedBox(width: BusyMaxSpacing.xs), _FormatButton( - label: 'U', - tooltip: 'Underline', + label: context.l10n.formatUnderlineShortLabel, + tooltip: context.l10n.formatUnderlineTooltip, active: _controller.selectionHasStyle( CalendarDescriptionInlineStyle.underline, ), @@ -181,19 +180,17 @@ class _FormatButton extends StatelessWidget { return Tooltip( message: tooltip, child: SizedBox.square( - dimension: BusyMaxSizes.pushButtonHeight, - child: YaruIconButton( + dimension: BusyMaxSizes.headerIconButton, + child: BusyMaxHeaderIconButton( tooltip: tooltip, onPressed: onPressed, - style: busyMaxHeaderIconButtonStyle( - foregroundColor: Theme.of(context).colorScheme.onSurface, - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (active || states.contains(WidgetState.hovered)) { - return surfaceColors.controlHover; - } - return Colors.transparent; - }), - ), + foregroundColor: Theme.of(context).colorScheme.onSurface, + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (active || states.contains(WidgetState.hovered)) { + return surfaceColors.controlHover; + } + return Colors.transparent; + }), icon: Text( label, style: Theme.of(context).textTheme.labelLarge?.copyWith( diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index a2bc8aa..0779504 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:yaru/yaru.dart'; @@ -32,6 +34,7 @@ Future showBusyMaxEventEditorDialog( initialDraft: initialDraft, sources: sources, categorySuggestionsByAccount: categorySuggestionsByAccount, + headerBarService: headerBarService, onCancel: () => Navigator.of(context).pop(), onSave: (draft) => Navigator.of(context).pop(EventEditorDialogResult.save(draft)), @@ -69,6 +72,7 @@ class EventEditor extends StatefulWidget { required this.onSave, this.onDelete, this.categorySuggestionsByAccount = const {}, + this.headerBarService, }); final EventEditorDraft initialDraft; @@ -77,6 +81,7 @@ class EventEditor extends StatefulWidget { final VoidCallback onCancel; final ValueChanged onSave; final ValueChanged? onDelete; + final LinuxHeaderBarService? headerBarService; @override State createState() => _EventEditorState(); @@ -86,10 +91,12 @@ class _EventEditorState extends State { late EventEditorDraft _draft; final _shortcutFocusNode = FocusNode(debugLabel: 'Event editor shortcuts'); final _guestController = TextEditingController(); - final _categoryController = TextEditingController(); String? _guestError; var _addingGuest = false; var _addingCategory = false; + var _confirmingCancel = false; + var _startTimeValid = true; + var _endTimeValid = true; @override void initState() { @@ -101,14 +108,13 @@ class _EventEditorState extends State { void dispose() { _shortcutFocusNode.dispose(); _guestController.dispose(); - _categoryController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = context.l10n; - final dirty = _draft != widget.initialDraft; + final dirty = _hasUnsavedChanges; CalendarSourceEntity? currentSource; for (final source in widget.sources) { if (source.id == _draft.sourceId) { @@ -120,9 +126,13 @@ class _EventEditorState extends State { final title = widget.initialDraft.eventId == null ? l10n.newEvent : l10n.editEvent; - final canSave = dirty && _draft.canSave; + final timeFieldsValid = _draft.allDay || (_startTimeValid && _endTimeValid); + final canSave = dirty && _draft.canSave && timeFieldsValid; return CallbackShortcuts( bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () { + unawaited(_cancel()); + }, const SingleActivator(LogicalKeyboardKey.keyS, control: true): () { if (canSave) { widget.onSave(_draft); @@ -137,18 +147,17 @@ class _EventEditorState extends State { title: title, cancelLabel: l10n.cancel, saveLabel: l10n.save, - onCancel: widget.onCancel, + onCancel: () => unawaited(_cancel()), onSave: canSave ? () => widget.onSave(_draft) : null, children: [ BusyMaxGroupedList( filled: true, children: [ YaruListTile.square( - hoverColor: busyMaxEditorRowHoverColor(context), title: TextFormField( initialValue: _draft.title, autofocus: true, - decoration: _plainEventFieldDecoration( + decoration: busyMaxGroupedTextFieldDecoration( context, labelText: l10n.title, ), @@ -160,10 +169,9 @@ class _EventEditorState extends State { ), ), YaruListTile.square( - hoverColor: busyMaxEditorRowHoverColor(context), title: TextFormField( initialValue: _draft.location, - decoration: _plainEventFieldDecoration( + decoration: busyMaxGroupedTextFieldDecoration( context, labelText: l10n.location, ), @@ -187,6 +195,7 @@ class _EventEditorState extends State { ], ), BusyMaxGroupedList( + title: l10n.startDateTime, filled: true, children: [ DesktopDateValueRow( @@ -195,7 +204,6 @@ class _EventEditorState extends State { onChanged: (value) { _setStart(_withDate(_draft.start, value), provider); }, - emptyLabel: l10n.noneValue, ), if (!_draft.allDay) DesktopTimeValueRow( @@ -204,12 +212,23 @@ class _EventEditorState extends State { onChanged: (value) { _setStart(_withTime(_draft.start, value), provider); }, - emptyLabel: '--:--', + timeZone: _draft.startTimeZone, + onTimeZoneChanged: (value) { + setState(() { + _draft = _draft.copyWith(startTimeZone: value); + }); + }, allowEmpty: false, + onValidityChanged: (valid) { + if (_startTimeValid != valid) { + setState(() => _startTimeValid = valid); + } + }, ), ], ), BusyMaxGroupedList( + title: l10n.endDateTime, filled: true, children: [ DesktopDateValueRow( @@ -218,7 +237,6 @@ class _EventEditorState extends State { onChanged: (value) { _setEnd(_withDate(_draft.end, value)); }, - emptyLabel: l10n.noneValue, ), if (!_draft.allDay) DesktopTimeValueRow( @@ -227,8 +245,18 @@ class _EventEditorState extends State { onChanged: (value) { _setEnd(_withTime(_draft.end, value)); }, - emptyLabel: '--:--', + timeZone: _draft.endTimeZone, + onTimeZoneChanged: (value) { + setState(() { + _draft = _draft.copyWith(endTimeZone: value); + }); + }, allowEmpty: false, + onValidityChanged: (valid) { + if (_endTimeValid != valid) { + setState(() => _endTimeValid = valid); + } + }, ), ], ), @@ -257,7 +285,6 @@ class _EventEditorState extends State { filled: true, children: [ YaruListTile.square( - hoverColor: busyMaxEditorRowHoverColor(context), title: EventDescriptionEditor( provider: provider, text: _draft.description, @@ -314,6 +341,33 @@ class _EventEditorState extends State { ); } + Future _cancel() async { + if (_confirmingCancel) { + return; + } + if (!_hasUnsavedChanges) { + widget.onCancel(); + return; + } + + _confirmingCancel = true; + try { + final discard = await showBusyMaxConfirm( + context, + title: context.l10n.discardChanges, + message: context.l10n.discardChangesConfirmation, + confirmLabel: context.l10n.discardChangesAction, + destructive: true, + headerBarService: widget.headerBarService, + ); + if (discard && mounted) { + widget.onCancel(); + } + } finally { + _confirmingCancel = false; + } + } + KeyEventResult _handleEditorKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent || !_canDeleteWithShortcut || @@ -364,10 +418,6 @@ class _EventEditorState extends State { values: labels.keys.toList(), selected: _recurrenceType(_draft.recurrence), labelFor: (value) => labels[value] ?? l10n.doesNotRepeat, - selectedBuilder: (context, value) => _eventEditorSelectedValue( - context, - labels[value] ?? l10n.doesNotRepeat, - ), onSelected: (value) { setState(() { _draft = value == 'none' @@ -408,17 +458,9 @@ class _EventEditorState extends State { enabled: existingSourceId == null, labelFor: (value) => sources.firstWhere((source) => source.id == value).summary, - menuItemBuilder: (context, value) { - return _calendarSourceChoice( - context, - sources.firstWhere((source) => source.id == value), - ); - }, - selectedBuilder: (context, value) { - return _calendarSourceSelectedChoice( - context, - sources.firstWhere((source) => source.id == value), - ); + selectorLeadingBuilder: (context, value) { + final source = sources.firstWhere((source) => source.id == value); + return _CalendarSourceDot(color: _calendarSourceColor(context, source)); }, onSelected: (value) { final source = sources.firstWhere((source) => source.id == value); @@ -430,7 +472,6 @@ class _EventEditorState extends State { setState(() { if (source.provider != TaskProvider.microsoft) { _addingCategory = false; - _categoryController.clear(); } _draft = _draft.copyWith( accountId: source.accountId, @@ -462,7 +503,7 @@ class _EventEditorState extends State { leading: const Icon(Icons.notifications_outlined), values: _reminderValuesFor(minutes[index]), selected: minutes[index], - labelFor: _reminderLabel, + labelFor: (value) => _reminderLabel(context, value), onSelected: (value) { _setReminderMinutes(provider, [ ...minutes.take(index), @@ -470,7 +511,7 @@ class _EventEditorState extends State { ...minutes.skip(index + 1), ]); }, - trailingAction: YaruIconButton( + trailingAction: BusyMaxHeaderIconButton( tooltip: l10n.removeReminder, iconSize: BusyMaxSizes.headerIcon, icon: const Icon(YaruIcons.window_close), @@ -480,11 +521,9 @@ class _EventEditorState extends State { ...minutes.skip(index + 1), ]); }, - style: busyMaxHeaderIconButtonStyle( - foregroundColor: colorScheme.onSurfaceVariant, - backgroundColor: busyMaxSubtleButtonBackground(context), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - ), + foregroundColor: colorScheme.onSurfaceVariant, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), ), ), if (canAddReminder) @@ -565,22 +604,21 @@ class _EventEditorState extends State { rows.add( YaruListTile.square( leading: const Icon(Icons.person_add_alt_outlined), - hoverColor: busyMaxEditorRowHoverColor(context), + trailing: YaruIconButton( + tooltip: context.l10n.addGuest, + icon: const Icon(YaruIcons.plus), + onPressed: _addGuest, + ), title: TextField( controller: _guestController, autofocus: true, - decoration: _plainEventFieldDecoration( + decoration: busyMaxGroupedTextFieldDecoration( context, labelText: context.l10n.addGuestEmail, errorText: _guestError, ), onSubmitted: (_) => _addGuest(), ), - trailing: YaruIconButton( - tooltip: context.l10n.addGuest, - icon: const Icon(YaruIcons.plus), - onPressed: _addGuest, - ), ), ); } @@ -597,7 +635,6 @@ class _EventEditorState extends State { widget.categorySuggestionsByAccount[_draft.accountId] ?? const [], adding: _addingCategory, - controller: _categoryController, inputKey: const Key('event-category-input'), onAddPressed: () { setState(() { @@ -606,7 +643,6 @@ class _EventEditorState extends State { }, onSubmitted: _addCategory, onCancelAdding: () { - _categoryController.clear(); setState(() { _addingCategory = false; }); @@ -629,9 +665,7 @@ class _EventEditorState extends State { leading: const Icon(Icons.work_outline), values: values, selected: selected, - labelFor: _availabilityLabel, - selectedBuilder: (context, value) => - _eventEditorSelectedValue(context, _availabilityLabel(value)), + labelFor: (value) => _availabilityLabel(context, value), onSelected: (value) { setState(() { _draft = _draft.copyWith(showAs: value); @@ -652,9 +686,7 @@ class _EventEditorState extends State { leading: const Icon(Icons.visibility_outlined), values: values, selected: selected, - labelFor: _titleCase, - selectedBuilder: (context, value) => - _eventEditorSelectedValue(context, _titleCase(value)), + labelFor: (value) => _visibilityLabel(context, value), onSelected: (value) { setState(() { _draft = _draft.copyWith(visibilityOrSensitivity: value); @@ -666,7 +698,7 @@ class _EventEditorState extends State { void _addGuest() { final email = _guestController.text.trim(); if (!_looksLikeEmail(email)) { - setState(() => _guestError = 'Enter a valid email address'); + setState(() => _guestError = context.l10n.feedbackInvalidEmail); return; } if (_draft.attendees.any((attendee) => attendee.email == email)) { @@ -692,10 +724,12 @@ class _EventEditorState extends State { void _addCategory(String value) { final category = value.trim(); - if (category.isEmpty || _draft.categories.contains(category)) { + if (category.isEmpty || + _draft.categories.any( + (existing) => existing.toLowerCase() == category.toLowerCase(), + )) { return; } - _categoryController.clear(); setState(() { _addingCategory = false; _draft = _draft.copyWith(categories: [..._draft.categories, category]); @@ -717,6 +751,8 @@ class _EventEditorState extends State { final start = _draft.start; final end = _draft.end; setState(() { + _startTimeValid = true; + _endTimeValid = true; _draft = _draft.copyWith( allDay: allDay, end: start != null && !_isValidEventEnd(start, end, allDay) @@ -726,6 +762,12 @@ class _EventEditorState extends State { }); } + bool get _hasUnsavedChanges { + final hasInvalidVisibleTime = + !_draft.allDay && (!_startTimeValid || !_endTimeValid); + return _draft != widget.initialDraft || hasInvalidVisibleTime; + } + void _setStart(DateTime start, BusyProvider provider) { final end = _draft.end; final recurrenceType = _recurrenceType(_draft.recurrence); @@ -777,39 +819,6 @@ TextStyle? _eventEditorProminentActionStyle( ).textTheme.labelLarge?.copyWith(color: color, fontWeight: fontWeight); } -InputDecoration _plainEventFieldDecoration( - BuildContext context, { - required String labelText, - String? errorText, - bool alignLabelWithHint = false, -}) { - final colorScheme = Theme.of(context).colorScheme; - final labelColor = errorText == null - ? colorScheme.onSurfaceVariant - : colorScheme.error; - final labelStyle = Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: labelColor); - return InputDecoration( - filled: false, - fillColor: Colors.transparent, - hoverColor: Colors.transparent, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - labelText: labelText, - labelStyle: labelStyle, - floatingLabelStyle: labelStyle, - floatingLabelBehavior: FloatingLabelBehavior.auto, - alignLabelWithHint: alignLabelWithHint, - errorText: errorText, - ); -} - String? _dateString(DateTime? value) { return value == null ? null : encodeDateOnly(value); } @@ -1028,93 +1037,47 @@ int _nextReminderMinute(List existing) { return _eventReminderMinuteOptions.first; } -String _reminderLabel(int minutes) { - return switch (minutes) { - 5 => '5 minutes before', - 10 => '10 minutes before', - 30 => '30 minutes before', - 60 => '1 hour before', - 1440 => '1 day before', - _ => '$minutes minutes before', - }; +String _reminderLabel(BuildContext context, int minutes) { + final l10n = context.l10n; + const minutesPerDay = Duration.minutesPerHour * Duration.hoursPerDay; + if (minutes % minutesPerDay == 0) { + return l10n.reminderDaysBefore(minutes ~/ minutesPerDay); + } + if (minutes % Duration.minutesPerHour == 0) { + return l10n.reminderHoursBefore(minutes ~/ Duration.minutesPerHour); + } + return l10n.reminderMinutesBefore(minutes); } -String _availabilityLabel(String value) { +String _availabilityLabel(BuildContext context, String value) { + final l10n = context.l10n; return switch (value) { - 'opaque' => 'Busy', - 'transparent' => 'Free', - 'oof' => 'Out of office', - 'workingElsewhere' => 'Working elsewhere', - _ => _titleCase(value), + 'opaque' || 'busy' => l10n.busy, + 'transparent' || 'free' => l10n.availabilityFree, + 'tentative' => l10n.availabilityTentative, + 'oof' => l10n.availabilityOutOfOffice, + 'workingElsewhere' => l10n.availabilityWorkingElsewhere, + _ => value, }; } -String _titleCase(String value) { - if (value.isEmpty) { - return value; - } - return value[0].toUpperCase() + value.substring(1); +String _visibilityLabel(BuildContext context, String value) { + final l10n = context.l10n; + return switch (value) { + 'default' => l10n.visibilityDefault, + 'public' => l10n.visibilityPublic, + 'private' => l10n.visibilityPrivate, + 'confidential' => l10n.visibilityConfidential, + 'normal' => l10n.sensitivityNormal, + 'personal' => l10n.sensitivityPersonal, + _ => value, + }; } bool _looksLikeEmail(String value) { return RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(value); } -Widget _calendarSourceChoice( - BuildContext context, - CalendarSourceEntity source, -) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - _CalendarSourceDot(color: _calendarSourceColor(context, source)), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: Text( - source.summary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); -} - -Widget _calendarSourceSelectedChoice( - BuildContext context, - CalendarSourceEntity source, -) { - return Row( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.max, - children: [ - _CalendarSourceDot(color: _calendarSourceColor(context, source)), - const SizedBox(width: BusyMaxSpacing.sm), - Flexible( - fit: FlexFit.loose, - child: Text( - source.summary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.end, - ), - ), - ], - ); -} - -Widget _eventEditorSelectedValue(BuildContext context, String value) { - return Align( - alignment: Alignment.centerRight, - child: Text( - value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.end, - ), - ); -} - class _CalendarSourceDot extends StatelessWidget { const _CalendarSourceDot({required this.color}); diff --git a/lib/src/features/feedback/presentation/feedback_dialog.dart b/lib/src/features/feedback/presentation/feedback_dialog.dart index a81b92c..f2e12d6 100644 --- a/lib/src/features/feedback/presentation/feedback_dialog.dart +++ b/lib/src/features/feedback/presentation/feedback_dialog.dart @@ -1,6 +1,8 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:uuid/uuid.dart'; import 'package:yaru/yaru.dart'; @@ -35,6 +37,7 @@ Future showBusyMaxFeedbackDialog( maxHeight: 760, builder: (dialogContext) => BusyMaxFeedbackDialog( submissionService: submissionService, + headerBarService: headerBarService, onCancel: () => Navigator.of(dialogContext).pop(), ), ); @@ -48,6 +51,7 @@ class BusyMaxFeedbackDialog extends StatefulWidget { this.metadataLoader, this.submissionIdGenerator, this.osVersionProvider, + this.headerBarService, }); final FeedbackSubmissionService submissionService; @@ -55,6 +59,7 @@ class BusyMaxFeedbackDialog extends StatefulWidget { final FeedbackAppMetadataLoader? metadataLoader; final FeedbackSubmissionIdGenerator? submissionIdGenerator; final FeedbackOsVersionProvider? osVersionProvider; + final LinuxHeaderBarService? headerBarService; @override State createState() => _BusyMaxFeedbackDialogState(); @@ -71,6 +76,7 @@ class _BusyMaxFeedbackDialogState extends State { var _includeTechnicalDetails = false; var _validationAttempted = false; var _submitting = false; + var _confirmingCancel = false; String? _statusMessage; var _statusIsError = false; @@ -105,149 +111,181 @@ class _BusyMaxFeedbackDialogState extends State { return PopScope( canPop: !_submitting, - child: BusyMaxModalEditorScaffold( - title: l10n.sendFeedback, - cancelLabel: l10n.cancel, - saveLabel: l10n.feedbackSubmit, - onCancel: widget.onCancel, - cancelEnabled: !_submitting, - onSave: _submitting ? null : _submit, - saving: _submitting, - children: [ - BusyMaxGroupedList( - filled: true, + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () { + unawaited(_cancel()); + }, + }, + child: Focus( + autofocus: true, + child: BusyMaxModalEditorScaffold( + title: l10n.reportAnIssue, + cancelLabel: l10n.cancel, + saveLabel: l10n.feedbackSubmit, + onCancel: () => unawaited(_cancel()), + cancelEnabled: !_submitting, + onSave: _submitting ? null : _submit, + saving: _submitting, children: [ - Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.md), - child: InputDecorator( - decoration: InputDecoration( - labelText: l10n.feedbackCategory, + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxComboRow( + key: const Key('feedback-category'), + title: l10n.feedbackCategory, errorText: categoryInvalid ? l10n.feedbackCategoryRequired : null, + values: const [null, ...FeedbackCategory.values], + selected: _category, + labelFor: (category) => category == null + ? l10n.feedbackSelectCategory + : _categoryLabel(context, category), + enabled: !_submitting, + onSelected: (value) { + setState(() { + _category = value; + _draftChanged(); + }); + }, ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - key: const Key('feedback-category'), - value: _category, - isExpanded: true, - hint: Text(l10n.feedbackSelectCategory), - items: [ - for (final category in FeedbackCategory.values) - DropdownMenuItem( - value: category, - child: Text(_categoryLabel(context, category)), - ), - ], - onChanged: _submitting - ? null - : (value) { - setState(() { - _category = value; - _draftChanged(); - }); - }, + YaruListTile.square( + title: TextField( + key: const Key('feedback-subject'), + controller: _subjectController, + enabled: !_submitting, + textInputAction: TextInputAction.next, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: l10n.feedbackSubject, + errorText: subjectInvalid + ? l10n.feedbackSubjectLengthError + : null, + ), + onChanged: (_) => setState(_draftChanged), ), ), - ), - ), - YaruListTile.square( - title: TextField( - key: const Key('feedback-subject'), - controller: _subjectController, - enabled: !_submitting, - textInputAction: TextInputAction.next, - decoration: InputDecoration( - labelText: l10n.feedbackSubject, - errorText: subjectInvalid - ? l10n.feedbackSubjectLengthError - : null, + YaruListTile.square( + title: TextField( + key: const Key('feedback-message'), + controller: _messageController, + enabled: !_submitting, + minLines: 4, + maxLines: 8, + keyboardType: TextInputType.multiline, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: l10n.feedbackDetailedMessage, + alignLabelWithHint: true, + errorText: messageInvalid + ? l10n.feedbackMessageLengthError + : null, + ), + onChanged: (_) => setState(_draftChanged), + ), ), - onChanged: (_) => setState(_draftChanged), - ), + YaruListTile.square( + title: TextField( + key: const Key('feedback-reply-email'), + controller: _replyEmailController, + enabled: !_submitting, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: l10n.feedbackReplyEmail, + errorText: replyEmailInvalid + ? l10n.feedbackInvalidEmail + : null, + ), + onChanged: (_) => setState(_draftChanged), + onSubmitted: (_) { + if (!_submitting) { + _submit(); + } + }, + ), + ), + ], ), - YaruListTile.square( - title: TextField( - key: const Key('feedback-message'), - controller: _messageController, - enabled: !_submitting, - minLines: 4, - maxLines: 8, - keyboardType: TextInputType.multiline, - decoration: InputDecoration( - labelText: l10n.feedbackDetailedMessage, - alignLabelWithHint: true, - errorText: messageInvalid - ? l10n.feedbackMessageLengthError - : null, + BusyMaxGroupedList( + filled: true, + children: [ + YaruCheckboxListTile( + key: const Key('feedback-technical-details'), + value: _includeTechnicalDetails, + onChanged: _submitting + ? null + : (value) { + setState(() { + _includeTechnicalDetails = value ?? false; + _draftChanged(); + }); + }, + title: Text(l10n.feedbackIncludeTechnicalDetails), + subtitle: Text(l10n.feedbackTechnicalDetailsDisclosure), + shape: const RoundedRectangleBorder(), ), - onChanged: (_) => setState(_draftChanged), - ), + ], ), - YaruListTile.square( - title: TextField( - key: const Key('feedback-reply-email'), - controller: _replyEmailController, - enabled: !_submitting, - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.done, - decoration: InputDecoration( - labelText: l10n.feedbackReplyEmail, - errorText: replyEmailInvalid - ? l10n.feedbackInvalidEmail - : null, + if (_statusMessage case final status?) ...[ + const SizedBox(height: BusyMaxSpacing.md), + Semantics( + liveRegion: true, + child: Text( + status, + key: const Key('feedback-status'), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: _statusIsError + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.primary, + ), ), - onChanged: (_) => setState(_draftChanged), - onSubmitted: (_) { - if (!_submitting) { - _submit(); - } - }, ), - ), - ], - ), - BusyMaxGroupedList( - filled: true, - children: [ - CheckboxListTile( - key: const Key('feedback-technical-details'), - value: _includeTechnicalDetails, - onChanged: _submitting - ? null - : (value) { - setState(() { - _includeTechnicalDetails = value ?? false; - _draftChanged(); - }); - }, - title: Text(l10n.feedbackIncludeTechnicalDetails), - subtitle: Text(l10n.feedbackTechnicalDetailsDisclosure), - controlAffinity: ListTileControlAffinity.leading, - ), + ], + const SizedBox(height: BusyMaxSpacing.lg), ], ), - if (_statusMessage case final status?) ...[ - const SizedBox(height: BusyMaxSpacing.md), - Semantics( - liveRegion: true, - child: Text( - status, - key: const Key('feedback-status'), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: _statusIsError - ? Theme.of(context).colorScheme.error - : Theme.of(context).colorScheme.primary, - ), - ), - ), - ], - const SizedBox(height: BusyMaxSpacing.lg), - ], + ), ), ); } + Future _cancel() async { + if (_submitting || _confirmingCancel) { + return; + } + final hasDraft = + _category != null || + _subjectController.text.trim().isNotEmpty || + _messageController.text.trim().isNotEmpty || + _replyEmailController.text.trim().isNotEmpty || + _includeTechnicalDetails; + if (!hasDraft) { + widget.onCancel(); + return; + } + + _confirmingCancel = true; + try { + final discard = await showBusyMaxConfirm( + context, + title: context.l10n.discardChanges, + message: context.l10n.discardChangesConfirmation, + confirmLabel: context.l10n.discardChangesAction, + destructive: true, + barrierColor: Colors.transparent, + headerBarService: widget.headerBarService, + ); + if (discard && mounted) { + widget.onCancel(); + } + } finally { + _confirmingCancel = false; + } + } + Future _submit() async { if (_submitting) { return; diff --git a/lib/src/features/notifications/desktop_notification_service.dart b/lib/src/features/notifications/desktop_notification_service.dart index ce11d7a..f43deb5 100644 --- a/lib/src/features/notifications/desktop_notification_service.dart +++ b/lib/src/features/notifications/desktop_notification_service.dart @@ -3,8 +3,10 @@ import 'dart:ui'; import 'package:desktop_notifications/desktop_notifications.dart'; +import '../../../l10n/generated/app_localizations.dart'; import '../../app/app_settings.dart'; import '../../core/logging/redacting_logger.dart'; +import '../../l10n/locale_resolution.dart'; typedef DesktopNotificationActionHandler = Future Function(String action); @@ -64,9 +66,9 @@ class DesktopNotificationService { DateTime Function()? now, }) : _backend = backend, _settings = settings, - _strings = NotificationStrings.forLocale( - locale ?? PlatformDispatcher.instance.locale, - ), + _strings = locale == null + ? NotificationStrings.forLocales(PlatformDispatcher.instance.locales) + : NotificationStrings.forLocale(locale), _syncFailureDebounce = syncFailureDebounce, _now = now ?? DateTime.now; @@ -84,7 +86,7 @@ class DesktopNotificationService { return; } - final body = _settings.detailedNotifications + final body = _showsNotificationDetails ? _strings.syncFailureBody(redactForLog(message)) : _strings.syncFailureBody(_strings.detailsHidden); final now = _now(); @@ -106,7 +108,7 @@ class DesktopNotificationService { if (!_settings.notifyConflicts || _isQuietHours()) { return; } - final body = _settings.detailedNotifications + final body = _showsNotificationDetails ? _strings.conflictBody(redactForLog(summary)) : _strings.conflictBody(_strings.detailsHidden); await _safeNotify( @@ -167,10 +169,10 @@ class DesktopNotificationService { ); } - bool get _usesPrivateReminderText { - return !_settings.detailedNotifications && - _settings.notificationDetailLevel == NotificationDetailLevel.private; - } + bool get _showsNotificationDetails => + _settings.notificationDetailLevel == NotificationDetailLevel.normal; + + bool get _usesPrivateReminderText => !_showsNotificationDetails; Future _safeNotify( String summary, @@ -261,87 +263,35 @@ class NotificationStrings { }); factory NotificationStrings.forLocale(Locale locale) { - return switch (locale.languageCode) { - 'de' => NotificationStrings.german, - 'fr' => NotificationStrings.french, - 'es' => NotificationStrings.spanish, - _ => NotificationStrings.english, - }; + return NotificationStrings.forLocales([locale]); } - static final english = NotificationStrings( - syncFailureTitle: 'BusyMax sync failed', - conflictTitle: 'BusyMax sync conflict', - dueTodayTitle: 'Tasks due today', - eventReminderTitle: 'Event reminder', - taskReminderTitle: 'Task reminder', - detailsHidden: 'Details are hidden by privacy settings.', - eventReminderBody: 'Event starts soon.', - taskReminderBody: 'Task is due soon.', - openAction: 'Open', - syncFailureBody: (message) => 'Background sync failed. $message', - conflictBody: (summary) => 'A pending local change was blocked. $summary', - dueTodayBody: (count) => - count == 1 ? 'One task is due today.' : '$count tasks are due today.', - ); - - static final german = NotificationStrings( - syncFailureTitle: 'BusyMax-Synchronisierung fehlgeschlagen', - conflictTitle: 'BusyMax-Synchronisierungskonflikt', - dueTodayTitle: 'Heute fällige Aufgaben', - eventReminderTitle: 'Terminerinnerung', - taskReminderTitle: 'Aufgabenerinnerung', - detailsHidden: - 'Details werden durch Datenschutzeinstellungen ausgeblendet.', - eventReminderBody: 'Der Termin beginnt bald.', - taskReminderBody: 'Die Aufgabe ist bald fällig.', - openAction: 'Öffnen', - syncFailureBody: (message) => - 'Hintergrundsynchronisierung fehlgeschlagen. $message', - conflictBody: (summary) => - 'Eine ausstehende lokale Änderung wurde blockiert. $summary', - dueTodayBody: (count) => count == 1 - ? 'Eine Aufgabe ist heute fällig.' - : '$count Aufgaben sind heute fällig.', - ); - - static final french = NotificationStrings( - syncFailureTitle: 'Échec de la synchronisation BusyMax', - conflictTitle: 'Conflit de synchronisation BusyMax', - dueTodayTitle: 'Tâches dues aujourd’hui', - eventReminderTitle: 'Rappel d’événement', - taskReminderTitle: 'Rappel de tâche', - detailsHidden: - 'Les détails sont masqués par les paramètres de confidentialité.', - eventReminderBody: 'L’événement commence bientôt.', - taskReminderBody: 'La tâche arrive bientôt à échéance.', - openAction: 'Ouvrir', - syncFailureBody: (message) => - 'La synchronisation en arrière-plan a échoué. $message', - conflictBody: (summary) => - 'Une modification locale en attente a été bloquée. $summary', - dueTodayBody: (count) => count == 1 - ? 'Une tâche est due aujourd’hui.' - : '$count tâches sont dues aujourd’hui.', - ); + factory NotificationStrings.forLocales(List? locales) { + final supportedLocale = resolveBusyMaxLocales( + locales, + AppLocalizations.supportedLocales, + ); + return NotificationStrings.fromLocalizations( + lookupAppLocalizations(supportedLocale), + ); + } - static final spanish = NotificationStrings( - syncFailureTitle: 'Falló la sincronización de BusyMax', - conflictTitle: 'Conflicto de sincronización de BusyMax', - dueTodayTitle: 'Tareas que vencen hoy', - eventReminderTitle: 'Recordatorio de evento', - taskReminderTitle: 'Recordatorio de tarea', - detailsHidden: - 'Los detalles están ocultos por la configuración de privacidad.', - eventReminderBody: 'El evento empieza pronto.', - taskReminderBody: 'La tarea vence pronto.', - openAction: 'Abrir', - syncFailureBody: (message) => - 'Falló la sincronización en segundo plano. $message', - conflictBody: (summary) => 'Se bloqueó un cambio local pendiente. $summary', - dueTodayBody: (count) => - count == 1 ? 'Una tarea vence hoy.' : '$count tareas vencen hoy.', - ); + factory NotificationStrings.fromLocalizations(AppLocalizations l10n) { + return NotificationStrings( + syncFailureTitle: l10n.syncFailureNotificationTitle, + conflictTitle: l10n.conflictNotificationTitle, + dueTodayTitle: l10n.dueTodayNotificationTitle, + eventReminderTitle: l10n.eventReminderNotificationTitle, + taskReminderTitle: l10n.taskReminderNotificationTitle, + detailsHidden: l10n.notificationDetailsHidden, + eventReminderBody: l10n.eventReminderNotificationBody, + taskReminderBody: l10n.taskReminderNotificationBody, + openAction: l10n.notificationOpenAction, + syncFailureBody: l10n.syncFailureNotificationBody, + conflictBody: l10n.conflictNotificationBody, + dueTodayBody: l10n.dueTodayNotificationBody, + ); + } final String syncFailureTitle; final String conflictTitle; diff --git a/lib/src/features/schedule/application/compact_agenda_controller.dart b/lib/src/features/schedule/application/compact_agenda_controller.dart deleted file mode 100644 index 9dfd668..0000000 --- a/lib/src/features/schedule/application/compact_agenda_controller.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../app/app_bootstrap.dart'; -import '../../../platform/main_window_command_client.dart'; -import '../../../schedule/schedule_item.dart'; -import '../../calendar/data/calendar_repository.dart'; -import '../../calendar/presentation/event_editor_draft.dart'; -import '../../tasks/data/tasks_repository.dart'; -import 'compact_agenda_data.dart'; - -final compactAgendaControllerProvider = Provider(( - ref, -) { - return CompactAgendaController(ref); -}); - -class CompactAgendaController { - const CompactAgendaController(this._ref); - - final Ref _ref; - - Future createTask({ - required String accountId, - required String taskListId, - required TaskCreateInput input, - }) async { - final repository = TasksRepository( - database: _ref.read(databaseProvider), - accountId: accountId, - ); - await repository.createTask(taskListId, input); - - await _requestTaskSync(accountId); - - _ref.invalidate(compactAgendaDataProvider); - _ref.invalidate(compactAgendaDataForQueryProvider); - } - - Future saveEvent(EventEditorDraft draft) async { - final repository = CalendarRepository( - database: _ref.read(databaseProvider), - localTimeZone: _ref.read(localTimeZoneProvider), - ); - await repository.updateLocalEvent(draft); - await _requestCalendarSync(draft.accountId); - - _ref.invalidate(compactAgendaDataProvider); - _ref.invalidate(compactAgendaDataForQueryProvider); - } - - Future deleteEvent(String eventId) async { - final repository = CalendarRepository( - database: _ref.read(databaseProvider), - localTimeZone: _ref.read(localTimeZoneProvider), - ); - final accountId = await repository.deleteLocalEvent(eventId); - await _requestCalendarSync(accountId); - - _ref.invalidate(compactAgendaDataProvider); - _ref.invalidate(compactAgendaDataForQueryProvider); - } - - Future deleteTask(TaskScheduleItem item) async { - final repository = TasksRepository( - database: _ref.read(databaseProvider), - accountId: item.accountId, - ); - await repository.deleteTask(item.sourceId, item.id); - await _requestTaskSync(item.accountId); - - _ref.invalidate(compactAgendaDataProvider); - _ref.invalidate(compactAgendaDataForQueryProvider); - } - - Future taskMutated(String accountId) async { - await _requestTaskSync(accountId); - _ref.invalidate(compactAgendaDataProvider); - _ref.invalidate(compactAgendaDataForQueryProvider); - } - - Future setTaskCompleted(TaskScheduleItem item, bool completed) async { - final fields = { - 'status': completed ? 'completed' : 'needsAction', - 'completed': completed ? DateTime.now().toUtc().toIso8601String() : null, - }; - - final repository = TasksRepository( - database: _ref.read(databaseProvider), - accountId: item.accountId, - ); - await repository.patchTask(item.sourceId, item.id, TaskPatchInput(fields)); - - await _requestTaskSync(item.accountId); - - _ref.invalidate(compactAgendaDataProvider); - _ref.invalidate(compactAgendaDataForQueryProvider); - } - - Future _requestTaskSync(String accountId) async { - try { - await const MainWindowCommandClient().requestTaskSync(accountId); - } on Object { - // The pending operation remains queued and will sync when the main engine - // is available. - } - } - - Future _requestCalendarSync(String accountId) async { - try { - await const MainWindowCommandClient().requestCalendarSync(accountId); - } on Object { - // The pending operation remains queued and will sync when the main engine - // is available. - } - } -} - -String compactAgendaTaskMutationKey(TaskScheduleItem item) { - return '${item.accountId}:${item.sourceId}:${item.id}'; -} diff --git a/lib/src/features/schedule/application/compact_agenda_data.dart b/lib/src/features/schedule/application/compact_agenda_data.dart deleted file mode 100644 index 5238f4d..0000000 --- a/lib/src/features/schedule/application/compact_agenda_data.dart +++ /dev/null @@ -1,250 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../app/app_bootstrap.dart'; -import '../../../schedule/schedule_filters.dart'; -import '../../../schedule/schedule_item.dart'; -import '../../../schedule/schedule_range.dart'; -import '../../../schedule/schedule_sorting.dart'; -import '../../../schedule/schedule_source_visibility.dart'; -import '../../task_lists/data/task_lists_repository.dart'; -import 'compact_agenda_sections.dart'; - -const compactAgendaInitialDays = 30; -const compactAgendaPageDays = 30; -const compactAgendaSqliteBusyRetryDelays = [ - Duration(milliseconds: 120), - Duration(milliseconds: 240), - Duration(milliseconds: 480), -]; - -typedef CompactAgendaDataLoader = - Future Function(Ref ref, CompactAgendaQuery query); -typedef CompactAgendaRetryDelay = Future Function(Duration duration); - -final compactAgendaDataLoaderProvider = Provider( - (ref) => loadCompactAgendaDataFromRepositories, -); - -final compactAgendaDataProvider = FutureProvider.autoDispose( - (ref) { - return ref.watch( - compactAgendaDataForQueryProvider(CompactAgendaQuery.initial).future, - ); - }, -); - -final compactAgendaDataForQueryProvider = FutureProvider.autoDispose - .family((ref, query) { - return loadCompactAgendaDataWithRetry(ref, query); - }); - -Future loadCompactAgendaDataWithRetry( - Ref ref, - CompactAgendaQuery query, { - CompactAgendaDataLoader? loader, - List retryDelays = compactAgendaSqliteBusyRetryDelays, - CompactAgendaRetryDelay delay = _compactAgendaDelay, -}) async { - final CompactAgendaDataLoader load = - loader ?? ref.read(compactAgendaDataLoaderProvider); - for (var attempt = 0; ; attempt += 1) { - try { - return await load(ref, query); - } on Object catch (error) { - if (!_isSqliteBusy(error) || attempt >= retryDelays.length) { - rethrow; - } - await delay(retryDelays[attempt]); - } - } -} - -Future loadCompactAgendaDataFromRepositories( - Ref ref, - CompactAgendaQuery query, -) async { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final days = query.futureDays < 1 - ? compactAgendaInitialDays - : query.futureDays; - final end = today.add(Duration(days: days)); - final range = ScheduleRange(start: today, end: end); - - CompactAgendaData empty({ - required bool hasSignedInAccounts, - required bool hasSources, - }) { - return CompactAgendaData( - today: today, - range: range, - items: const [], - hasMoreOverdueTasks: false, - hasMoreNoDateTasks: false, - hasSignedInAccounts: hasSignedInAccounts, - hasSources: hasSources, - generatedAt: now, - ); - } - - final accounts = await ref - .read(accountsRepositoryProvider) - .listSignedInAccounts(); - if (accounts.isEmpty) { - return empty(hasSignedInAccounts: false, hasSources: false); - } - - final accountIds = accounts.map((account) => account.id).toSet(); - final calendarSources = await ref - .read(calendarRepositoryProvider) - .listVisibleSources(accountIds.toList()); - final taskLists = []; - for (final account in accounts) { - taskLists.addAll( - await ref - .read(taskListsRepositoryForAccountProvider(account.id)) - .listTaskLists(), - ); - } - - final visibility = ScheduleSourceVisibility.fromSources( - calendarSources: calendarSources, - taskLists: taskLists, - settings: ref.read(appSettingsControllerProvider), - ); - final hasSources = - visibility.visibleCalendarSourceIds.isNotEmpty || - visibility.visibleTaskListIds.isNotEmpty; - if (!hasSources) { - return empty(hasSignedInAccounts: true, hasSources: false); - } - - final repository = ref.read(scheduleRepositoryProvider); - final datedItemsFuture = repository.listItems( - range: range, - filters: ScheduleFilters( - accountIds: accountIds, - sourceIds: visibility.visibleCalendarSourceIds, - taskListIds: visibility.visibleTaskListIds, - sourceFilterActive: true, - taskListFilterActive: true, - includeCalendarEvents: true, - includeTasks: true, - showCompletedTasks: false, - showNoDateTasks: false, - ), - ); - final overdueTasksFuture = repository.listOverdueTasks( - before: today, - limit: query.overdueLimit, - filters: ScheduleFilters( - accountIds: accountIds, - taskListIds: visibility.visibleTaskListIds, - taskListFilterActive: true, - includeTasks: true, - showCompletedTasks: false, - ), - ); - final noDateTasksFuture = repository.listNoDateTasks( - limit: query.noDateLimit, - filters: ScheduleFilters( - accountIds: accountIds, - taskListIds: visibility.visibleTaskListIds, - taskListFilterActive: true, - includeTasks: true, - showCompletedTasks: false, - ), - ); - final datedItems = await datedItemsFuture; - final overdueTasks = await overdueTasksFuture; - final noDateTasks = await noDateTasksFuture; - final rawItems = [...datedItems, ...overdueTasks.items, ...noDateTasks.items]; - - final items = rawItems.where((item) { - final start = item.start; - if (start == null) { - return item is TaskScheduleItem && !item.completed; - } - if (item is CalendarScheduleItem) { - return !start.isBefore(today) && start.isBefore(end); - } - if (item is TaskScheduleItem) { - return !item.completed && start.isBefore(end); - } - return false; - }).toList()..sort(compareScheduleItems); - - return CompactAgendaData( - today: today, - range: range, - items: items, - hasMoreOverdueTasks: overdueTasks.hasMore, - hasMoreNoDateTasks: noDateTasks.hasMore, - hasSignedInAccounts: true, - hasSources: true, - generatedAt: now, - ); -} - -class CompactAgendaData { - const CompactAgendaData({ - required this.today, - required this.range, - required this.items, - required this.hasMoreOverdueTasks, - required this.hasMoreNoDateTasks, - required this.hasSignedInAccounts, - required this.hasSources, - required this.generatedAt, - }); - - final DateTime today; - final ScheduleRange range; - final List items; - final bool hasMoreOverdueTasks; - final bool hasMoreNoDateTasks; - final bool hasSignedInAccounts; - final bool hasSources; - final DateTime generatedAt; -} - -class CompactAgendaQuery { - const CompactAgendaQuery({ - required this.futureDays, - required this.overdueLimit, - required this.noDateLimit, - }); - - static const initial = CompactAgendaQuery( - futureDays: compactAgendaInitialDays, - overdueLimit: compactAgendaInitialOverdueLimit, - noDateLimit: compactAgendaInitialNoDateLimit, - ); - - final int futureDays; - final int overdueLimit; - final int noDateLimit; - - @override - bool operator ==(Object other) { - return other is CompactAgendaQuery && - other.futureDays == futureDays && - other.overdueLimit == overdueLimit && - other.noDateLimit == noDateLimit; - } - - @override - int get hashCode => Object.hash(futureDays, overdueLimit, noDateLimit); -} - -Future _compactAgendaDelay(Duration duration) { - return Future.delayed(duration); -} - -bool _isSqliteBusy(Object error) { - final message = error.toString().toLowerCase(); - return message.contains('database is locked') || - message.contains('sqlite_busy') || - message.contains('sqlite exception(5)') || - message.contains('sqliteexception(5)'); -} diff --git a/lib/src/features/schedule/application/compact_agenda_sections.dart b/lib/src/features/schedule/application/compact_agenda_sections.dart deleted file mode 100644 index ead417d..0000000 --- a/lib/src/features/schedule/application/compact_agenda_sections.dart +++ /dev/null @@ -1,105 +0,0 @@ -import '../../../schedule/schedule_item.dart'; -import '../../../schedule/schedule_projection.dart'; -import '../../../schedule/schedule_sorting.dart'; - -const compactAgendaInitialOverdueLimit = 8; -const compactAgendaOverduePageSize = 8; -const compactAgendaInitialNoDateLimit = 8; -const compactAgendaNoDatePageSize = 8; - -enum CompactAgendaSectionKind { overdue, day, noDate } - -class CompactAgendaSection { - const CompactAgendaSection({ - required this.kind, - required this.items, - this.day, - this.hasMore = false, - }); - - final CompactAgendaSectionKind kind; - final DateTime? day; - final List items; - final bool hasMore; -} - -List buildCompactAgendaSections({ - required DateTime today, - required List items, - DateTime? end, - int overdueLimit = compactAgendaInitialOverdueLimit, - int noDateLimit = compactAgendaInitialNoDateLimit, - bool hasMoreOverdueTasks = false, - bool hasMoreNoDateTasks = false, -}) { - final rangeEnd = end == null ? null : ScheduleProjection.day(end); - final visibleOverdueLimit = overdueLimit < 1 - ? compactAgendaInitialOverdueLimit - : overdueLimit; - final visibleNoDateLimit = noDateLimit < 1 - ? compactAgendaInitialNoDateLimit - : noDateLimit; - final overdueTasks = items.whereType().where((item) { - final start = item.start; - return start != null && - !item.completed && - ScheduleProjection.day(start).isBefore(today); - }).toList()..sort(compareScheduleItems); - - final sections = []; - if (overdueTasks.isNotEmpty) { - sections.add( - CompactAgendaSection( - kind: CompactAgendaSectionKind.overdue, - items: overdueTasks.take(visibleOverdueLimit).toList(), - hasMore: - hasMoreOverdueTasks || overdueTasks.length > visibleOverdueLimit, - ), - ); - } - - final grouped = >{}; - final noDateTasks = []; - for (final item in items) { - if (item is TaskScheduleItem && item.completed) { - continue; - } - final start = item.start; - if (start == null) { - if (item is TaskScheduleItem) { - noDateTasks.add(item); - } - continue; - } - final day = ScheduleProjection.day(start); - if (day.isBefore(today) || (rangeEnd != null && !day.isBefore(rangeEnd))) { - continue; - } - grouped.putIfAbsent(day, () => []).add(item); - } - - final days = grouped.keys.toList()..sort(); - if (noDateTasks.isNotEmpty) { - noDateTasks.sort(compareScheduleItems); - sections.add( - CompactAgendaSection( - kind: CompactAgendaSectionKind.noDate, - items: noDateTasks.take(visibleNoDateLimit).toList(), - hasMore: hasMoreNoDateTasks || noDateTasks.length > visibleNoDateLimit, - ), - ); - } - - for (final day in days) { - final dayItems = grouped[day]!..sort(compareScheduleItems); - sections.add( - CompactAgendaSection( - kind: CompactAgendaSectionKind.day, - day: day, - items: dayItems, - ), - ); - } - - return sections; -} diff --git a/lib/src/features/schedule/application/compact_agenda_snapshot.dart b/lib/src/features/schedule/application/compact_agenda_snapshot.dart deleted file mode 100644 index b451235..0000000 --- a/lib/src/features/schedule/application/compact_agenda_snapshot.dart +++ /dev/null @@ -1,290 +0,0 @@ -import '../../../schedule/schedule_item.dart'; -import '../../../schedule/schedule_range.dart'; -import '../../../task_providers/task_provider.dart'; -import 'compact_agenda_data.dart'; -import 'compact_agenda_sections.dart'; - -Map encodeCompactAgendaQuery(CompactAgendaQuery query) { - return { - 'futureDays': query.futureDays, - 'overdueLimit': query.overdueLimit, - 'noDateLimit': query.noDateLimit, - }; -} - -CompactAgendaQuery decodeCompactAgendaQuery(Object? raw) { - if (raw is! Map) { - return CompactAgendaQuery.initial; - } - final map = raw.cast(); - return CompactAgendaQuery( - futureDays: _intValue(map, 'futureDays', compactAgendaInitialDays), - overdueLimit: _intValue( - map, - 'overdueLimit', - compactAgendaInitialOverdueLimit, - ), - noDateLimit: _intValue(map, 'noDateLimit', compactAgendaInitialNoDateLimit), - ); -} - -Map encodeCompactAgendaData(CompactAgendaData data) { - return { - 'today': data.today.toIso8601String(), - 'rangeStart': data.range.start.toIso8601String(), - 'rangeEnd': data.range.end.toIso8601String(), - 'items': data.items.map(encodeScheduleItem).toList(), - 'hasMoreOverdueTasks': data.hasMoreOverdueTasks, - 'hasMoreNoDateTasks': data.hasMoreNoDateTasks, - 'hasSignedInAccounts': data.hasSignedInAccounts, - 'hasSources': data.hasSources, - 'generatedAt': data.generatedAt.toIso8601String(), - }; -} - -CompactAgendaData decodeCompactAgendaData(Object? raw) { - final map = _mapValue(raw); - return CompactAgendaData( - today: _requiredDateTime(map, 'today'), - range: ScheduleRange( - start: _requiredDateTime(map, 'rangeStart'), - end: _requiredDateTime(map, 'rangeEnd'), - ), - items: _listValue(map['items']).map(decodeScheduleItem).toList(), - hasMoreOverdueTasks: _boolValue(map, 'hasMoreOverdueTasks'), - hasMoreNoDateTasks: _boolValue(map, 'hasMoreNoDateTasks'), - hasSignedInAccounts: _boolValue(map, 'hasSignedInAccounts'), - hasSources: _boolValue(map, 'hasSources'), - generatedAt: _requiredDateTime(map, 'generatedAt'), - ); -} - -Map encodeScheduleItem(ScheduleItem item) { - final common = { - 'kind': item is TaskScheduleItem ? 'task' : 'calendarEvent', - 'id': item.id, - 'accountId': item.accountId, - 'provider': item.provider.storageValue, - 'sourceId': item.sourceId, - 'title': item.title, - 'sourceName': item.sourceName, - 'accountDisplayName': item.accountDisplayName, - 'accountEmail': item.accountEmail, - 'start': item.start?.toIso8601String(), - 'end': item.end?.toIso8601String(), - 'allDay': item.allDay, - 'categories': item.categories, - }; - if (item is TaskScheduleItem) { - return { - ...common, - 'completed': item.completed, - 'notes': item.notes, - 'reminder': item.reminder?.toIso8601String(), - }; - } - final event = item as CalendarScheduleItem; - return { - ...common, - 'providerCalendarId': event.providerCalendarId, - 'providerRecurringEventId': event.providerRecurringEventId, - 'editorStart': event.editorStart?.toIso8601String(), - 'editorEnd': event.editorEnd?.toIso8601String(), - 'startTimeZone': event.startTimeZone, - 'endTimeZone': event.endTimeZone, - 'location': event.location, - 'description': event.description, - 'descriptionContentType': event.descriptionContentType, - 'descriptionHtml': event.descriptionHtml, - 'recurrence': event.recurrence, - 'attendees': event.attendees, - 'colorHex': event.colorHex, - 'reminderMinutesBeforeStart': event.reminderMinutesBeforeStart, - }; -} - -ScheduleItem decodeScheduleItem(Object? raw) { - final map = _mapValue(raw); - final kind = _requiredString(map, 'kind'); - final provider = TaskProviderParsing.fromStorageValue( - _optionalString(map, 'provider'), - ); - final common = _ScheduleItemCommon( - id: _requiredString(map, 'id'), - accountId: _requiredString(map, 'accountId'), - provider: provider, - sourceId: _requiredString(map, 'sourceId'), - title: _requiredString(map, 'title'), - sourceName: _optionalString(map, 'sourceName'), - accountDisplayName: _optionalString(map, 'accountDisplayName'), - accountEmail: _optionalString(map, 'accountEmail'), - start: _optionalDateTime(map, 'start'), - end: _optionalDateTime(map, 'end'), - allDay: _boolValue(map, 'allDay'), - categories: _stringListValue(map['categories']), - ); - if (kind == 'task') { - return TaskScheduleItem( - id: common.id, - accountId: common.accountId, - provider: common.provider, - sourceId: common.sourceId, - title: common.title, - completed: _boolValue(map, 'completed'), - allDay: common.allDay, - start: common.start, - end: common.end, - notes: _optionalString(map, 'notes'), - categories: common.categories, - reminder: _optionalDateTime(map, 'reminder'), - sourceName: common.sourceName, - accountDisplayName: common.accountDisplayName, - accountEmail: common.accountEmail, - ); - } - if (kind != 'calendarEvent') { - throw FormatException('Unsupported compact agenda item kind $kind.'); - } - return CalendarScheduleItem( - id: common.id, - accountId: common.accountId, - provider: common.provider, - sourceId: common.sourceId, - providerCalendarId: - _optionalString(map, 'providerCalendarId') ?? common.sourceId, - providerRecurringEventId: _optionalString(map, 'providerRecurringEventId'), - title: common.title, - allDay: common.allDay, - start: common.start, - end: common.end, - editorStart: _optionalDateTime(map, 'editorStart'), - editorEnd: _optionalDateTime(map, 'editorEnd'), - startTimeZone: _optionalString(map, 'startTimeZone'), - endTimeZone: _optionalString(map, 'endTimeZone'), - location: _optionalString(map, 'location'), - description: _optionalString(map, 'description'), - descriptionContentType: _optionalString(map, 'descriptionContentType'), - descriptionHtml: _optionalString(map, 'descriptionHtml'), - recurrence: map['recurrence'], - attendees: _mapListValue(map['attendees']), - colorHex: _optionalString(map, 'colorHex'), - categories: common.categories, - reminderMinutesBeforeStart: _intListValue( - map['reminderMinutesBeforeStart'], - ), - sourceName: common.sourceName, - accountDisplayName: common.accountDisplayName, - accountEmail: common.accountEmail, - ); -} - -class _ScheduleItemCommon { - const _ScheduleItemCommon({ - required this.id, - required this.accountId, - required this.provider, - required this.sourceId, - required this.title, - required this.sourceName, - required this.accountDisplayName, - required this.accountEmail, - required this.start, - required this.end, - required this.allDay, - required this.categories, - }); - - final String id; - final String accountId; - final TaskProvider provider; - final String sourceId; - final String title; - final String? sourceName; - final String? accountDisplayName; - final String? accountEmail; - final DateTime? start; - final DateTime? end; - final bool allDay; - final List categories; -} - -Map _mapValue(Object? value) { - if (value is! Map) { - throw const FormatException('Compact agenda snapshot is not a map.'); - } - return value.cast(); -} - -List _listValue(Object? value) { - if (value is! List) { - return const []; - } - return value.cast(); -} - -List _stringListValue(Object? value) { - return _listValue(value).map((item) => item.toString()).toList(); -} - -List _intListValue(Object? value) { - return _listValue(value) - .map((item) => item is int ? item : int.tryParse(item.toString())) - .nonNulls - .toList(); -} - -List> _mapListValue(Object? value) { - return [ - for (final item in _listValue(value)) - if (item is Map) Map.from(item), - ]; -} - -String _requiredString(Map map, String key) { - final value = map[key]; - if (value == null) { - throw FormatException('Compact agenda snapshot missing $key.'); - } - return value.toString(); -} - -String? _optionalString(Map map, String key) { - final value = map[key]; - if (value == null) { - return null; - } - final text = value.toString(); - return text.isEmpty ? null : text; -} - -DateTime _requiredDateTime(Map map, String key) { - final value = _optionalDateTime(map, key); - if (value == null) { - throw FormatException('Compact agenda snapshot missing $key.'); - } - return value; -} - -DateTime? _optionalDateTime(Map map, String key) { - final value = map[key]; - if (value == null) { - return null; - } - return DateTime.tryParse(value.toString()); -} - -bool _boolValue(Map map, String key) { - final value = map[key]; - if (value is bool) { - return value; - } - return value?.toString() == 'true'; -} - -int _intValue(Map map, String key, int fallback) { - final value = map[key]; - if (value is int) { - return value; - } - return int.tryParse(value?.toString() ?? '') ?? fallback; -} diff --git a/lib/src/features/schedule/presentation/calendar_day_semantics.dart b/lib/src/features/schedule/presentation/calendar_day_semantics.dart new file mode 100644 index 0000000..13f71c5 --- /dev/null +++ b/lib/src/features/schedule/presentation/calendar_day_semantics.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// Gives every interactive calendar day one consistent desktop accessibility +/// contract, regardless of the visual calendar that renders it. +class BusyMaxCalendarDaySemantics extends StatelessWidget { + const BusyMaxCalendarDaySemantics({ + super.key, + required this.day, + required this.selected, + required this.onTap, + required this.child, + }); + + final DateTime day; + final bool selected; + final VoidCallback onTap; + final Widget child; + + @override + Widget build(BuildContext context) { + final locale = Localizations.localeOf(context).toLanguageTag(); + final label = DateFormat.yMMMMEEEEd(locale).format(day); + + return Semantics( + container: true, + button: true, + selected: selected, + label: label, + onTap: onTap, + child: Tooltip( + key: ValueKey((day.year, day.month, day.day)), + message: label, + excludeFromSemantics: true, + child: child, + ), + ); + } +} diff --git a/lib/src/features/schedule/presentation/compact_agenda_app.dart b/lib/src/features/schedule/presentation/compact_agenda_app.dart deleted file mode 100644 index f53a056..0000000 --- a/lib/src/features/schedule/presentation/compact_agenda_app.dart +++ /dev/null @@ -1,324 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:desktop_multi_window/desktop_multi_window.dart'; -import 'package:busymax/l10n/generated/app_localizations.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logging/logging.dart'; -import 'package:system_theme/system_theme.dart'; -import 'package:ubuntu_localizations/ubuntu_localizations.dart'; -import 'package:window_manager/window_manager.dart'; - -import '../../../app/app_bootstrap.dart'; -import '../../../app/app_theme.dart'; -import '../../../app/busymax_design.dart'; -import '../../../app/system_accent.dart'; -import '../../../platform/gtk_font_service.dart'; -import '../../../platform/busymax_window_args.dart'; -import '../application/compact_agenda_data.dart'; -import 'compact_agenda_panel.dart'; - -const _compactAgendaPanelWidth = 420.0; -const _compactAgendaPanelHeight = 680.0; -const _compactAgendaWindowSize = Size( - _compactAgendaPanelWidth + BusyMaxShadow.windowMargin * 2, - _compactAgendaPanelHeight + BusyMaxShadow.windowMargin * 2, -); -const _compactAgendaWindowChannel = MethodChannel( - 'io.busystack.busymax/compact_agenda_window', -); -final _compactAgendaWindowLogger = Logger('BusyMaxCompactAgendaWindow'); - -class BusyMaxCompactAgendaApp extends ConsumerStatefulWidget { - const BusyMaxCompactAgendaApp({ - required this.windowController, - required this.windowArgs, - super.key, - }); - - final WindowController windowController; - final BusyMaxWindowArgs windowArgs; - - @override - ConsumerState createState() => - _BusyMaxCompactAgendaAppState(); -} - -class _BusyMaxCompactAgendaAppState - extends ConsumerState - with WindowListener { - @override - void initState() { - super.initState(); - unawaited( - widget.windowController.setWindowMethodHandler(_handleWindowMethodCall), - ); - windowManager.addListener(this); - unawaited(windowManager.setPreventClose(true)); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - unawaited(_show()); - } - }); - } - - @override - void dispose() { - unawaited(_clearWindowMethodHandler()); - windowManager.removeListener(this); - super.dispose(); - } - - Future _handleWindowMethodCall(MethodCall call) async { - switch (call.method) { - case 'busymax.compactAgenda.show': - await _show(call.arguments); - return true; - case 'busymax.compactAgenda.hide': - await widget.windowController.hide(); - return true; - case 'busymax.compactAgenda.toggle': - final visible = await windowManager.isVisible(); - final focused = await _isFocused(); - if (visible && focused) { - await widget.windowController.hide(); - } else { - await _show(call.arguments); - } - return true; - case 'busymax.compactAgenda.refresh': - ref.invalidate(compactAgendaDataProvider); - ref.invalidate(compactAgendaDataForQueryProvider); - return true; - case 'busymax.compactAgenda.destroy': - unawaited(_destroyWindow()); - return true; - } - - throw MissingPluginException('Not implemented: ${call.method}'); - } - - Future _destroyWindow() async { - await _clearWindowMethodHandler(); - try { - await windowManager.setPreventClose(false); - } on Object { - // The native window can already be gone during app shutdown. - } - try { - await windowManager.destroy(); - } on Object { - // Ignore stale secondary-window removal during main-process shutdown. - } - } - - Future _clearWindowMethodHandler() async { - try { - await widget.windowController.setWindowMethodHandler(null); - } on Object { - // The compact engine may already be unregistering during app shutdown. - } - } - - Future _show([Object? rawArgs]) async { - final position = _requestedPosition(rawArgs) ?? _initialRequestedPosition(); - _logPositioning('show requested', position); - final shownNatively = await _showNativeWindow(position); - if (!shownNatively) { - await _moveNearTrayArea(position); - await widget.windowController.show(); - unawaited(_focusNearTrayArea()); - } - ref.invalidate(compactAgendaDataProvider); - ref.invalidate(compactAgendaDataForQueryProvider); - } - - Future _showNativeWindow(Offset? position) async { - const attempts = 8; - const retryDelay = Duration(milliseconds: 60); - for (var attempt = 0; attempt < attempts; attempt += 1) { - try { - final result = await _compactAgendaWindowChannel.invokeMethod( - 'show', - _nativeWindowArguments(position), - ); - final succeeded = result ?? false; - _logPositioning( - 'native show completed native_position_succeeded=$succeeded', - position, - ); - return succeeded; - } on MissingPluginException { - if (attempt == attempts - 1) { - _logPositioning('native show unavailable', position, warning: true); - return false; - } - } on Object catch (error) { - if (attempt == attempts - 1) { - _logPositioning( - 'native show failed error=$error', - position, - warning: true, - ); - return false; - } - } - await Future.delayed(retryDelay); - } - return false; - } - - Future _moveNearTrayArea(Offset? requestedPosition) async { - try { - final position = requestedPosition ?? _initialRequestedPosition(); - if (position == null) { - await windowManager.setSize(_compactAgendaWindowSize); - await windowManager.setAlignment(Alignment.topRight); - _logPositioning('window_manager fallback aligned topRight', null); - return; - } - await windowManager.setBounds( - null, - position: position, - size: _compactAgendaWindowSize, - ); - _logPositioning('window_manager fallback setBounds succeeded', position); - } on Object catch (error) { - _logPositioning( - 'window_manager fallback failed error=$error', - requestedPosition, - warning: true, - ); - // Positioning is best-effort, especially on Wayland. - } - } - - Future _focusNearTrayArea() async { - try { - await windowManager.focus(); - } on Object { - // Positioning is best-effort, especially on Wayland. - } - } - - Offset? _initialRequestedPosition() { - final x = widget.windowArgs.requestedPositionX; - final y = widget.windowArgs.requestedPositionY; - if (x == null || y == null) { - return null; - } - return Offset(x, y); - } - - Offset? _requestedPosition(Object? rawArgs) { - if (rawArgs is! Map) { - return null; - } - final position = rawArgs['position']; - if (position is! Map) { - return null; - } - final x = position['x']; - final y = position['y']; - if (x is! num || y is! num) { - return null; - } - final dx = x.toDouble(); - final dy = y.toDouble(); - if (!dx.isFinite || !dy.isFinite) { - return null; - } - return Offset(dx, dy); - } - - Map _nativeWindowArguments(Offset? position) { - return { - if (position != null) ...{'x': position.dx, 'y': position.dy}, - 'width': _compactAgendaWindowSize.width, - 'height': _compactAgendaWindowSize.height, - }; - } - - void _logPositioning(String event, Offset? position, {bool warning = false}) { - final requested = position == null - ? 'requested_x= requested_y=' - : 'requested_x=${position.dx.round()} requested_y=${position.dy.round()}'; - final session = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - final backend = Platform.environment['GDK_BACKEND'] ?? ''; - final message = - 'Compact agenda positioning: event="$event" $requested ' - 'final_width=${_compactAgendaWindowSize.width.round()} ' - 'final_height=${_compactAgendaWindowSize.height.round()} ' - 'session=$session gdk_backend=$backend'; - if (warning) { - _compactAgendaWindowLogger.warning(message); - } else { - _compactAgendaWindowLogger.fine(message); - } - } - - Future _isFocused() async { - try { - return await windowManager.isFocused(); - } on Object { - return false; - } - } - - @override - void onWindowClose() { - unawaited(widget.windowController.hide()); - } - - @override - Widget build(BuildContext context) { - final settings = ref.watch(appSettingsControllerProvider); - final ubuntuAccentColor = ref - .watch(ubuntuSystemAccentColorProvider) - .valueOrNull; - final gtkFont = ref.watch(gtkFontSettingsProvider).valueOrNull; - final gtkThemeColors = ref.watch(gtkThemeColorsProvider).valueOrNull; - - return SystemThemeBuilder( - builder: (context, systemColor) { - final accentColor = - ubuntuAccentColor ?? gtkThemeColors?.accent ?? systemColor.accent; - return MaterialApp( - title: 'BusyMax Agenda', - debugShowCheckedModeBanner: false, - theme: buildBusyMaxTheme( - brightness: Brightness.light, - accentColor: accentColor, - family: settings.themeFamily, - gtkFontFamily: gtkFont?.family, - gtkFontSize: gtkFont?.size, - gtkThemeColors: gtkThemeColors, - ), - darkTheme: buildBusyMaxTheme( - brightness: Brightness.dark, - accentColor: accentColor, - family: settings.themeFamily, - gtkFontFamily: gtkFont?.family, - gtkFontSize: gtkFont?.size, - gtkThemeColors: gtkThemeColors, - ), - themeMode: settings.themeMode, - localizationsDelegates: const [ - ...AppLocalizations.localizationsDelegates, - ...GlobalUbuntuLocalizations.delegates, - ], - supportedLocales: AppLocalizations.supportedLocales, - home: const Scaffold( - backgroundColor: Colors.transparent, - body: Padding( - padding: EdgeInsets.all(BusyMaxShadow.windowMargin), - child: CompactAgendaPanel(), - ), - ), - ); - }, - ); - } -} diff --git a/lib/src/features/schedule/presentation/compact_agenda_formatting.dart b/lib/src/features/schedule/presentation/compact_agenda_formatting.dart deleted file mode 100644 index 6e7adb8..0000000 --- a/lib/src/features/schedule/presentation/compact_agenda_formatting.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; - -import '../../../l10n/l10n.dart'; -import '../../../schedule/schedule_item.dart'; -import '../../../schedule/schedule_projection.dart'; - -String compactAgendaDayLabel( - BuildContext context, { - required DateTime today, - required DateTime day, -}) { - final normalizedToday = ScheduleProjection.day(today); - final normalizedDay = ScheduleProjection.day(day); - if (normalizedDay == normalizedToday) { - return context.l10n.today; - } - if (normalizedDay == normalizedToday.add(const Duration(days: 1))) { - return context.l10n.tomorrow; - } - final locale = Localizations.localeOf(context).toString(); - return DateFormat('EEE, MMM d', locale).format(normalizedDay); -} - -String compactAgendaTodaySubtitle(BuildContext context, DateTime today) { - final locale = Localizations.localeOf(context).toString(); - return '${context.l10n.today} - ${DateFormat.MMMd(locale).format(today)}'; -} - -String compactAgendaItemMeta( - BuildContext context, - ScheduleItem item, { - required DateTime today, -}) { - if (item is TaskScheduleItem) { - return _taskDueLabel(context, item, today: today); - } - return _eventTimeLabel(context, item); -} - -String _eventTimeLabel(BuildContext context, ScheduleItem item) { - if (item.allDay) { - return context.l10n.compactAgendaAllDay; - } - final start = item.start; - if (start == null) { - return ''; - } - final end = item.end; - final startText = _formatTime(context, start); - if (end == null || - !end.isAfter(start) || - ScheduleProjection.day(end) != ScheduleProjection.day(start)) { - return startText; - } - return '$startText-${_formatTime(context, end)}'; -} - -String _taskDueLabel( - BuildContext context, - TaskScheduleItem item, { - required DateTime today, -}) { - final start = item.start; - if (start == null) { - return ''; - } - final day = ScheduleProjection.day(start); - final normalizedToday = ScheduleProjection.day(today); - if (day == normalizedToday) { - return item.allDay - ? context.l10n.compactAgendaDueToday - : context.l10n.compactAgendaDueOn(_formatTime(context, start)); - } - if (day == normalizedToday.add(const Duration(days: 1))) { - return context.l10n.compactAgendaDueTomorrow; - } - if (day == normalizedToday.subtract(const Duration(days: 1))) { - return context.l10n.compactAgendaDueOn( - DateFormat.E(Localizations.localeOf(context).toString()).format(day), - ); - } - return context.l10n.compactAgendaDueOn( - DateFormat.MMMd(Localizations.localeOf(context).toString()).format(day), - ); -} - -String _formatTime(BuildContext context, DateTime value) { - return MaterialLocalizations.of(context).formatTimeOfDay( - TimeOfDay.fromDateTime(value), - alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), - ); -} diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart deleted file mode 100644 index a7ced03..0000000 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ /dev/null @@ -1,1507 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:window_manager/window_manager.dart'; -import 'package:yaru/yaru.dart'; - -import '../../../app/app_bootstrap.dart'; -import '../../../app/busymax_dialogs.dart'; -import '../../../app/busymax_design.dart'; -import '../../../app/busymax_yaru_theme.dart'; -import '../../../core/logging/redacting_logger.dart'; -import '../../../l10n/l10n.dart'; -import '../../../platform/main_window_command_client.dart'; -import '../../../schedule/schedule_item.dart'; -import '../../../schedule/schedule_projection.dart'; -import '../../../task_providers/task_provider.dart'; -import '../../calendar/data/calendar_repository.dart'; -import '../../calendar/presentation/event_editor.dart'; -import '../../calendar/presentation/event_editor_draft.dart'; -import '../../task_lists/data/task_lists_repository.dart'; -import '../../tasks/data/tasks_repository.dart'; -import '../../tasks/presentation/new_task_dialog.dart'; -import '../../tasks/presentation/task_details_pane.dart'; -import '../application/compact_agenda_controller.dart'; -import '../application/compact_agenda_data.dart'; -import '../application/compact_agenda_sections.dart'; -import 'compact_agenda_formatting.dart'; -import 'schedule_item_details_popover.dart'; -import 'schedule_item_exporter.dart'; - -typedef CompactAgendaTaskCompletionCallback = - Future Function(TaskScheduleItem item, bool completed); - -const _compactAgendaMinimumLayoutSize = Size(320, 480); - -class CompactAgendaPanel extends ConsumerStatefulWidget { - const CompactAgendaPanel({ - super.key, - this.data, - this.onOpenBusyMax, - this.onNewTask, - this.onRefresh, - this.onHide, - this.onOpenItem, - this.onTaskCompletionChanged, - }); - - final AsyncValue? data; - final Future Function()? onOpenBusyMax; - final Future Function()? onNewTask; - final Future Function()? onRefresh; - final Future Function()? onHide; - final Future Function(ScheduleItem item)? onOpenItem; - final CompactAgendaTaskCompletionCallback? onTaskCompletionChanged; - - @override - ConsumerState createState() => _CompactAgendaPanelState(); -} - -class _CompactAgendaPanelState extends ConsumerState { - final _mutatingTaskKeys = {}; - var _loadedDays = compactAgendaInitialDays; - var _overdueLimit = compactAgendaInitialOverdueLimit; - var _noDateLimit = compactAgendaInitialNoDateLimit; - var _loadMoreArmed = true; - DateTime? _lastLoadedRangeEnd; - CompactAgendaData? _lastAgendaData; - bool _bodyScrolledUnderHeader = false; - bool _bodyScrolledUnderFooter = false; - bool _creatingTask = false; - bool _creatingEvent = false; - TaskScheduleItem? _editingTask; - EventEditorDraft? _editingEventDraft; - - @override - Widget build(BuildContext context) { - final colors = BusyMaxSurfaceColors.of(context); - final colorScheme = Theme.of(context).colorScheme; - final AsyncValue watchedData = - widget.data ?? ref.watch(compactAgendaDataForQueryProvider(_query)); - final currentData = watchedData.valueOrNull; - if (currentData != null) { - _lastAgendaData = currentData; - if (_lastLoadedRangeEnd != currentData.range.end) { - _lastLoadedRangeEnd = currentData.range.end; - _loadMoreArmed = true; - } - } - final data = watchedData.isLoading && _lastAgendaData != null - ? AsyncData(_lastAgendaData!) - : watchedData; - return Shortcuts( - shortcuts: const { - SingleActivator(LogicalKeyboardKey.escape): _HideIntent(), - SingleActivator(LogicalKeyboardKey.keyR, control: true): - _RefreshIntent(), - }, - child: Actions( - actions: { - _HideIntent: CallbackAction<_HideIntent>( - onInvoke: (_) { - unawaited(_hide()); - return null; - }, - ), - _RefreshIntent: CallbackAction<_RefreshIntent>( - onInvoke: (_) { - unawaited(_refresh()); - return null; - }, - ), - }, - child: Focus( - autofocus: true, - child: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < - _compactAgendaMinimumLayoutSize.width || - constraints.maxHeight < - _compactAgendaMinimumLayoutSize.height) { - return const SizedBox.expand(); - } - - final child = _editingTask != null - ? _CompactAgendaTaskEditorView( - item: _editingTask!, - onClose: _closeTaskEditor, - ) - : _creatingEvent || _editingEventDraft != null - ? _CompactAgendaEventEditorView( - initialDraft: _editingEventDraft, - categorySuggestionsByAccount: - _categorySuggestionsByAccount(), - onCancel: _closeEventEditor, - onSave: _saveEvent, - onDelete: _deleteEvent, - ) - : _creatingTask - ? _CompactAgendaNewTaskView( - onCancel: _closeNewTaskEditor, - onSubmitted: _createTask, - ) - : Column( - children: [ - _CompactAgendaHeader( - data: data.valueOrNull, - onRefresh: _refresh, - onHide: _hide, - ), - Expanded( - child: Stack( - children: [ - NotificationListener( - onNotification: - _handleScrollMetricsNotification, - child: NotificationListener( - onNotification: _handleScrollNotification, - child: ColoredBox( - color: colorScheme.surface, - child: _body(data), - ), - ), - ), - _CompactAgendaScrollShadow( - visible: _bodyScrolledUnderHeader, - below: true, - ), - _CompactAgendaScrollShadow( - visible: _bodyScrolledUnderFooter, - below: false, - ), - ], - ), - ), - _CompactAgendaBottomBar( - onNewEvent: _newEvent, - onNewTask: _newTask, - ), - ], - ); - - return DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(BusyMaxRadius.window), - boxShadow: BusyMaxShadow.windowShadowsFor(context), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(BusyMaxRadius.window), - clipBehavior: Clip.antiAliasWithSaveLayer, - child: DecoratedBox( - decoration: BoxDecoration( - color: colors.card, - border: Border.all(color: colors.border), - ), - child: child, - ), - ), - ); - }, - ), - ), - ), - ); - } - - Widget _body(AsyncValue data) { - return data.when( - loading: () => const _CompactAgendaLoadingState(), - error: (error, stackTrace) => _CompactAgendaMessageState( - icon: Icons.event_busy_outlined, - title: context.l10n.trayAgendaError, - message: redactForLog(error), - primaryLabel: context.l10n.compactAgendaRetry, - onPrimary: _refresh, - secondaryLabel: context.l10n.compactAgendaOpenBusyMax, - onSecondary: _openBusyMax, - ), - data: (agenda) { - if (!agenda.hasSignedInAccounts) { - _scheduleScrollChromeReset(); - return _CompactAgendaMessageState( - icon: Icons.login, - title: context.l10n.trayAgendaSignInRequired, - primaryLabel: context.l10n.compactAgendaOpenBusyMax, - onPrimary: _openBusyMax, - ); - } - if (!agenda.hasSources) { - _scheduleScrollChromeReset(); - return _CompactAgendaMessageState( - icon: Icons.event_busy_outlined, - title: context.l10n.trayAgendaNoSources, - primaryLabel: context.l10n.compactAgendaOpenBusyMax, - onPrimary: _openBusyMax, - ); - } - if (agenda.items.isEmpty) { - _scheduleScrollChromeReset(); - return _CompactAgendaMessageState( - icon: Icons.event_available, - title: context.l10n.compactAgendaClear, - message: context.l10n.noEventsOrTasks, - ); - } - return _sections(agenda); - }, - ); - } - - bool _handleScrollNotification(ScrollNotification notification) { - _updateScrollChrome(notification.metrics); - _maybeLoadMore(notification.metrics); - return false; - } - - bool _handleScrollMetricsNotification( - ScrollMetricsNotification notification, - ) { - _updateScrollChrome(notification.metrics); - return false; - } - - void _updateScrollChrome(ScrollMetrics metrics) { - final pixels = metrics.pixels.clamp(0.0, metrics.maxScrollExtent); - final scrolledFromTop = pixels > 0.5; - final canScrollFurtherDown = metrics.maxScrollExtent - pixels > 0.5; - _setScrollChrome( - header: scrolledFromTop, - footer: scrolledFromTop && canScrollFurtherDown, - ); - } - - void _scheduleScrollChromeReset() { - if (!_bodyScrolledUnderHeader && !_bodyScrolledUnderFooter) { - return; - } - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _setScrollChrome(header: false, footer: false); - } - }); - } - - void _setScrollChrome({required bool header, required bool footer}) { - if (_bodyScrolledUnderHeader == header && - _bodyScrolledUnderFooter == footer) { - return; - } - setState(() { - _bodyScrolledUnderHeader = header; - _bodyScrolledUnderFooter = footer; - }); - } - - void _maybeLoadMore(ScrollMetrics metrics) { - if (widget.data != null || - !_loadMoreArmed || - metrics.axis != Axis.vertical || - metrics.extentAfter > 1) { - return; - } - final agenda = _lastAgendaData; - if (agenda == null || !agenda.hasSignedInAccounts || !agenda.hasSources) { - return; - } - _loadMoreArmed = false; - setState(() { - _loadedDays += compactAgendaPageDays; - }); - } - - Widget _sections(CompactAgendaData data) { - final sections = buildCompactAgendaSections( - today: data.today, - end: data.range.end, - items: data.items, - overdueLimit: _overdueLimit, - noDateLimit: _noDateLimit, - hasMoreOverdueTasks: data.hasMoreOverdueTasks, - hasMoreNoDateTasks: data.hasMoreNoDateTasks, - ); - return ListView.builder( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.md, - BusyMaxSpacing.sm, - BusyMaxSpacing.md, - BusyMaxSpacing.md, - ), - itemCount: sections.length, - itemBuilder: (context, index) { - final section = sections[index]; - return _CompactAgendaSectionView( - section: section, - today: data.today, - mutatingTaskKeys: _mutatingTaskKeys, - onOpenItem: _openItem, - onTaskCompletionChanged: _setTaskCompleted, - onLoadMoreOverdue: _loadMoreOverdue, - onLoadMoreNoDate: _loadMoreNoDate, - ); - }, - ); - } - - void _loadMoreOverdue() { - setState(() { - _overdueLimit += compactAgendaOverduePageSize; - }); - } - - void _loadMoreNoDate() { - setState(() { - _noDateLimit += compactAgendaNoDatePageSize; - }); - } - - Future _openBusyMax() async { - final callback = widget.onOpenBusyMax; - if (callback != null) { - await callback(); - return; - } - await const MainWindowCommandClient().openMain(); - await windowManager.hide(); - } - - Future _newTask() async { - final callback = widget.onNewTask; - if (callback != null) { - await callback(); - return; - } - setState(() { - _creatingTask = true; - _creatingEvent = false; - _editingTask = null; - _editingEventDraft = null; - _bodyScrolledUnderHeader = false; - _bodyScrolledUnderFooter = false; - }); - } - - Future _newEvent() async { - setState(() { - _creatingEvent = true; - _creatingTask = false; - _editingTask = null; - _editingEventDraft = null; - _bodyScrolledUnderHeader = false; - _bodyScrolledUnderFooter = false; - }); - } - - void _closeNewTaskEditor() { - if (!_creatingTask) { - return; - } - setState(() { - _creatingTask = false; - }); - } - - Future _createTask(NewTaskDraft draft) async { - await ref - .read(compactAgendaControllerProvider) - .createTask( - accountId: draft.accountId, - taskListId: draft.taskListId, - input: draft.input, - ); - if (!mounted) { - return; - } - setState(() { - _creatingTask = false; - }); - _invalidateAgendaData(); - } - - void _openTaskEditor(TaskScheduleItem item) { - setState(() { - _editingTask = item; - _creatingTask = false; - _creatingEvent = false; - _editingEventDraft = null; - _bodyScrolledUnderHeader = false; - _bodyScrolledUnderFooter = false; - }); - } - - void _closeTaskEditor() { - if (_editingTask == null) { - return; - } - setState(() { - _editingTask = null; - }); - _invalidateAgendaData(); - } - - void _openEventEditor(CalendarScheduleItem item) { - setState(() { - _editingEventDraft = _eventDraftFromItem(item); - _creatingEvent = false; - _creatingTask = false; - _editingTask = null; - _bodyScrolledUnderHeader = false; - _bodyScrolledUnderFooter = false; - }); - } - - void _closeEventEditor() { - if (!_creatingEvent && _editingEventDraft == null) { - return; - } - setState(() { - _creatingEvent = false; - _editingEventDraft = null; - }); - _invalidateAgendaData(); - } - - Future _saveEvent(EventEditorDraft draft) async { - await ref.read(compactAgendaControllerProvider).saveEvent(draft); - if (!mounted) { - return; - } - setState(() { - _creatingEvent = false; - _editingEventDraft = null; - }); - _invalidateAgendaData(); - } - - Future _deleteEvent(String eventId) async { - await ref.read(compactAgendaControllerProvider).deleteEvent(eventId); - if (!mounted) { - return; - } - setState(() { - _creatingEvent = false; - _editingEventDraft = null; - }); - _invalidateAgendaData(); - } - - Future _refresh() async { - final callback = widget.onRefresh; - if (callback != null) { - await callback(); - return; - } - _invalidateAgendaData(); - } - - Future _hide() async { - final callback = widget.onHide; - if (callback != null) { - await callback(); - return; - } - await windowManager.hide(); - } - - Future _openItem( - BuildContext anchorContext, - ScheduleItem item, [ - Offset? globalPosition, - ]) async { - final callback = widget.onOpenItem; - if (callback != null) { - await callback(item); - return; - } - final action = await showScheduleItemDetailsPopover( - context: context, - anchorContext: anchorContext, - item: item, - anchorPoint: globalPosition, - ); - if (!mounted || action == null) { - return; - } - switch (action) { - case ScheduleItemDetailsAction.export: - await _exportItem(item); - case ScheduleItemDetailsAction.edit: - if (item is TaskScheduleItem) { - _openTaskEditor(item); - } else if (item is CalendarScheduleItem) { - _openEventEditor(item); - } else { - await const MainWindowCommandClient().openScheduleItem(item); - await windowManager.hide(); - } - case ScheduleItemDetailsAction.delete: - await _deleteItem(item); - } - } - - Future _exportItem(ScheduleItem item) async { - try { - final file = await exportScheduleItemWithSaveDialog(item); - if (file == null || !mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.exportedFile(file.path))), - ); - } on Object catch (error) { - if (!mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.exportFailed(redactForLog(error)))), - ); - } - } - - Future _deleteItem(ScheduleItem item) async { - final confirmed = await showBusyMaxConfirm( - context, - title: item is CalendarScheduleItem - ? context.l10n.deleteEvent - : context.l10n.deleteTask, - message: item is TaskScheduleItem - ? context.l10n.deleteTaskConfirmation(item.title) - : 'Delete "${item.title}"?', - confirmLabel: context.l10n.delete, - destructive: true, - ); - if (!confirmed) { - return; - } - try { - if (item is CalendarScheduleItem) { - await _deleteEvent(item.id); - } else if (item is TaskScheduleItem) { - await ref.read(compactAgendaControllerProvider).deleteTask(item); - } - if (!mounted) { - return; - } - _invalidateAgendaData(); - } on Object catch (error) { - if (!mounted) { - return; - } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(redactForLog(error)))); - } - } - - Future _setTaskCompleted(TaskScheduleItem item, bool completed) async { - final key = compactAgendaTaskMutationKey(item); - if (_mutatingTaskKeys.contains(key)) { - return; - } - setState(() => _mutatingTaskKeys.add(key)); - try { - final callback = widget.onTaskCompletionChanged; - if (callback != null) { - await callback(item, completed); - } else { - await ref - .read(compactAgendaControllerProvider) - .setTaskCompleted(item, completed); - } - _invalidateAgendaData(); - } on Object catch (error) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(redactForLog(error)))); - } - } finally { - if (mounted) { - setState(() => _mutatingTaskKeys.remove(key)); - } - } - } - - void _invalidateAgendaData() { - ref.invalidate(compactAgendaDataProvider); - ref.invalidate(compactAgendaDataForQueryProvider(_query)); - } - - CompactAgendaQuery get _query { - return CompactAgendaQuery( - futureDays: _loadedDays, - overdueLimit: _overdueLimit, - noDateLimit: _noDateLimit, - ); - } - - EventEditorDraft _eventDraftFromItem(CalendarScheduleItem item) { - return EventEditorDraft.existing( - eventId: item.id, - accountId: item.accountId, - sourceId: item.sourceId, - providerCalendarId: item.providerCalendarId, - providerRecurringEventId: item.providerRecurringEventId, - title: item.title, - allDay: item.allDay, - start: item.editorStart ?? item.start, - end: item.editorEnd ?? item.end, - startTimeZone: item.startTimeZone, - endTimeZone: item.endTimeZone, - location: item.location, - description: item.description, - descriptionContentType: item.descriptionContentType, - descriptionHtml: item.descriptionHtml, - recurrence: item.recurrence, - attendees: [ - for (final attendee in item.attendees) - EventAttendeeDraft.fromJson(attendee), - ], - reminders: _eventRemindersForEdit( - item.provider, - item.reminderMinutesBeforeStart, - ), - categories: item.categories, - ); - } - - Map> _categorySuggestionsByAccount() { - final byAccount = >{}; - for (final item in _lastAgendaData?.items ?? const []) { - if (item.categories.isEmpty) { - continue; - } - byAccount - .putIfAbsent(item.accountId, () => {}) - .addAll( - item.categories - .map((category) => category.trim()) - .where((category) => category.isNotEmpty), - ); - } - return { - for (final entry in byAccount.entries) - entry.key: (entry.value.toList()..sort()), - }; - } -} - -EventEditorDraft _newEventDraft(List sources) { - final source = sources.first; - final start = _defaultNewEventStart(); - return EventEditorDraft.newEvent( - accountId: source.accountId, - sourceId: source.id, - providerCalendarId: source.providerCalendarId, - start: start, - end: start.add(const Duration(hours: 1)), - ); -} - -DateTime _defaultNewEventStart() { - final now = DateTime.now(); - final base = DateTime(now.year, now.month, now.day, now.hour); - return now.minute < 30 - ? base.add(const Duration(minutes: 30)) - : base.add(const Duration(hours: 1)); -} - -List _editableSources( - List sources, { - required String? currentSourceId, -}) { - final visibleEditable = [ - for (final source in sources) - if (!source.isDeleted && - !source.hidden && - source.selected && - (!source.readOnly || source.id == currentSourceId)) - source, - ]; - if (visibleEditable.isNotEmpty) { - return visibleEditable; - } - return [ - for (final source in sources) - if (!source.isDeleted && - !source.hidden && - (!source.readOnly || source.id == currentSourceId)) - source, - ]; -} - -Object? _eventRemindersForEdit(BusyProvider provider, List minutes) { - final normalized = [ - for (final value in minutes) - if (value > 0) value, - ]; - if (normalized.isEmpty) { - return null; - } - if (provider == TaskProvider.google) { - return { - 'useDefault': false, - 'overrides': [ - for (final minutes in normalized) - {'method': 'popup', 'minutes': minutes}, - ], - }; - } - return {'isReminderOn': true, 'reminderMinutesBeforeStart': normalized.first}; -} - -bool _listEquals(List a, List b) { - if (identical(a, b)) { - return true; - } - if (a.length != b.length) { - return false; - } - for (var index = 0; index < a.length; index += 1) { - if (a[index] != b[index]) { - return false; - } - } - return true; -} - -class _CompactAgendaHeader extends StatelessWidget { - const _CompactAgendaHeader({ - required this.data, - required this.onRefresh, - required this.onHide, - }); - - final CompactAgendaData? data; - final Future Function() onRefresh; - final Future Function() onHide; - - @override - Widget build(BuildContext context) { - final colors = BusyMaxSurfaceColors.of(context); - final subtitle = compactAgendaTodaySubtitle( - context, - data?.today ?? DateTime.now(), - ); - return DragToMoveArea( - child: Container( - height: 56, - key: const ValueKey('compactAgendaHeader'), - padding: const EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), - decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), - child: Row( - children: [ - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.compactAgendaTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colors.mutedForeground, - ), - ), - ], - ), - ), - _CompactHeaderButton( - tooltip: context.l10n.compactAgendaRefresh, - icon: Icons.refresh, - onPressed: () => unawaited(onRefresh()), - ), - const SizedBox(width: BusyMaxSpacing.xs), - YaruWindowControl( - type: YaruWindowControlType.close, - semanticLabel: context.l10n.compactAgendaHide, - onTap: () => unawaited(onHide()), - ), - ], - ), - ), - ); - } -} - -class _CompactHeaderButton extends StatelessWidget { - const _CompactHeaderButton({ - required this.tooltip, - required this.icon, - required this.onPressed, - }); - - final String tooltip; - final IconData icon; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return YaruIconButton( - tooltip: tooltip, - icon: Icon(icon), - iconSize: BusyMaxSizes.iconMd, - onPressed: onPressed, - style: busyMaxHeaderIconButtonStyle( - foregroundColor: BusyMaxSurfaceColors.of(context).foreground, - backgroundColor: busyMaxSubtleButtonBackground(context), - ), - ); - } -} - -class _CompactAgendaTaskEditorView extends ConsumerWidget { - const _CompactAgendaTaskEditorView({ - required this.item, - required this.onClose, - }); - - final TaskScheduleItem item; - final VoidCallback onClose; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final database = ref.read(databaseProvider); - final controller = ref.read(compactAgendaControllerProvider); - return ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: TaskDetailsPane( - accountId: item.accountId, - taskListId: item.sourceId, - taskId: item.id, - tasksRepositoryForAccount: (accountId) => - TasksRepository(database: database, accountId: accountId), - taskListsRepositoryForAccount: (accountId) => - TaskListsRepository(database: database, accountId: accountId), - onTaskMutationCommitted: controller.taskMutated, - onClose: onClose, - dialogBarrierColor: Colors.transparent, - ), - ); - } -} - -class _CompactAgendaEventEditorView extends ConsumerStatefulWidget { - const _CompactAgendaEventEditorView({ - required this.initialDraft, - required this.categorySuggestionsByAccount, - required this.onCancel, - required this.onSave, - required this.onDelete, - }); - - final EventEditorDraft? initialDraft; - final Map> categorySuggestionsByAccount; - final VoidCallback onCancel; - final Future Function(EventEditorDraft draft) onSave; - final Future Function(String eventId) onDelete; - - @override - ConsumerState<_CompactAgendaEventEditorView> createState() => - _CompactAgendaEventEditorViewState(); -} - -class _CompactAgendaEventEditorViewState - extends ConsumerState<_CompactAgendaEventEditorView> { - late final CalendarRepository _repository; - Stream>? _sourcesStream; - List? _sourceAccountIds; - - @override - void initState() { - super.initState(); - _repository = CalendarRepository( - database: ref.read(databaseProvider), - localTimeZone: ref.read(localTimeZoneProvider), - ); - } - - @override - Widget build(BuildContext context) { - final accounts = ref.watch(accountsStreamProvider); - return ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: accounts.when( - loading: () => const _CompactAgendaLoadingState(), - error: (error, stackTrace) => _CompactAgendaMessageState( - icon: Icons.event_busy_outlined, - title: context.l10n.trayAgendaError, - message: redactForLog(error), - primaryLabel: context.l10n.cancel, - onPrimary: () async => widget.onCancel(), - ), - data: (accounts) { - if (accounts.isEmpty) { - return _CompactAgendaMessageState( - icon: Icons.login, - title: context.l10n.trayAgendaSignInRequired, - primaryLabel: context.l10n.cancel, - onPrimary: () async => widget.onCancel(), - ); - } - final accountIds = [for (final account in accounts) account.id]; - return StreamBuilder>( - stream: _watchSources(accountIds), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting && - !snapshot.hasData) { - return const _CompactAgendaLoadingState(); - } - final sources = _editableSources( - snapshot.data ?? const [], - currentSourceId: widget.initialDraft?.sourceId, - ); - if (sources.isEmpty) { - return _CompactAgendaMessageState( - icon: Icons.event_busy_outlined, - title: context.l10n.trayAgendaNoSources, - primaryLabel: context.l10n.cancel, - onPrimary: () async => widget.onCancel(), - ); - } - final draft = widget.initialDraft ?? _newEventDraft(sources); - return EventEditor( - initialDraft: draft, - sources: sources, - categorySuggestionsByAccount: - widget.categorySuggestionsByAccount, - onCancel: widget.onCancel, - onSave: (draft) => unawaited(_save(draft)), - onDelete: draft.eventId == null - ? null - : (eventId) => unawaited(_delete(eventId)), - ); - }, - ); - }, - ), - ); - } - - Stream> _watchSources(List accountIds) { - if (_sourcesStream == null || - _sourceAccountIds == null || - !_listEquals(_sourceAccountIds!, accountIds)) { - _sourceAccountIds = accountIds; - _sourcesStream = _repository - .watchSourcesForAccounts(accountIds) - .asBroadcastStream(); - } - return _sourcesStream!; - } - - Future _save(EventEditorDraft draft) async { - try { - await widget.onSave(draft); - } on Object catch (error) { - if (!mounted) { - return; - } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(redactForLog(error)))); - } - } - - Future _delete(String eventId) async { - try { - await widget.onDelete(eventId); - } on Object catch (error) { - if (!mounted) { - return; - } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(redactForLog(error)))); - } - } -} - -class _CompactAgendaNewTaskView extends ConsumerWidget { - const _CompactAgendaNewTaskView({ - required this.onCancel, - required this.onSubmitted, - }); - - final VoidCallback onCancel; - final Future Function(NewTaskDraft draft) onSubmitted; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final accounts = ref.watch(accountsStreamProvider); - return ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: accounts.when( - loading: () => const _CompactAgendaLoadingState(), - error: (error, stackTrace) => _CompactAgendaMessageState( - icon: Icons.event_busy_outlined, - title: context.l10n.trayAgendaError, - message: redactForLog(error), - primaryLabel: context.l10n.cancel, - onPrimary: () async => onCancel(), - ), - data: (accounts) { - if (accounts.isEmpty) { - return _CompactAgendaMessageState( - icon: Icons.login, - title: context.l10n.trayAgendaSignInRequired, - primaryLabel: context.l10n.cancel, - onPrimary: () async => onCancel(), - ); - } - return NewTaskEditorPanel( - accounts: accounts, - categorySuggestionsForAccount: (accountId) => TasksRepository( - database: ref.read(databaseProvider), - accountId: accountId, - ).watchCategorySuggestions(), - onSubmitted: onSubmitted, - onCancel: onCancel, - ); - }, - ), - ); - } -} - -class _CompactAgendaLoadingState extends StatelessWidget { - const _CompactAgendaLoadingState(); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - const LinearProgressIndicator(minHeight: 2), - Expanded( - child: ListView.separated( - padding: const EdgeInsets.all(BusyMaxSpacing.md), - itemCount: 4, - separatorBuilder: (_, _) => - const SizedBox(height: BusyMaxSpacing.sm), - itemBuilder: (context, index) => const _SkeletonRow(), - ), - ), - ], - ); - } -} - -class _SkeletonRow extends StatelessWidget { - const _SkeletonRow(); - - @override - Widget build(BuildContext context) { - final colors = BusyMaxSurfaceColors.of(context); - return Container( - height: 62, - padding: const EdgeInsets.all(BusyMaxSpacing.md), - decoration: BoxDecoration( - color: colors.control, - borderRadius: BorderRadius.circular(BusyMaxRadius.sm), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FractionallySizedBox( - widthFactor: 0.68, - child: Container(height: 10, color: colors.controlHover), - ), - const SizedBox(height: BusyMaxSpacing.sm), - FractionallySizedBox( - widthFactor: 0.42, - child: Container(height: 8, color: colors.controlHover), - ), - ], - ), - ); - } -} - -class _CompactAgendaMessageState extends StatelessWidget { - const _CompactAgendaMessageState({ - required this.icon, - required this.title, - this.message, - this.primaryLabel, - this.onPrimary, - this.secondaryLabel, - this.onSecondary, - }); - - final IconData icon; - final String title; - final String? message; - final String? primaryLabel; - final Future Function()? onPrimary; - final String? secondaryLabel; - final Future Function()? onSecondary; - - @override - Widget build(BuildContext context) { - final colors = BusyMaxSurfaceColors.of(context); - return LayoutBuilder( - builder: (context, constraints) { - final minHeight = constraints.maxHeight.isFinite - ? (constraints.maxHeight - BusyMaxSpacing.xl * 2) - .clamp(0.0, double.infinity) - .toDouble() - : 0.0; - return SingleChildScrollView( - padding: const EdgeInsets.all(BusyMaxSpacing.xl), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: minHeight), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 34, color: colors.mutedForeground), - const SizedBox(height: BusyMaxSpacing.md), - Text( - title, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - if (message != null && message!.isNotEmpty) ...[ - const SizedBox(height: BusyMaxSpacing.sm), - Text( - message!, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colors.mutedForeground, - ), - ), - ], - if (primaryLabel != null || secondaryLabel != null) ...[ - const SizedBox(height: BusyMaxSpacing.lg), - Wrap( - alignment: WrapAlignment.center, - spacing: BusyMaxSpacing.sm, - runSpacing: BusyMaxSpacing.sm, - children: [ - if (primaryLabel != null) - BusyMaxPushButton.filled( - onPressed: onPrimary == null - ? null - : () => unawaited(onPrimary!()), - child: Text(primaryLabel!), - ), - if (secondaryLabel != null) - BusyMaxPushButton.outlined( - onPressed: onSecondary == null - ? null - : () => unawaited(onSecondary!()), - child: Text(secondaryLabel!), - ), - ], - ), - ], - ], - ), - ), - ), - ); - }, - ); - } -} - -class _CompactAgendaSectionView extends StatelessWidget { - const _CompactAgendaSectionView({ - required this.section, - required this.today, - required this.mutatingTaskKeys, - required this.onOpenItem, - required this.onTaskCompletionChanged, - required this.onLoadMoreOverdue, - required this.onLoadMoreNoDate, - }); - - final CompactAgendaSection section; - final DateTime today; - final Set mutatingTaskKeys; - final Future Function( - BuildContext anchorContext, - ScheduleItem item, [ - Offset? globalPosition, - ]) - onOpenItem; - final CompactAgendaTaskCompletionCallback onTaskCompletionChanged; - final VoidCallback onLoadMoreOverdue; - final VoidCallback onLoadMoreNoDate; - - @override - Widget build(BuildContext context) { - final title = switch (section.kind) { - CompactAgendaSectionKind.overdue => context.l10n.compactAgendaOverdue, - CompactAgendaSectionKind.day => compactAgendaDayLabel( - context, - today: today, - day: section.day ?? today, - ), - CompactAgendaSectionKind.noDate => context.l10n.noDate, - }; - return BusyMaxGroupedList( - title: title, - filled: true, - children: [ - for (final item in section.items) - _CompactAgendaRow( - item: item, - today: today, - mutating: - item is TaskScheduleItem && - mutatingTaskKeys.contains(compactAgendaTaskMutationKey(item)), - onOpenItem: onOpenItem, - onTaskCompletionChanged: onTaskCompletionChanged, - ), - if (section.hasMore) - _MoreBucketRow( - title: switch (section.kind) { - CompactAgendaSectionKind.overdue => - context.l10n.agendaLoadMoreOverdue, - CompactAgendaSectionKind.noDate => - context.l10n.agendaLoadMoreNoDate, - CompactAgendaSectionKind.day => - context.l10n.agendaLoadMoreOverdue, - }, - onLoadMore: switch (section.kind) { - CompactAgendaSectionKind.overdue => onLoadMoreOverdue, - CompactAgendaSectionKind.noDate => onLoadMoreNoDate, - CompactAgendaSectionKind.day => onLoadMoreOverdue, - }, - ), - ], - ); - } -} - -class _CompactAgendaRow extends StatelessWidget { - const _CompactAgendaRow({ - required this.item, - required this.today, - required this.mutating, - required this.onOpenItem, - required this.onTaskCompletionChanged, - }); - - final ScheduleItem item; - final DateTime today; - final bool mutating; - final Future Function( - BuildContext anchorContext, - ScheduleItem item, [ - Offset? globalPosition, - ]) - onOpenItem; - final CompactAgendaTaskCompletionCallback onTaskCompletionChanged; - - @override - Widget build(BuildContext context) { - final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; - Offset? pointerDownPosition; - return AnimatedOpacity( - opacity: mutating ? 0.48 : 1, - duration: const Duration(milliseconds: 120), - child: BusyMaxActionRow( - title: item.title, - titleWidget: _CompactAgendaRowTitle(item: item), - subtitleWidget: _CompactAgendaRowSubtitle(item: item, today: today), - leading: _CompactAgendaRowMarker(item: item), - trailing: task == null - ? null - : YaruCheckbox( - value: task.completed, - onChanged: mutating - ? null - : (value) => unawaited( - onTaskCompletionChanged(task, value ?? false), - ), - ), - enabled: !mutating, - onPointerDown: (position) => pointerDownPosition = position, - onTap: () => unawaited(onOpenItem(context, item, pointerDownPosition)), - ), - ); - } -} - -class _CompactAgendaRowMarker extends StatelessWidget { - const _CompactAgendaRowMarker({required this.item}); - - final ScheduleItem item; - - @override - Widget build(BuildContext context) { - final isTask = item.kind == ScheduleItemKind.task; - final color = BusyMaxSurfaceColors.of(context).mutedForeground; - final icon = isTask ? YaruIcons.task_list : YaruIcons.calendar; - return Icon(icon, size: BusyMaxSizes.iconSm, color: color); - } -} - -class _CompactAgendaRowTitle extends StatelessWidget { - const _CompactAgendaRowTitle({required this.item}); - - final ScheduleItem item; - - @override - Widget build(BuildContext context) { - final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; - return Text( - item.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - decoration: task?.completed == true ? TextDecoration.lineThrough : null, - ), - ); - } -} - -class _CompactAgendaRowSubtitle extends StatelessWidget { - const _CompactAgendaRowSubtitle({required this.item, required this.today}); - - final ScheduleItem item; - final DateTime today; - - @override - Widget build(BuildContext context) { - final surfaceColors = BusyMaxSurfaceColors.of(context); - final source = ScheduleProjection.sourceLabelForScheduleItem(item); - final meta = compactAgendaItemMeta(context, item, today: today); - final event = item is CalendarScheduleItem - ? item as CalendarScheduleItem - : null; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - [if (meta.isNotEmpty) meta, source].join(' - '), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: surfaceColors.mutedForeground), - ), - if (event?.location?.trim().isNotEmpty == true) - Text( - event!.location!.trim(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: surfaceColors.mutedForeground, - ), - ), - ], - ); - } -} - -class _MoreBucketRow extends StatelessWidget { - const _MoreBucketRow({required this.title, required this.onLoadMore}); - - final String title; - final VoidCallback onLoadMore; - - @override - Widget build(BuildContext context) { - return BusyMaxActionRow( - title: title, - leading: const Icon(YaruIcons.plus, size: BusyMaxSizes.iconSm), - onTap: onLoadMore, - ); - } -} - -class _CompactAgendaBottomBar extends StatelessWidget { - const _CompactAgendaBottomBar({ - required this.onNewEvent, - required this.onNewTask, - }); - - final Future Function() onNewEvent; - final Future Function() onNewTask; - - @override - Widget build(BuildContext context) { - return Container( - key: const ValueKey('compactAgendaFooter'), - padding: const EdgeInsets.all(BusyMaxSpacing.md), - decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), - child: Row( - children: [ - Expanded( - child: BusyMaxPushButton.filled( - onPressed: () => unawaited(onNewEvent()), - child: Text(context.l10n.newEvent), - ), - ), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: BusyMaxPushButton.filled( - onPressed: () => unawaited(onNewTask()), - child: Text(context.l10n.compactAgendaNewTask), - ), - ), - ], - ), - ); - } -} - -class _CompactAgendaScrollShadow extends StatelessWidget { - const _CompactAgendaScrollShadow({ - required this.visible, - required this.below, - }); - - final bool visible; - final bool below; - - @override - Widget build(BuildContext context) { - return IgnorePointer( - child: AnimatedOpacity( - key: ValueKey( - below - ? 'compactAgendaTopScrollShadow' - : 'compactAgendaBottomScrollShadow', - ), - opacity: visible ? 1 : 0, - duration: const Duration(milliseconds: 120), - child: Align( - alignment: below ? Alignment.topCenter : Alignment.bottomCenter, - child: DecoratedBox( - decoration: BoxDecoration( - boxShadow: BusyMaxShadow.edgeShadowsFor(context, below: below), - ), - child: const SizedBox(width: double.infinity, height: 1), - ), - ), - ), - ); - } -} - -class _HideIntent extends Intent { - const _HideIntent(); -} - -class _RefreshIntent extends Intent { - const _RefreshIntent(); -} diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index e04ad46..69a417d 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -2,14 +2,20 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_glyphs.dart'; import '../../../app/busymax_surface_colors.dart'; +import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; +import 'calendar_day_semantics.dart'; -class MiniCalendar extends StatelessWidget { +const _miniCalendarHeaderControlExtent = 28.0; + +/// Sidebar calendar with local month paging and schedule-view shortcuts. +class MiniCalendar extends StatefulWidget { const MiniCalendar({ super.key, required this.selectedDate, @@ -29,11 +35,36 @@ class MiniCalendar extends StatelessWidget { final ValueChanged onYearSelected; final ValueChanged onWeekSelected; + @override + State createState() => _MiniCalendarState(); +} + +class _MiniCalendarState extends State { + late DateTime _displayedMonth; + + @override + void initState() { + super.initState(); + _displayedMonth = _monthOf(widget.selectedDate); + } + + @override + void didUpdateWidget(covariant MiniCalendar oldWidget) { + super.didUpdateWidget(oldWidget); + if (!_sameMonth(oldWidget.selectedDate, widget.selectedDate)) { + _displayedMonth = _monthOf(widget.selectedDate); + } + } + + void _showMonth(DateTime month) { + setState(() => _displayedMonth = _monthOf(month)); + } + @override Widget build(BuildContext context) { - final first = DateTime(selectedDate.year, selectedDate.month); - final start = _calendarStartForMonth(first, firstWeekday); - final groupedItems = ScheduleProjection.groupByDay(items); + final l10n = context.l10n; + final visibleMonth = _displayedMonth; + final first = DateTime(visibleMonth.year, visibleMonth.month); final locale = Localizations.localeOf(context).toLanguageTag(); return Padding( padding: const EdgeInsetsDirectional.fromSTEB( @@ -48,130 +79,261 @@ class MiniCalendar extends StatelessWidget { Row( children: [ Expanded( + flex: BusyMaxCalendarHeaderLayout.monthControlFlex, child: _MiniCalendarStepper( - label: _monthName(selectedDate), - previousTooltip: 'Previous month', - nextTooltip: 'Next month', - onPrevious: () => onSelected( - DateTime(selectedDate.year, selectedDate.month - 1), + label: localizedMonthHeading(locale, visibleMonth), + previousTooltip: l10n.previousMonth, + nextTooltip: l10n.nextMonth, + onPrevious: () => _showMonth( + DateTime(visibleMonth.year, visibleMonth.month - 1), ), - onNext: () => onSelected( - DateTime(selectedDate.year, selectedDate.month + 1), + onNext: () => _showMonth( + DateTime(visibleMonth.year, visibleMonth.month + 1), ), - labelTooltip: 'Open month', - onLabelPressed: () => onMonthSelected(first), + labelTooltip: l10n.openMonthView, + onLabelPressed: () => widget.onMonthSelected(first), ), ), const SizedBox(width: BusyMaxSpacing.sm), Expanded( + flex: BusyMaxCalendarHeaderLayout.yearControlFlex, child: _MiniCalendarStepper( - label: '${selectedDate.year}', - previousTooltip: 'Previous year', - nextTooltip: 'Next year', - onPrevious: () => onSelected( - DateTime(selectedDate.year - 1, selectedDate.month), + label: '${visibleMonth.year}', + previousTooltip: l10n.previousYear, + nextTooltip: l10n.nextYear, + onPrevious: () => _showMonth( + DateTime(visibleMonth.year - 1, visibleMonth.month), ), - onNext: () => onSelected( - DateTime(selectedDate.year + 1, selectedDate.month), + onNext: () => _showMonth( + DateTime(visibleMonth.year + 1, visibleMonth.month), ), - labelTooltip: 'Open year', + labelTooltip: l10n.openYearView, onLabelPressed: () => - onYearSelected(DateTime(selectedDate.year)), + widget.onYearSelected(DateTime(visibleMonth.year)), ), ), ], ), const SizedBox(height: BusyMaxSpacing.sm), - LayoutBuilder( - builder: (context, constraints) { - final weekNumberExtent = math.min( - BusyMaxSizes.miniCalendarWeekButton, - constraints.maxWidth / (DateTime.daysPerWeek + 1), - ); - final dayExtent = - math.max(0.0, constraints.maxWidth - weekNumberExtent) / - DateTime.daysPerWeek; - const weekdayHeaderHeight = 18.0; - return SizedBox( - width: double.infinity, - height: weekdayHeaderHeight + BusyMaxSpacing.xs + dayExtent * 6, - child: Column( + MiniCalendarGrid( + displayedMonth: first, + selectedDate: widget.selectedDate, + firstWeekday: widget.firstWeekday, + markerColorsByDay: miniCalendarMarkerColorsForItems( + context, + widget.items, + ), + onDaySelected: widget.onSelected, + onWeekSelected: widget.onWeekSelected, + ), + ], + ), + ); + } +} + +/// One fixed month used by each card in the year view. +class YearMonthMiniCalendar extends StatelessWidget { + const YearMonthMiniCalendar({ + super.key, + required this.displayedMonth, + required this.selectedDate, + required this.firstWeekday, + required this.markerColorsByDay, + required this.onDaySelected, + required this.onMonthSelected, + required this.onWeekSelected, + required this.onDayDoubleTap, + }); + + final DateTime displayedMonth; + final DateTime selectedDate; + final int firstWeekday; + final Map> markerColorsByDay; + final ValueChanged onDaySelected; + final ValueChanged onMonthSelected; + final ValueChanged onWeekSelected; + final ValueChanged onDayDoubleTap; + + @override + Widget build(BuildContext context) { + final month = _monthOf(displayedMonth); + final locale = Localizations.localeOf(context).toLanguageTag(); + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + BusyMaxSpacing.headerInset, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.md, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _MiniCalendarHeaderLabel( + label: DateFormat.yMMMM(locale).format(month), + tooltip: context.l10n.openMonthView, + onPressed: () => onMonthSelected(month), + ), + const SizedBox(height: BusyMaxSpacing.sm), + MiniCalendarGrid( + displayedMonth: month, + selectedDate: selectedDate, + firstWeekday: firstWeekday, + highlightSelectedDateOutsideMonth: false, + markerColorsByDay: markerColorsByDay, + onDaySelected: onDaySelected, + onWeekSelected: onWeekSelected, + onDayDoubleTap: onDayDoubleTap, + ), + ], + ), + ); + } +} + +/// Shared stateless weekday, week-number, and day-cell matrix. +class MiniCalendarGrid extends StatelessWidget { + const MiniCalendarGrid({ + super.key, + required this.displayedMonth, + required this.selectedDate, + required this.firstWeekday, + required this.onDaySelected, + this.markerColorsByDay = const {}, + this.highlightSelectedDateOutsideMonth = true, + this.shrinkToFitHeight = false, + this.onWeekSelected, + this.onDayDoubleTap, + }); + + final DateTime displayedMonth; + final DateTime selectedDate; + final int firstWeekday; + final Map> markerColorsByDay; + final bool highlightSelectedDateOutsideMonth; + final bool shrinkToFitHeight; + final ValueChanged onDaySelected; + final ValueChanged? onWeekSelected; + final ValueChanged? onDayDoubleTap; + + @override + Widget build(BuildContext context) { + final month = _monthOf(displayedMonth); + final start = _calendarStartForMonth(month, firstWeekday); + final locale = Localizations.localeOf(context).toLanguageTag(); + return LayoutBuilder( + builder: (context, constraints) { + final maximumWeekNumberExtent = onWeekSelected == null + ? _miniCalendarHeaderControlExtent - BusyMaxSpacing.headerInset + : _miniCalendarHeaderControlExtent; + final weekNumberExtent = math.min( + maximumWeekNumberExtent, + constraints.maxWidth / (DateTime.daysPerWeek + 1), + ); + final naturalDayExtent = + math.max(0.0, constraints.maxWidth - weekNumberExtent) / + DateTime.daysPerWeek; + const weekdayHeaderHeight = 18.0; + const fixedVerticalExtent = weekdayHeaderHeight + BusyMaxSpacing.xs; + final fittedDayExtent = + shrinkToFitHeight && constraints.hasBoundedHeight + ? math.max(0.0, constraints.maxHeight - fixedVerticalExtent) / 6 + : naturalDayExtent; + final dayExtent = math.min(naturalDayExtent, fittedDayExtent); + return SizedBox( + width: double.infinity, + height: fixedVerticalExtent + dayExtent * 6, + child: Column( + children: [ + SizedBox( + height: weekdayHeaderHeight, + child: Row( children: [ - SizedBox( - height: weekdayHeaderHeight, - child: Row( - children: [ - SizedBox(width: weekNumberExtent), - for (final weekday in _weekdays(firstWeekday)) - Expanded( - child: Center( - child: Text( - DateFormat.E( - locale, - ).format(_weekdayDate(weekday)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), + SizedBox(width: weekNumberExtent), + for (final weekday in _weekdays(firstWeekday)) + Expanded( + child: Center( + child: Text( + DateFormat.E(locale).format(_weekdayDate(weekday)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, ), - ), - ), - ], - ), - ), - const SizedBox(height: BusyMaxSpacing.xs), - for (var row = 0; row < 6; row++) - SizedBox( - height: dayExtent, - child: _MiniCalendarWeekRow( - weekStart: _addCalendarDays( - start, - row * DateTime.daysPerWeek, ), - weekNumberExtent: weekNumberExtent, - selectedMonth: selectedDate.month, - selectedYear: selectedDate.year, - groupedItems: groupedItems, - locale: locale, - onDaySelected: onSelected, - onWeekSelected: onWeekSelected, ), ), ], ), - ); - }, + ), + const SizedBox(height: BusyMaxSpacing.xs), + for (var row = 0; row < 6; row++) + SizedBox( + height: dayExtent, + child: _MiniCalendarWeekRow( + weekStart: _addCalendarDays( + start, + row * DateTime.daysPerWeek, + ), + weekNumberExtent: weekNumberExtent, + onWeekSelected: onWeekSelected, + selectedDate: selectedDate, + displayedMonth: month, + highlightSelectedDateOutsideMonth: + highlightSelectedDateOutsideMonth, + markerColorsByDay: markerColorsByDay, + onDaySelected: onDaySelected, + onDayDoubleTap: onDayDoubleTap, + ), + ), + ], ), - ], - ), + ); + }, ); } } +Map> miniCalendarMarkerColorsForItems( + BuildContext context, + List items, +) { + final brightness = Theme.of(context).brightness; + final groupedItems = ScheduleProjection.groupByDay(items); + return { + for (final entry in groupedItems.entries) + entry.key: [ + for (final item in entry.value.take(3)) + ScheduleProjection.colorForItem(item, brightness), + ], + }; +} + class _MiniCalendarWeekRow extends StatelessWidget { const _MiniCalendarWeekRow({ required this.weekStart, required this.weekNumberExtent, - required this.selectedMonth, - required this.selectedYear, - required this.groupedItems, - required this.locale, - required this.onDaySelected, required this.onWeekSelected, + required this.selectedDate, + required this.displayedMonth, + required this.highlightSelectedDateOutsideMonth, + required this.markerColorsByDay, + required this.onDaySelected, + required this.onDayDoubleTap, }); final DateTime weekStart; final double weekNumberExtent; - final int selectedMonth; - final int selectedYear; - final Map> groupedItems; - final String locale; + final ValueChanged? onWeekSelected; + final DateTime selectedDate; + final DateTime displayedMonth; + final bool highlightSelectedDateOutsideMonth; + final Map> markerColorsByDay; final ValueChanged onDaySelected; - final ValueChanged onWeekSelected; + final ValueChanged? onDayDoubleTap; @override Widget build(BuildContext context) { @@ -182,6 +344,7 @@ class _MiniCalendarWeekRow extends StatelessWidget { width: weekNumberExtent, child: _MiniCalendarWeekNumberButton( weekStart: weekStart, + buttonWidth: weekNumberExtent, onSelected: onWeekSelected, ), ), @@ -189,11 +352,13 @@ class _MiniCalendarWeekRow extends StatelessWidget { Expanded( child: _MiniCalendarDayButton( day: _addCalendarDays(weekStart, column), - selectedMonth: selectedMonth, - selectedYear: selectedYear, - groupedItems: groupedItems, - locale: locale, + selectedDate: selectedDate, + displayedMonth: displayedMonth, + highlightSelectedDateOutsideMonth: + highlightSelectedDateOutsideMonth, + markerColorsByDay: markerColorsByDay, onSelected: onDaySelected, + onDoubleTap: onDayDoubleTap, ), ), ], @@ -204,84 +369,115 @@ class _MiniCalendarWeekRow extends StatelessWidget { class _MiniCalendarWeekNumberButton extends StatelessWidget { const _MiniCalendarWeekNumberButton({ required this.weekStart, + required this.buttonWidth, required this.onSelected, }); final DateTime weekStart; - final ValueChanged onSelected; + final double buttonWidth; + final ValueChanged? onSelected; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final weekNumber = _isoWeekNumber(weekStart); + final label = Text( + '$weekNumber', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.72), + ), + ); return Center( child: Tooltip( - message: 'Week $weekNumber', - child: TextButton( - onPressed: () => onSelected(weekStart), - style: - busyMaxHeaderIconButtonStyle( - foregroundColor: colorScheme.onSurfaceVariant, - backgroundColor: busyMaxHeaderButtonBackground(context), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - ).copyWith( - fixedSize: const WidgetStatePropertyAll( - Size.square(BusyMaxSizes.miniCalendarWeekButton), - ), - minimumSize: const WidgetStatePropertyAll( - Size.square(BusyMaxSizes.miniCalendarWeekButton), - ), - maximumSize: const WidgetStatePropertyAll( - Size.square(BusyMaxSizes.miniCalendarWeekButton), - ), + message: context.l10n.weekNumberTooltip(weekNumber), + child: onSelected == null + ? SizedBox.square( + dimension: buttonWidth, + child: Center(child: label), + ) + : TextButton( + onPressed: () => onSelected!(weekStart), + style: + busyMaxHeaderIconButtonStyle( + context, + foregroundColor: colorScheme.onSurfaceVariant, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll( + Colors.transparent, + ), + ).copyWith( + fixedSize: WidgetStatePropertyAll( + Size.square(buttonWidth), + ), + minimumSize: WidgetStatePropertyAll( + Size.square(buttonWidth), + ), + maximumSize: WidgetStatePropertyAll( + Size.square(buttonWidth), + ), + ), + child: label, ), - child: Text( - '$weekNumber', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.72), - ), - ), - ), ), ); } } -class _MiniCalendarDayButton extends StatelessWidget { +class _MiniCalendarDayButton extends StatefulWidget { const _MiniCalendarDayButton({ required this.day, - required this.selectedMonth, - required this.selectedYear, - required this.groupedItems, - required this.locale, + required this.selectedDate, + required this.displayedMonth, + required this.highlightSelectedDateOutsideMonth, + required this.markerColorsByDay, required this.onSelected, + required this.onDoubleTap, }); final DateTime day; - final int selectedMonth; - final int selectedYear; - final Map> groupedItems; - final String locale; + final DateTime selectedDate; + final DateTime displayedMonth; + final bool highlightSelectedDateOutsideMonth; + final Map> markerColorsByDay; final ValueChanged onSelected; + final ValueChanged? onDoubleTap; + + @override + State<_MiniCalendarDayButton> createState() => _MiniCalendarDayButtonState(); +} + +class _MiniCalendarDayButtonState extends State<_MiniCalendarDayButton> { + bool _isHovering = false; @override Widget build(BuildContext context) { + final day = widget.day; + final selectedDate = widget.selectedDate; + final onSelected = widget.onSelected; final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); + final inDisplayedMonth = + day.year == widget.displayedMonth.year && + day.month == widget.displayedMonth.month; + final selected = + _sameDay(day, selectedDate) && + (inDisplayedMonth || widget.highlightSelectedDateOutsideMonth); final today = _sameDay(day, DateTime.now()); - final currentMonth = - selectedYear == DateTime.now().year && - selectedMonth == DateTime.now().month; - final highlightToday = today && currentMonth; - final items = - groupedItems[ScheduleProjection.day(day)] ?? const []; + final displayingCurrentMonth = + widget.displayedMonth.year == DateTime.now().year && + widget.displayedMonth.month == DateTime.now().month; + final highlightToday = today && displayingCurrentMonth; + final markerColors = + widget.markerColorsByDay[_dayOf(day)] ?? const []; - return Tooltip( - message: DateFormat.yMMMMEEEEd(locale).format(day), + return BusyMaxCalendarDaySemantics( + day: day, + selected: selected, + onTap: () => onSelected(day), child: LayoutBuilder( builder: (context, constraints) { final canShowIndicators = - items.isNotEmpty && constraints.maxHeight >= 28; + markerColors.isNotEmpty && constraints.maxHeight >= 28; final indicatorHeight = canShowIndicators ? 4.0 : 0.0; final availableMarkerExtent = math.min( constraints.maxWidth, @@ -290,50 +486,86 @@ class _MiniCalendarDayButton extends StatelessWidget { (canShowIndicators ? BusyMaxSpacing.xxs : 0), ); final markerSize = math.min( - 24.0, + 25.5, math.max(0.0, availableMarkerExtent), ); - return InkWell( - onTap: () => onSelected(day), - customBorder: const CircleBorder(), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox.square( - dimension: markerSize, - child: DecoratedBox( - decoration: BoxDecoration( - color: highlightToday - ? surfaceColors.controlActive - : null, - shape: BoxShape.circle, - ), - child: Center( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - '${day.day}', - style: TextStyle( - color: highlightToday - ? surfaceColors.foreground - : day.month == selectedMonth - ? null - : colorScheme.onSurfaceVariant, - fontWeight: highlightToday ? FontWeight.w600 : null, + final hoverColor = Color.alphaBlend( + surfaceColors.foreground.withValues( + alpha: Theme.of(context).brightness == Brightness.light + ? 0.06 + : 0.12, + ), + surfaceColors.card, + ); + final hoveredMarkerSize = math.min( + 32.0, + math.max(0.0, availableMarkerExtent), + ); + final currentMarkerSize = _isHovering && !selected + ? hoveredMarkerSize + : markerSize; + final backgroundColor = selected + ? colorScheme.primary + : highlightToday + ? surfaceColors.controlActive + : _isHovering + ? hoverColor + : Colors.transparent; + + return MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + child: InkWell( + onTap: () => onSelected(day), + onDoubleTap: widget.onDoubleTap == null + ? null + : () => widget.onDoubleTap!(day), + excludeFromSemantics: true, + hoverColor: Colors.transparent, + customBorder: const CircleBorder(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox.square( + dimension: currentMarkerSize, + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + ), + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '${day.day}', + style: TextStyle( + color: selected + ? colorScheme.onPrimary + : highlightToday + ? surfaceColors.foreground + : inDisplayedMonth + ? null + : colorScheme.onSurfaceVariant.withValues( + alpha: 0.45, + ), + fontWeight: selected || highlightToday + ? FontWeight.w600 + : null, + ), ), ), ), ), ), - ), - if (canShowIndicators) ...[ - const SizedBox(height: BusyMaxSpacing.xxs), - _MiniCalendarDayIndicators( - items: items, - height: indicatorHeight, - ), + if (canShowIndicators) ...[ + const SizedBox(height: BusyMaxSpacing.xxs), + _MiniCalendarDayIndicators( + colors: markerColors, + height: indicatorHeight, + ), + ], ], - ], + ), ), ); }, @@ -343,30 +575,29 @@ class _MiniCalendarDayButton extends StatelessWidget { } class _MiniCalendarDayIndicators extends StatelessWidget { - const _MiniCalendarDayIndicators({required this.items, required this.height}); + const _MiniCalendarDayIndicators({ + required this.colors, + required this.height, + }); - final List items; + final List colors; final double height; @override Widget build(BuildContext context) { - if (items.isEmpty) { + if (colors.isEmpty) { return SizedBox(height: height); } - final brightness = Theme.of(context).brightness; return SizedBox( height: height, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - for (final item in items.take(3)) + for (final color in colors.take(3)) Padding( padding: const EdgeInsets.symmetric(horizontal: 1), child: DecoratedBox( - decoration: BoxDecoration( - color: ScheduleProjection.colorForItem(item, brightness), - shape: BoxShape.circle, - ), + decoration: BoxDecoration(color: color, shape: BoxShape.circle), child: const SizedBox.square(dimension: 4), ), ), @@ -402,7 +633,7 @@ class _MiniCalendarStepper extends StatelessWidget { builder: (context, constraints) { final compact = constraints.maxWidth < - BusyMaxSizes.headerIconButton * 2 + BusyMaxSpacing.xs * 2; + _miniCalendarHeaderControlExtent * 2 + BusyMaxSpacing.xs * 2; if (compact) { return Row( children: [ @@ -413,7 +644,7 @@ class _MiniCalendarStepper extends StatelessWidget { context, colorScheme: colorScheme, tooltip: previousTooltip, - icon: YaruIcons.pan_start, + icon: BusyMaxGlyphs.startFor(Directionality.of(context)), onPressed: onPrevious, ), ), @@ -425,7 +656,7 @@ class _MiniCalendarStepper extends StatelessWidget { context, colorScheme: colorScheme, tooltip: nextTooltip, - icon: YaruIcons.pan_end, + icon: BusyMaxGlyphs.endFor(Directionality.of(context)), onPressed: onNext, ), ), @@ -440,7 +671,7 @@ class _MiniCalendarStepper extends StatelessWidget { context, colorScheme: colorScheme, tooltip: previousTooltip, - icon: YaruIcons.pan_start, + icon: BusyMaxGlyphs.startFor(Directionality.of(context)), onPressed: onPrevious, ), const SizedBox(width: BusyMaxSpacing.xs), @@ -450,7 +681,7 @@ class _MiniCalendarStepper extends StatelessWidget { context, colorScheme: colorScheme, tooltip: nextTooltip, - icon: YaruIcons.pan_end, + icon: BusyMaxGlyphs.endFor(Directionality.of(context)), onPressed: onNext, ), ], @@ -466,74 +697,102 @@ class _MiniCalendarStepper extends StatelessWidget { required IconData icon, required VoidCallback onPressed, }) { - return YaruIconButton( + return BusyMaxHeaderIconButton( tooltip: tooltip, - iconSize: BusyMaxSizes.headerIcon, + iconSize: BusyMaxSizes.iconSm, icon: Icon(icon), onPressed: onPressed, - style: busyMaxHeaderIconButtonStyle( - foregroundColor: colorScheme.onSurfaceVariant, - backgroundColor: busyMaxHeaderButtonBackground(context), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - ), + foregroundColor: colorScheme.onSurfaceVariant, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + fixedSize: const Size.square(_miniCalendarHeaderControlExtent), + shape: const CircleBorder(), ); } Widget _label(BuildContext context) { - final action = onLabelPressed; - if (action == null) { - return Text( - label, - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: busyMaxSectionHeaderStyle(context), + return _MiniCalendarHeaderLabel( + label: label, + tooltip: labelTooltip, + onPressed: onLabelPressed, + ); + } +} + +class _MiniCalendarHeaderLabel extends StatelessWidget { + const _MiniCalendarHeaderLabel({ + required this.label, + this.tooltip, + this.onPressed, + }); + + final String label; + final String? tooltip; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final labelStyle = Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ); + final labelWidget = Text( + label, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: labelStyle, + ); + if (onPressed == null) { + return SizedBox( + height: _miniCalendarHeaderControlExtent, + child: Center(child: labelWidget), ); } - final colorScheme = Theme.of(context).colorScheme; return Tooltip( - message: labelTooltip ?? label, + message: tooltip ?? label, child: TextButton( - onPressed: action, - style: busyMaxHeaderTextButtonStyle( - foregroundColor: colorScheme.onSurfaceVariant, - backgroundColor: busyMaxHeaderButtonBackground(context), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - ), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: busyMaxSectionHeaderStyle(context), - ), + onPressed: onPressed, + style: + busyMaxHeaderTextButtonStyle( + context, + foregroundColor: colorScheme.onSurface, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + ).copyWith( + minimumSize: const WidgetStatePropertyAll( + Size( + _miniCalendarHeaderControlExtent, + _miniCalendarHeaderControlExtent, + ), + ), + maximumSize: const WidgetStatePropertyAll( + Size(double.infinity, _miniCalendarHeaderControlExtent), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: BusyMaxSpacing.headerInset), + ), + ), + child: labelWidget, ), ); } } -String _monthName(DateTime date) { - const months = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ]; - return months[date.month - 1]; -} - bool _sameDay(DateTime a, DateTime b) { return a.year == b.year && a.month == b.month && a.day == b.day; } +bool _sameMonth(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month; +} + +DateTime _monthOf(DateTime date) => DateTime(date.year, date.month); + +DateTime _dayOf(DateTime date) => DateTime(date.year, date.month, date.day); + DateTime _calendarStartForMonth(DateTime first, int firstWeekday) { final monthWeekdayFromMonday = first.weekday - DateTime.monday; final firstWeekdayFromMonday = firstWeekday - DateTime.monday; diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 69e4824..d268b15 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -92,7 +92,7 @@ class _ScheduleAgendaViewState extends State { return NotificationListener( onNotification: _handleScroll, child: ColoredBox( - color: Theme.of(context).colorScheme.surface, + color: BusyMaxSurfaceColors.of(context).window, child: ListView( padding: const EdgeInsets.fromLTRB( BusyMaxSpacing.lg, @@ -204,7 +204,6 @@ class _AgendaRow extends StatelessWidget { @override Widget build(BuildContext context) { final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; - Offset? pointerDownPosition; final onAnchorAvailable = this.onAnchorAvailable; if (onAnchorAvailable != null) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -227,8 +226,7 @@ class _AgendaRow extends StatelessWidget { ? null : (value) => onTaskCompletionChanged!(value ?? false), ), - onPointerDown: (position) => pointerDownPosition = position, - onTap: () => onTap(context, pointerDownPosition), + onActivated: onTap, ); } } @@ -241,7 +239,12 @@ class _AgendaItemMarker extends StatelessWidget { @override Widget build(BuildContext context) { final isTask = item.kind == ScheduleItemKind.task; - final color = BusyMaxSurfaceColors.of(context).mutedForeground; + final color = isTask + ? BusyMaxSurfaceColors.of(context).mutedForeground + : ScheduleProjection.colorForItem( + item, + Theme.of(context).colorScheme.brightness, + ); final icon = isTask ? YaruIcons.task_list : YaruIcons.calendar; return Icon(icon, size: BusyMaxSizes.iconSm, color: color); } diff --git a/lib/src/features/schedule/presentation/schedule_anchored_popover.dart b/lib/src/features/schedule/presentation/schedule_anchored_popover.dart new file mode 100644 index 0000000..a698fd6 --- /dev/null +++ b/lib/src/features/schedule/presentation/schedule_anchored_popover.dart @@ -0,0 +1,427 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../app/busymax_design.dart'; +import '../../../app/busymax_dialogs.dart'; + +typedef ScheduleAnchoredPopoverBuilder = + Widget Function( + BuildContext context, + BusyMaxPopoverArrowSide arrowSide, + double arrowAlignment, + ); + +/// Coordinates anchored popovers with schedule-owned native header actions. +class ScheduleAnchoredPopoverController { + _SchedulePopoverRegistration? _active; + + bool get isOpen => _active != null; + + Future dismiss() { + return _active?.dismiss() ?? Future.value(); + } + + void _attach(_SchedulePopoverRegistration registration) { + _active = registration; + } + + void _detach(Object token) { + if (_active?.token == token) { + _active = null; + } + } +} + +class ScheduleAnchoredPopoverScope extends InheritedWidget { + const ScheduleAnchoredPopoverScope({ + super.key, + required this.controller, + required super.child, + }); + + final ScheduleAnchoredPopoverController controller; + + static ScheduleAnchoredPopoverController? maybeControllerOf( + BuildContext context, + ) { + return context + .dependOnInheritedWidgetOfExactType() + ?.controller; + } + + @override + bool updateShouldNotify(ScheduleAnchoredPopoverScope oldWidget) { + return oldWidget.controller != controller; + } +} + +class _SchedulePopoverRegistration { + const _SchedulePopoverRegistration({ + required this.token, + required this.dismiss, + }); + + final Object token; + final Future Function() dismiss; +} + +/// Presents a keyboard-modal desktop popover anchored to a widget or pointer. +/// +/// The route owns focus while it is visible, restores the previous focus on +/// dismissal, and constrains its child to the real space above or below the +/// anchor. Callers remain responsible only for their popover content. +Future showScheduleAnchoredPopover({ + required BuildContext context, + required BuildContext anchorContext, + required ScheduleAnchoredPopoverBuilder builder, + required String semanticLabel, + Offset? anchorPoint, + double preferredWidth = 420, + double minimumWidth = 280, + double preferredMinimumHeight = 240, +}) async { + final previousFocus = FocusManager.instance.primaryFocus; + final anchorRect = anchorPoint == null + ? scheduleGlobalRectFor(anchorContext) + : Rect.fromCenter(center: anchorPoint, width: 1, height: 1); + final disableAnimations = + MediaQuery.maybeOf(context)?.disableAnimations ?? false; + final controller = + ScheduleAnchoredPopoverScope.maybeControllerOf(anchorContext) ?? + ScheduleAnchoredPopoverScope.maybeControllerOf(context); + final navigator = Navigator.of(context, rootNavigator: true); + final route = RawDialogRoute( + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.transparent, + transitionDuration: disableAnimations + ? Duration.zero + : const Duration(milliseconds: 120), + traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, + directionalTraversalEdgeBehavior: TraversalEdgeBehavior.stop, + pageBuilder: (context, animation, secondaryAnimation) { + return _ScheduleAnchoredPopoverRoute( + anchorRect: anchorRect, + preferredWidth: preferredWidth, + minimumWidth: minimumWidth, + preferredMinimumHeight: preferredMinimumHeight, + semanticLabel: semanticLabel, + builder: builder, + ); + }, + transitionBuilder: (context, animation, secondaryAnimation, child) { + if (disableAnimations) { + return child; + } + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ); + return FadeTransition( + opacity: curved, + child: ScaleTransition( + scale: Tween(begin: 0.98, end: 1).animate(curved), + child: child, + ), + ); + }, + ); + final routeResult = navigator.push(route); + final token = Object(); + final finished = Completer(); + controller?._attach( + _SchedulePopoverRegistration( + token: token, + dismiss: () async { + if (route.isCurrent) { + navigator.pop(); + } + await finished.future; + }, + ), + ); + try { + return await routeResult; + } finally { + if (previousFocus?.context?.mounted ?? false) { + previousFocus!.requestFocus(); + } + controller?._detach(token); + if (!finished.isCompleted) { + finished.complete(); + } + } +} + +Rect? scheduleGlobalRectFor(BuildContext context) { + final renderObject = context.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) { + return null; + } + return renderObject.localToGlobal(Offset.zero) & renderObject.size; +} + +class _ScheduleAnchoredPopoverRoute extends StatelessWidget { + const _ScheduleAnchoredPopoverRoute({ + required this.anchorRect, + required this.preferredWidth, + required this.minimumWidth, + required this.preferredMinimumHeight, + required this.builder, + required this.semanticLabel, + }); + + final Rect? anchorRect; + final double preferredWidth; + final double minimumWidth; + final double preferredMinimumHeight; + final ScheduleAnchoredPopoverBuilder builder; + final String semanticLabel; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final layout = _SchedulePopoverLayout.resolve( + anchor: anchorRect, + viewport: constraints.biggest, + textDirection: Directionality.of(context), + preferredWidth: preferredWidth, + minimumWidth: minimumWidth, + preferredMinimumHeight: preferredMinimumHeight, + ); + return BusyMaxModalShortcutBoundary( + child: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + }, + child: Actions( + actions: { + DismissIntent: CallbackAction( + onInvoke: (_) { + Navigator.of(context).pop(); + return null; + }, + ), + }, + child: FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: Focus( + autofocus: true, + child: Semantics( + scopesRoute: true, + namesRoute: true, + label: semanticLabel, + explicitChildNodes: true, + child: BlockSemantics( + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => Navigator.of(context).pop(), + ), + ), + Positioned.fill( + child: CustomSingleChildLayout( + delegate: _SchedulePopoverPositionDelegate( + layout, + ), + child: builder( + context, + layout.arrowSide, + layout.arrowAlignment, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +class _SchedulePopoverLayout { + const _SchedulePopoverLayout({ + required this.anchor, + required this.left, + required this.width, + required this.maximumHeight, + required this.horizontalMargin, + required this.verticalMargin, + required this.arrowSide, + required this.arrowAlignment, + }); + + factory _SchedulePopoverLayout.resolve({ + required Rect? anchor, + required Size viewport, + required TextDirection textDirection, + required double preferredWidth, + required double minimumWidth, + required double preferredMinimumHeight, + }) { + const gap = BusyMaxSpacing.xs; + final horizontalMargin = _adaptivePopoverMargin( + viewport.width, + preferredMinimumExtent: minimumWidth, + ); + final verticalMargin = _adaptivePopoverMargin( + viewport.height, + preferredMinimumExtent: preferredMinimumHeight, + ); + final safeViewportWidth = math.max(1.0, viewport.width); + final safeHorizontalMargin = math.min( + horizontalMargin, + safeViewportWidth / 2, + ); + final availableWidth = math.max( + 1.0, + safeViewportWidth - safeHorizontalMargin * 2, + ); + final width = math + .min(preferredWidth, availableWidth) + .clamp(1.0, availableWidth); + final maximumLeft = math.max( + safeHorizontalMargin, + safeViewportWidth - width - safeHorizontalMargin, + ); + if (anchor == null) { + return _SchedulePopoverLayout( + anchor: null, + left: ((safeViewportWidth - width) / 2) + .clamp(safeHorizontalMargin, maximumLeft) + .toDouble(), + width: width, + maximumHeight: math.max(1.0, viewport.height - verticalMargin * 2), + horizontalMargin: safeHorizontalMargin, + verticalMargin: verticalMargin, + arrowSide: BusyMaxPopoverArrowSide.top, + arrowAlignment: 0.5, + ); + } + + final preferredLeft = textDirection == TextDirection.rtl + ? anchor.right - width + : anchor.left; + final left = preferredLeft + .clamp(safeHorizontalMargin, maximumLeft) + .toDouble(); + final spaceAbove = math.max(0.0, anchor.top - gap - verticalMargin); + final spaceBelow = math.max( + 0.0, + viewport.height - anchor.bottom - gap - verticalMargin, + ); + final showBelow = + spaceBelow >= math.min(preferredMinimumHeight, spaceAbove) || + spaceBelow >= spaceAbove; + final arrowAlignment = width <= 0 + ? 0.5 + : ((anchor.center.dx - left) / width).clamp(0.08, 0.92).toDouble(); + final resolvedMaximumHeight = math.max( + 1.0, + showBelow ? spaceBelow : spaceAbove, + ); + return _SchedulePopoverLayout( + anchor: anchor, + left: left, + width: width, + maximumHeight: resolvedMaximumHeight, + horizontalMargin: horizontalMargin, + verticalMargin: verticalMargin, + arrowSide: showBelow + ? BusyMaxPopoverArrowSide.top + : BusyMaxPopoverArrowSide.bottom, + arrowAlignment: arrowAlignment, + ); + } + + final Rect? anchor; + final double left; + final double width; + final double maximumHeight; + final double horizontalMargin; + final double verticalMargin; + final BusyMaxPopoverArrowSide arrowSide; + final double arrowAlignment; +} + +class _SchedulePopoverPositionDelegate extends SingleChildLayoutDelegate { + const _SchedulePopoverPositionDelegate(this.layout); + + final _SchedulePopoverLayout layout; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return BoxConstraints( + minWidth: layout.width, + maxWidth: layout.width, + maxHeight: layout.maximumHeight, + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + const gap = BusyMaxSpacing.xs; + final maximumTop = math.max( + layout.verticalMargin, + size.height - childSize.height - layout.verticalMargin, + ); + final anchor = layout.anchor; + if (anchor == null) { + return Offset( + layout.left, + ((size.height - childSize.height) / 2) + .clamp(layout.verticalMargin, maximumTop) + .toDouble(), + ); + } + final preferredTop = switch (layout.arrowSide) { + BusyMaxPopoverArrowSide.top => anchor.bottom + gap, + BusyMaxPopoverArrowSide.bottom => anchor.top - childSize.height - gap, + }; + return Offset( + layout.left, + preferredTop.clamp(layout.verticalMargin, maximumTop).toDouble(), + ); + } + + @override + bool shouldRelayout(covariant _SchedulePopoverPositionDelegate oldDelegate) { + return layout.anchor != oldDelegate.layout.anchor || + layout.left != oldDelegate.layout.left || + layout.width != oldDelegate.layout.width || + layout.maximumHeight != oldDelegate.layout.maximumHeight || + layout.horizontalMargin != oldDelegate.layout.horizontalMargin || + layout.verticalMargin != oldDelegate.layout.verticalMargin || + layout.arrowSide != oldDelegate.layout.arrowSide || + layout.arrowAlignment != oldDelegate.layout.arrowAlignment; + } +} + +double _adaptivePopoverMargin( + double extent, { + required double preferredMinimumExtent, +}) { + final availableSurplus = extent - preferredMinimumExtent; + if (availableSurplus <= 0) { + return 0; + } + return math.min(BusyMaxShadow.nativePopoverPaintMargin, availableSurplus / 2); +} diff --git a/lib/src/features/schedule/presentation/schedule_create_menu.dart b/lib/src/features/schedule/presentation/schedule_create_menu.dart index 8e3d4a8..4fb7a78 100644 --- a/lib/src/features/schedule/presentation/schedule_create_menu.dart +++ b/lib/src/features/schedule/presentation/schedule_create_menu.dart @@ -1,46 +1,59 @@ import 'package:flutter/material.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_shortcuts.dart'; import '../../../l10n/l10n.dart'; enum ScheduleCreateChoice { event, task } +ScheduleCreateChoice? singleAvailableScheduleCreateChoice({ + required bool canCreateEvent, + required bool canCreateTask, +}) { + if (canCreateEvent == canCreateTask) { + return null; + } + return canCreateEvent + ? ScheduleCreateChoice.event + : ScheduleCreateChoice.task; +} + Future showScheduleCreateMenu({ required BuildContext context, -}) { - return showDialog( + BuildContext? anchorContext, + Offset? anchorPoint, + bool canCreateEvent = true, + bool canCreateTask = true, + BusyMaxMenuSession? session, + bool preferAbove = false, +}) async { + if (!canCreateEvent && !canCreateTask) { + return null; + } + + final selection = await showBusyMaxMenu( context: context, - builder: (context) { - return Dialog( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 320), - child: Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - context.l10n.createChoiceTitle, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: BusyMaxSpacing.md), - BusyMaxPushButton.outlined( - onPressed: () => - Navigator.of(context).pop(ScheduleCreateChoice.event), - child: Text(context.l10n.createEventAtTime), - ), - const SizedBox(height: BusyMaxSpacing.sm), - BusyMaxPushButton.outlined( - onPressed: () => - Navigator.of(context).pop(ScheduleCreateChoice.task), - child: Text(context.l10n.createTaskAtDate), - ), - ], - ), - ), - ), - ); - }, + anchorContext: anchorContext ?? context, + anchorPoint: anchorPoint, + session: session, + focusFirst: anchorPoint == null, + preferAbove: preferAbove, + entries: [ + BusyMaxMenuEntry( + value: ScheduleCreateChoice.event, + label: context.l10n.createEventAtTime, + icon: Icons.event_outlined, + enabled: canCreateEvent, + shortcut: BusyMaxShortcutLabels.newEvent, + ), + BusyMaxMenuEntry( + value: ScheduleCreateChoice.task, + label: context.l10n.createTaskAtDate, + icon: Icons.task_alt_outlined, + enabled: canCreateTask, + shortcut: BusyMaxShortcutLabels.newTask, + ), + ], ); + return selection?.value; } diff --git a/lib/src/features/schedule/presentation/schedule_day_week_view.dart b/lib/src/features/schedule/presentation/schedule_day_week_view.dart index 9e6a05b..dce5e8f 100644 --- a/lib/src/features/schedule/presentation/schedule_day_week_view.dart +++ b/lib/src/features/schedule/presentation/schedule_day_week_view.dart @@ -96,11 +96,12 @@ class _ScheduleDayWeekViewState extends State { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); - final borderColor = busyMaxPanelBorder(context); + final workspaceColor = surfaceColors.window; + final gridColor = busyMaxCalendarGridColor(context); final todayOverlayAlpha = widget.daysShowed == 1 ? 0.0 : 0.035; final todayColor = Color.alphaBlend( surfaceColors.controlActive.withValues(alpha: todayOverlayAlpha), - colorScheme.surface, + workspaceColor, ); final showFullDayBar = _hasRenderedFullDayEvents(context, widget); final fullDayBarHeight = showFullDayBar ? _fullDayBarHeight : 0.0; @@ -124,7 +125,7 @@ class _ScheduleDayWeekViewState extends State { onDayChange: (day) => widget.onDaySelected(_day(day)), daysHeaderParam: icv.DaysHeaderParam( daysHeaderHeight: widget.daysShowed == 1 ? 0 : 50, - daysHeaderColor: colorScheme.surface, + daysHeaderColor: workspaceColor, dayHeaderBuilder: (day, isToday) => widget.daysShowed == 1 ? const SizedBox.shrink() : _PlannerDayHeader(day: day, isToday: isToday), @@ -144,10 +145,10 @@ class _ScheduleDayWeekViewState extends State { ), ), fullDayEventsBarDecoration: BoxDecoration( - color: colorScheme.surface, - border: Border(bottom: BorderSide(color: borderColor)), + color: workspaceColor, + border: Border(bottom: BorderSide(color: gridColor)), ), - fullDayBackgroundColor: colorScheme.surface, + fullDayBackgroundColor: workspaceColor, fullDayEventsBuilder: (events, width) { return _FullDayScrollPane( events: events, @@ -195,7 +196,7 @@ class _ScheduleDayWeekViewState extends State { ), dayParam: icv.DayParam( todayColor: todayColor, - dayColor: colorScheme.surface, + dayColor: workspaceColor, dayTopPadding: 8, dayBottomPadding: 16, onSlotMinutesRound: 15, @@ -203,7 +204,7 @@ class _ScheduleDayWeekViewState extends State { dayCustomPainter: (heightPerMinute, isToday) => icv.LinesPainter( heightPerMinute: heightPerMinute, isToday: isToday, - lineColor: borderColor, + lineColor: gridColor, hourStrokeWidth: 0.7, halfStrokeWidth: 0.35, quarterStrokeWidth: 0, @@ -277,7 +278,7 @@ class _ScheduleDayWeekViewState extends State { ), offTimesColor: Color.alphaBlend( colorScheme.onSurface.withValues(alpha: 0.025), - colorScheme.surface, + workspaceColor, ), offTimesAllDaysPainter: (column, day, isToday, heightPerMinute, ranges, color) => @@ -487,8 +488,10 @@ class _PlannerDayHeader extends StatelessWidget { return Container( alignment: Alignment.center, decoration: BoxDecoration( - color: colorScheme.surface, - border: Border(bottom: BorderSide(color: busyMaxPanelBorder(context))), + color: surfaceColors.window, + border: Border( + bottom: BorderSide(color: busyMaxCalendarGridColor(context)), + ), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -667,11 +670,12 @@ class _AllDayResizeHandle extends StatelessWidget { Widget build(BuildContext context) { final surfaceColors = BusyMaxSurfaceColors.of(context); final colorScheme = Theme.of(context).colorScheme; + final resizeLabel = context.l10n.resizeAllDayPanel; return Tooltip( - message: 'Resize all-day panel', + message: resizeLabel, child: Semantics( button: true, - label: 'Resize all-day panel', + label: resizeLabel, child: MouseRegion( cursor: SystemMouseCursors.resizeUpDown, child: GestureDetector( @@ -848,7 +852,7 @@ class _ScheduleIcvEvent { else entries.first.event.copyWith( title: entries.first.item.title, - description: '${entries.length} items', + description: context.l10n.scheduleItemCount(entries.length), data: _ScheduleSlotGroup([for (final entry in entries) entry.item]), eventType: _ScheduleSlotGroup, ), diff --git a/lib/src/features/schedule/presentation/schedule_empty_states.dart b/lib/src/features/schedule/presentation/schedule_empty_states.dart index 22fb00f..02f28da 100644 --- a/lib/src/features/schedule/presentation/schedule_empty_states.dart +++ b/lib/src/features/schedule/presentation/schedule_empty_states.dart @@ -3,6 +3,94 @@ import 'package:flutter/material.dart'; import '../../../app/busymax_design.dart'; import '../../../l10n/l10n.dart'; +class ScheduleLoadingState extends StatelessWidget { + const ScheduleLoadingState({super.key}); + + @override + Widget build(BuildContext context) { + final label = context.l10n.scheduleLoading; + return Semantics( + container: true, + liveRegion: true, + label: label, + child: ExcludeSemantics( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: BusyMaxSpacing.lg), + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} + +class ScheduleNoSourcesState extends StatelessWidget { + const ScheduleNoSourcesState({ + super.key, + required this.hasAccounts, + required this.onOpenSettings, + this.onRefresh, + }); + + final bool hasAccounts; + final VoidCallback onOpenSettings; + final VoidCallback? onRefresh; + + @override + Widget build(BuildContext context) { + return BusyMaxEmptyState( + icon: Icons.calendar_month_outlined, + title: hasAccounts + ? context.l10n.scheduleNoSources + : context.l10n.scheduleSignInRequired, + message: hasAccounts + ? context.l10n.scheduleNoSourcesDescription + : context.l10n.scheduleSignInDescription, + actions: [ + BusyMaxPushButton.suggested( + onPressed: onOpenSettings, + child: Text(context.l10n.settings), + ), + if (onRefresh != null) + BusyMaxPushButton.standard( + onPressed: onRefresh, + child: Text(context.l10n.refresh), + ), + ], + ); + } +} + +class ScheduleUnavailableState extends StatelessWidget { + const ScheduleUnavailableState({super.key, required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return BusyMaxEmptyState( + icon: Icons.sync_problem_outlined, + title: context.l10n.scheduleUnavailable, + actions: [ + BusyMaxPushButton.suggested( + onPressed: onRetry, + child: Text(context.l10n.retry), + ), + ], + ); + } +} + class ScheduleEmptyState extends StatelessWidget { const ScheduleEmptyState({ super.key, @@ -10,8 +98,8 @@ class ScheduleEmptyState extends StatelessWidget { required this.onNewTask, }); - final VoidCallback onNewEvent; - final VoidCallback onNewTask; + final VoidCallback? onNewEvent; + final VoidCallback? onNewTask; @override Widget build(BuildContext context) { @@ -19,15 +107,30 @@ class ScheduleEmptyState extends StatelessWidget { icon: Icons.event_available, title: context.l10n.noEventsOrTasks, actions: [ - BusyMaxPushButton.outlined( - onPressed: onNewEvent, - child: Text(context.l10n.newEvent), - ), - BusyMaxPushButton.outlined( - onPressed: onNewTask, - child: Text(context.l10n.newTask), - ), + if (onNewEvent != null) + BusyMaxPushButton.standard( + onPressed: onNewEvent, + child: Text(context.l10n.newEvent), + ), + if (onNewTask != null) + BusyMaxPushButton.standard( + onPressed: onNewTask, + child: Text(context.l10n.newTask), + ), ], ); } } + +class ScheduleSearchEmptyState extends StatelessWidget { + const ScheduleSearchEmptyState({super.key}); + + @override + Widget build(BuildContext context) { + return BusyMaxEmptyState( + icon: Icons.search_off_outlined, + title: context.l10n.scheduleNoSearchResults, + message: context.l10n.scheduleNoSearchResultsDescription, + ); + } +} diff --git a/lib/src/features/schedule/presentation/schedule_event_block.dart b/lib/src/features/schedule/presentation/schedule_event_block.dart index adf6908..b803e25 100644 --- a/lib/src/features/schedule/presentation/schedule_event_block.dart +++ b/lib/src/features/schedule/presentation/schedule_event_block.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_surface_colors.dart'; @@ -7,7 +8,7 @@ import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import 'schedule_item_selection.dart'; -class ScheduleEventBlock extends StatelessWidget { +class ScheduleEventBlock extends StatefulWidget { const ScheduleEventBlock({ super.key, required this.item, @@ -23,78 +24,140 @@ class ScheduleEventBlock extends StatelessWidget { final bool compact; final ScheduleItemTapCallback? onTap; + @override + State createState() => _ScheduleEventBlockState(); +} + +class _ScheduleEventBlockState extends State { + static const _activationShortcuts = { + SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), + }; + + Offset? _pointerDownPosition; + var _showFocusHighlight = false; + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); - final blockHeight = scheduleSafeBlockHeight(height); - final blockWidth = scheduleSafeBlockWidth(width); - final dense = compact || blockHeight < 36; + final blockHeight = scheduleSafeBlockHeight(widget.height); + final blockWidth = scheduleSafeBlockWidth(widget.width); + final dense = widget.compact || blockHeight < 36; final verticalPadding = dense ? 2.0 : 5.0; final contentHeight = blockHeight - verticalPadding * 2; final timeRange = _timeRange(context); - final showTime = !item.allDay && !dense && contentHeight >= 34; + final showTime = !widget.item.allDay && !dense && contentHeight >= 34; final titleMaxLines = showTime || contentHeight < 36 ? 1 : 2; final tooltipDetails = _tooltipDetails(context); + final interactive = widget.onTap != null; + final focusBorder = BorderSide(color: colorScheme.primary, width: 2); + final sourceAccent = ScheduleProjection.colorForItem( + widget.item, + colorScheme.brightness, + ); - Offset? pointerDownPosition; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: onTap == null - ? null - : (details) => pointerDownPosition = details.globalPosition, - onTap: onTap == null ? null : () => onTap!(context, pointerDownPosition), - child: Tooltip( - message: tooltipDetails.isEmpty - ? item.title - : '${item.title}\n$tooltipDetails', - waitDuration: const Duration(milliseconds: 600), - child: SizedBox( - width: blockWidth, - height: blockHeight, - child: Material( - color: Colors.transparent, - child: Container( - clipBehavior: Clip.antiAlias, - padding: EdgeInsets.symmetric( - horizontal: dense ? 6 : 8, - vertical: verticalPadding, - ), - decoration: BoxDecoration( - color: surfaceColors.control, - borderRadius: BorderRadius.circular(BusyMaxRadius.sm), - border: Border( - left: BorderSide(color: surfaceColors.subtleBorder, width: 4), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: showTime - ? MainAxisAlignment.spaceBetween - : MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - Flexible( - child: Text( - item.title, - maxLines: titleMaxLines, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, + return Semantics( + container: true, + button: interactive, + enabled: interactive, + label: _semanticsLabel(context), + onTap: interactive ? _activateWithoutPointer : null, + excludeSemantics: true, + child: FocusableActionDetector( + enabled: interactive, + mouseCursor: interactive ? SystemMouseCursors.click : MouseCursor.defer, + shortcuts: _activationShortcuts, + actions: >{ + ActivateIntent: CallbackAction( + onInvoke: (_) { + _activateWithoutPointer(); + return null; + }, + ), + }, + onShowFocusHighlight: (value) { + if (_showFocusHighlight != value) { + setState(() => _showFocusHighlight = value); + } + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: !interactive + ? null + : (details) => _pointerDownPosition = details.globalPosition, + onTapCancel: !interactive ? null : () => _pointerDownPosition = null, + onTap: interactive ? _activateFromPointer : null, + onSecondaryTapDown: !interactive + ? null + : (details) => _pointerDownPosition = details.globalPosition, + onSecondaryTap: interactive ? _activateFromPointer : null, + child: Tooltip( + excludeFromSemantics: true, + message: tooltipDetails.isEmpty + ? widget.item.title + : '${widget.item.title}\n$tooltipDetails', + waitDuration: const Duration(milliseconds: 600), + child: SizedBox( + width: blockWidth, + height: blockHeight, + child: Material( + color: Colors.transparent, + child: Container( + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.symmetric( + horizontal: dense ? 6 : 8, + vertical: verticalPadding, + ), + decoration: BoxDecoration( + color: surfaceColors.control, + borderRadius: BorderRadius.circular(BusyMaxRadius.sm), + border: Border( + left: BorderSide( + color: _showFocusHighlight + ? colorScheme.primary + : sourceAccent, + width: 4, ), + top: _showFocusHighlight ? focusBorder : BorderSide.none, + right: _showFocusHighlight + ? focusBorder + : BorderSide.none, + bottom: _showFocusHighlight + ? focusBorder + : BorderSide.none, ), ), - if (showTime && timeRange.isNotEmpty) - Text( - timeRange, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: showTime + ? MainAxisAlignment.spaceBetween + : MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + child: Text( + widget.item.title, + maxLines: titleMaxLines, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), ), - ), - ], + if (showTime && timeRange.isNotEmpty) + Text( + timeRange, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), ), ), ), @@ -105,25 +168,47 @@ class ScheduleEventBlock extends StatelessWidget { String _tooltipDetails(BuildContext context) { final parts = [ - if (!item.allDay && item.start != null) _timeRange(context), - if (item.location != null && item.location!.isNotEmpty) item.location!, - ScheduleProjection.sourceLabelForScheduleItem(item), + if (!widget.item.allDay && widget.item.start != null) _timeRange(context), + if (widget.item.location != null && widget.item.location!.isNotEmpty) + widget.item.location!, + ScheduleProjection.sourceLabelForScheduleItem(widget.item), ]; return parts.join(' · '); } + String _semanticsLabel(BuildContext context) { + return [ + widget.item.title, + scheduleTimeRange(context, widget.item), + if (widget.item.location != null && widget.item.location!.isNotEmpty) + widget.item.location!, + ScheduleProjection.sourceLabelForScheduleItem(widget.item), + ].where((part) => part.isNotEmpty).join(', '); + } + String _timeRange(BuildContext context) { - final start = item.start; + final start = widget.item.start; if (start == null) { return ''; } final startText = _formatTime(context, start); - final end = item.end; + final end = widget.item.end; if (end == null) { return startText; } return '$startText-${_formatTime(context, end)}'; } + + void _activateFromPointer() { + final pointerDownPosition = _pointerDownPosition; + _pointerDownPosition = null; + widget.onTap?.call(context, pointerDownPosition); + } + + void _activateWithoutPointer() { + _pointerDownPosition = null; + widget.onTap?.call(context); + } } double? scheduleSafeBlockWidth(double? width) { diff --git a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart index a0e8625..8a76f5d 100644 --- a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart @@ -1,15 +1,13 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; import 'package:intl/intl.dart' hide TextDirection; import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; -import '../../../app/busymax_yaru_theme.dart'; import '../../../calendar_providers/calendar_description.dart'; import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; +import 'schedule_anchored_popover.dart'; import 'schedule_event_block.dart'; enum ScheduleItemDetailsAction { export, edit, delete } @@ -20,170 +18,90 @@ Future showScheduleItemDetailsPopover({ required ScheduleItem item, Offset? anchorPoint, }) { - final anchorRect = anchorPoint == null - ? _globalRectFor(anchorContext) - : _globalRectForPoint(anchorPoint); - return showGeneralDialog( + return showScheduleAnchoredPopover( context: context, - barrierDismissible: true, - barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, - barrierColor: Colors.transparent, - transitionDuration: const Duration(milliseconds: 120), - pageBuilder: (context, animation, secondaryAnimation) { - return _ScheduleItemDetailsPopover(anchorRect: anchorRect, item: item); - }, - transitionBuilder: (context, animation, secondaryAnimation, child) { - final curved = CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - ); - return FadeTransition( - opacity: curved, - child: ScaleTransition( - scale: Tween(begin: 0.98, end: 1).animate(curved), - child: child, - ), + anchorContext: anchorContext, + anchorPoint: anchorPoint, + semanticLabel: item.title, + builder: (context, arrowSide, arrowAlignment) { + return _ScheduleItemDetailsPopoverCard( + item: item, + arrowSide: arrowSide, + arrowAlignment: arrowAlignment, ); }, ); } -class _ScheduleItemDetailsPopover extends StatelessWidget { - const _ScheduleItemDetailsPopover({ - required this.anchorRect, - required this.item, - }); - - final Rect? anchorRect; - final ScheduleItem item; - - @override - Widget build(BuildContext context) { - final surfaceColors = BusyMaxSurfaceColors.of(context); - final colorScheme = Theme.of(context).colorScheme; - final itemColor = ScheduleProjection.colorForItem( - item, - colorScheme.brightness, - ); - - return Material( - color: Colors.transparent, - child: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - final layout = _popoverLayout( - anchorRect, - constraints.biggest, - textDirection: Directionality.of(context), - ); - return Stack( - children: [ - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => Navigator.of(context).pop(), - ), - ), - Positioned.fill( - child: CustomSingleChildLayout( - delegate: _PopoverPositionDelegate(layout), - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: layout.width, - maxWidth: layout.width, - ), - child: _ScheduleItemDetailsPopoverCard( - item: item, - itemColor: itemColor, - surfaceColors: surfaceColors, - arrowSide: layout.arrowSide, - arrowAlignment: layout.arrowAlignment, - ), - ), - ), - ), - ], - ); - }, - ), - ), - ); - } -} - class _ScheduleItemDetailsPopoverCard extends StatelessWidget { const _ScheduleItemDetailsPopoverCard({ required this.item, - required this.itemColor, - required this.surfaceColors, required this.arrowSide, required this.arrowAlignment, }); final ScheduleItem item; - final Color itemColor; - final BusyMaxSurfaceColors surfaceColors; final BusyMaxPopoverArrowSide arrowSide; final double arrowAlignment; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final itemColor = ScheduleProjection.colorForItem( + item, + colorScheme.brightness, + ); return Material( color: Colors.transparent, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: BusyMaxPopoverSurface( - color: surfaceColors.popover, - arrowSide: arrowSide, - arrowAlignment: arrowAlignment, - child: Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: BusyMaxSizes.iconSm, - height: BusyMaxSizes.iconSm, - margin: const EdgeInsets.only(top: BusyMaxSpacing.xs), - decoration: BoxDecoration( - color: itemColor, - shape: BoxShape.circle, - ), + child: BusyMaxContentPopoverSurface( + arrowSide: arrowSide, + arrowAlignment: arrowAlignment, + child: SingleChildScrollView( + padding: const EdgeInsets.all(BusyMaxSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: BusyMaxSizes.iconSm, + height: BusyMaxSizes.iconSm, + margin: const EdgeInsets.only(top: BusyMaxSpacing.xs), + decoration: BoxDecoration( + color: itemColor, + shape: BoxShape.circle, ), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - item.title, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: BusyMaxSpacing.xxs), - Text( - _kindLabel(context, item), - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), + ), + const SizedBox(width: BusyMaxSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.title, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: BusyMaxSpacing.xxs), + Text( + _kindLabel(context, item), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], ), - const SizedBox(width: BusyMaxSpacing.sm), - _PopoverActions(item: item), - ], - ), - const SizedBox(height: BusyMaxSpacing.lg), - _ScheduleItemDetails(item: item), - ], - ), + ), + const SizedBox(width: BusyMaxSpacing.sm), + _PopoverActions(item: item), + ], + ), + const SizedBox(height: BusyMaxSpacing.lg), + _ScheduleItemDetails(item: item), + ], ), ), ), @@ -198,162 +116,46 @@ class _PopoverActions extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - BusyMaxCircularAction( - icon: Icons.download_outlined, - tooltip: context.l10n.export, - onPressed: () => - Navigator.of(context).pop(ScheduleItemDetailsAction.export), - ), - const SizedBox(width: BusyMaxSpacing.xs), - BusyMaxCircularAction( + final actions = [ + BusyMaxPopoverIconButton( + icon: YaruIcons.share, + tooltip: context.l10n.export, + onPressed: () => + Navigator.of(context).pop(ScheduleItemDetailsAction.export), + ), + if (item.capabilities.canEdit) + BusyMaxPopoverIconButton( icon: Icons.edit_outlined, tooltip: _editLabel(context, item), onPressed: () => Navigator.of(context).pop(ScheduleItemDetailsAction.edit), ), - const SizedBox(width: BusyMaxSpacing.xs), - BusyMaxCircularAction( - icon: Icons.delete_outline, + if (item.capabilities.canDelete) + BusyMaxPopoverIconButton( + icon: YaruIcons.trash, tooltip: context.l10n.delete, destructive: true, onPressed: () => Navigator.of(context).pop(ScheduleItemDetailsAction.delete), ), - const SizedBox(width: BusyMaxSpacing.xs), - BusyMaxCircularAction( - icon: Icons.close, - tooltip: MaterialLocalizations.of(context).closeButtonTooltip, - onPressed: () => Navigator.of(context).pop(), - ), + BusyMaxPopoverIconButton( + icon: YaruIcons.window_close, + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: () => Navigator.of(context).pop(), + ), + ]; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < actions.length; index += 1) ...[ + if (index > 0) const SizedBox(width: BusyMaxSpacing.xs), + actions[index], + ], ], ); } } -Rect? _globalRectFor(BuildContext context) { - final renderObject = context.findRenderObject(); - if (renderObject is! RenderBox || !renderObject.hasSize) { - return null; - } - return renderObject.localToGlobal(Offset.zero) & renderObject.size; -} - -Rect _globalRectForPoint(Offset point) { - return Rect.fromCenter(center: point, width: 1, height: 1); -} - -_PopoverLayout _popoverLayout( - Rect? anchor, - Size viewport, { - required TextDirection textDirection, -}) { - const margin = BusyMaxSpacing.md; - const gap = BusyMaxSpacing.xs; - const preferredWidth = 420.0; - const minWidth = 280.0; - const estimatedHeight = 310.0; - - final availableWidth = math.max(0.0, viewport.width - margin * 2); - final width = availableWidth < minWidth - ? availableWidth - : math.min(preferredWidth, availableWidth); - final maxLeft = math.max(margin, viewport.width - width - margin); - - if (anchor == null) { - return _PopoverLayout( - anchor: null, - left: ((viewport.width - width) / 2).clamp(margin, maxLeft).toDouble(), - width: width, - arrowSide: BusyMaxPopoverArrowSide.top, - arrowAlignment: 0.5, - ); - } - - final preferredLeft = textDirection == TextDirection.rtl - ? anchor.right - width - : anchor.left; - final left = preferredLeft.clamp(margin, maxLeft).toDouble(); - final below = anchor.bottom + gap; - final showBelow = below + estimatedHeight <= viewport.height - margin; - final arrowAlignment = ((anchor.center.dx - left) / width) - .clamp(0.08, 0.92) - .toDouble(); - - return _PopoverLayout( - anchor: anchor, - left: left, - width: width, - arrowSide: showBelow - ? BusyMaxPopoverArrowSide.top - : BusyMaxPopoverArrowSide.bottom, - arrowAlignment: arrowAlignment, - ); -} - -class _PopoverLayout { - const _PopoverLayout({ - required this.anchor, - required this.left, - required this.width, - required this.arrowSide, - required this.arrowAlignment, - }); - - final Rect? anchor; - final double left; - final double width; - final BusyMaxPopoverArrowSide arrowSide; - final double arrowAlignment; -} - -class _PopoverPositionDelegate extends SingleChildLayoutDelegate { - const _PopoverPositionDelegate(this.layout); - - final _PopoverLayout layout; - - @override - BoxConstraints getConstraintsForChild(BoxConstraints constraints) { - const margin = BusyMaxSpacing.md; - return BoxConstraints( - minWidth: layout.width, - maxWidth: layout.width, - maxHeight: math.max(0, constraints.maxHeight - margin * 2), - ); - } - - @override - Offset getPositionForChild(Size size, Size childSize) { - const margin = BusyMaxSpacing.md; - const gap = BusyMaxSpacing.xs; - final maxTop = math.max(margin, size.height - childSize.height - margin); - final anchor = layout.anchor; - - if (anchor == null) { - final centered = (size.height - childSize.height) / 2; - return Offset(layout.left, centered.clamp(margin, maxTop).toDouble()); - } - - final preferredTop = switch (layout.arrowSide) { - BusyMaxPopoverArrowSide.top => anchor.bottom + gap, - BusyMaxPopoverArrowSide.bottom => anchor.top - childSize.height - gap, - }; - return Offset(layout.left, preferredTop.clamp(margin, maxTop).toDouble()); - } - - @override - bool shouldRelayout(covariant _PopoverPositionDelegate oldDelegate) { - final old = oldDelegate.layout; - return layout.anchor != old.anchor || - layout.left != old.left || - layout.width != old.width || - layout.arrowSide != old.arrowSide || - layout.arrowAlignment != old.arrowAlignment; - } -} - class _ScheduleItemDetails extends StatelessWidget { const _ScheduleItemDetails({required this.item}); @@ -483,7 +285,7 @@ List _eventDetails(BuildContext context, CalendarScheduleItem item) { icon: Icons.notifications_outlined, text: '${context.l10n.reminder}: ' - '${item.reminderMinutesBeforeStart.map(_reminderBeforeLabel).join(', ')}', + '${item.reminderMinutesBeforeStart.map((minutes) => _reminderBeforeLabel(context, minutes)).join(', ')}', ), if (item.categories.isNotEmpty) _ScheduleDetailRow( @@ -526,16 +328,17 @@ List _taskDetails(BuildContext context, TaskScheduleItem item) { ]; } -String _reminderBeforeLabel(int minutes) { +String _reminderBeforeLabel(BuildContext context, int minutes) { return switch (minutes) { - 0 => 'At start', - 1 => '1 minute before', - < 60 => '$minutes minutes before', - 60 => '1 hour before', - < 1440 when minutes % 60 == 0 => '${minutes ~/ 60} hours before', - 1440 => '1 day before', - > 1440 when minutes % 1440 == 0 => '${minutes ~/ 1440} days before', - _ => '$minutes minutes before', + 0 => context.l10n.reminderAtStart, + < 60 => context.l10n.reminderMinutesBefore(minutes), + < 1440 when minutes % 60 == 0 => context.l10n.reminderHoursBefore( + minutes ~/ 60, + ), + >= 1440 when minutes % 1440 == 0 => context.l10n.reminderDaysBefore( + minutes ~/ 1440, + ), + _ => context.l10n.reminderMinutesBefore(minutes), }; } diff --git a/lib/src/features/schedule/presentation/schedule_month_view.dart b/lib/src/features/schedule/presentation/schedule_month_view.dart index 4b417ab..4e0e8c1 100644 --- a/lib/src/features/schedule/presentation/schedule_month_view.dart +++ b/lib/src/features/schedule/presentation/schedule_month_view.dart @@ -10,10 +10,14 @@ import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_range.dart'; +import 'calendar_day_semantics.dart'; import 'schedule_item_chip.dart'; import 'schedule_item_selection.dart'; import 'schedule_more_popover.dart'; +typedef ScheduleDayCreateCallback = + void Function(DateTime day, {BuildContext? anchorContext}); + class ScheduleMonthView extends StatelessWidget { const ScheduleMonthView({ super.key, @@ -32,7 +36,7 @@ class ScheduleMonthView extends StatelessWidget { final List items; final int firstWeekday; final ValueChanged onDaySelected; - final ValueChanged onCreateAtDay; + final ScheduleDayCreateCallback onCreateAtDay; final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; @@ -45,14 +49,13 @@ class ScheduleMonthView extends StatelessWidget { final month = DateTime(selectedDate.year, selectedDate.month); final grouped = ScheduleProjection.groupByDay(items); final theme = Theme.of(context); - final border = theme.colorScheme.onSurface.withValues( - alpha: theme.brightness == Brightness.dark ? 0.06 : 0.10, - ); + final workspaceColor = BusyMaxSurfaceColors.of(context).window; + final border = busyMaxCalendarGridColor(context); return Column( children: [ ColoredBox( - color: theme.colorScheme.surface, + color: workspaceColor, child: SizedBox( height: 34, child: Row( @@ -110,7 +113,8 @@ class ScheduleMonthView extends StatelessWidget { selected: DateUtils.isSameDay(day, selectedDate), items: grouped[day] ?? const [], onSelect: () => onDaySelected(day), - onCreate: () => onCreateAtDay(day), + onCreate: (anchorContext) => + onCreateAtDay(day, anchorContext: anchorContext), onItemSelected: onItemSelected, onTaskCompletionChanged: onTaskCompletionChanged, ), @@ -142,136 +146,163 @@ class _MonthDayCell extends StatelessWidget { final bool selected; final List items; final VoidCallback onSelect; - final VoidCallback onCreate; + final ValueChanged onCreate; final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); + final workspaceColor = surfaceColors.window; final today = DateUtils.isSameDay(day, DateTime.now()); - return Material( - color: selected - ? Color.alphaBlend(surfaceColors.control, colorScheme.surface) - : colorScheme.surface, - child: InkWell( - onTap: onSelect, - onDoubleTap: onCreate, - child: LayoutBuilder( - builder: (context, constraints) { - const headerHeight = 24.0; - const itemHeight = 22.0; - const moreHeight = 24.0; - const itemGap = BusyMaxSpacing.xs; - final contentHeight = math.max( - 0.0, - constraints.maxHeight - BusyMaxSpacing.xs * 2, - ); - final availableRowsHeight = math.max( - 0.0, - contentHeight - headerHeight - itemGap, - ); - final rowSlots = (availableRowsHeight / (itemHeight + itemGap)) - .floor(); - final needsOverflowRow = items.length > rowSlots; - final visibleCount = needsOverflowRow - ? math.max(0, rowSlots - 1) - : math.min(items.length, rowSlots); - final visible = items.take(visibleCount).toList(); - final overflow = items.length - visible.length; - final showOverflow = - overflow > 0 && availableRowsHeight >= moreHeight; + return BusyMaxCalendarDaySemantics( + day: day, + selected: selected, + onTap: onSelect, + child: Material( + color: selected + ? Color.alphaBlend(surfaceColors.control, workspaceColor) + : workspaceColor, + child: InkWell( + onTap: onSelect, + onDoubleTap: () => onCreate(context), + excludeFromSemantics: true, + child: LayoutBuilder( + builder: (context, constraints) { + const headerHeight = 24.0; + const itemHeight = 22.0; + const moreHeight = 24.0; + const itemGap = BusyMaxSpacing.xs; + final contentHeight = math.max( + 0.0, + constraints.maxHeight - BusyMaxSpacing.xs * 2, + ); + final availableRowsHeight = math.max( + 0.0, + contentHeight - headerHeight - itemGap, + ); + final rowSlots = (availableRowsHeight / (itemHeight + itemGap)) + .floor(); + final needsOverflowRow = items.length > rowSlots; + final visibleCount = needsOverflowRow + ? math.max(0, rowSlots - 1) + : math.min(items.length, rowSlots); + final visible = items.take(visibleCount).toList(); + final overflow = items.length - visible.length; + final showOverflow = + overflow > 0 && availableRowsHeight >= moreHeight; - if (contentHeight < headerHeight + itemGap) { - return Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.xs), - child: Align( - alignment: AlignmentDirectional.topStart, - child: SizedBox( - width: 26, - height: contentHeight, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: AlignmentDirectional.topStart, - child: _MonthDayNumber( - day: day, - selected: selected, - today: today, - inCurrentMonth: inCurrentMonth, + if (contentHeight < headerHeight + itemGap) { + return Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.xs), + child: Align( + alignment: AlignmentDirectional.topStart, + child: SizedBox( + width: 26, + height: contentHeight, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.topStart, + child: _MonthDayNumber( + day: day, + selected: selected, + today: today, + inCurrentMonth: inCurrentMonth, + ), ), ), ), - ), - ); - } + ); + } - return Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.xs), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - _MonthDayNumber( - day: day, - selected: selected, - today: today, - inCurrentMonth: inCurrentMonth, - ), - const Spacer(), - if (selected) - SizedBox.square( - dimension: 24, - child: YaruIconButton( - tooltip: context.l10n.create, - icon: const Icon(YaruIcons.plus, size: 16), - onPressed: onCreate, - ), + return Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + _MonthDayNumber( + day: day, + selected: selected, + today: today, + inCurrentMonth: inCurrentMonth, ), - ], - ), - const SizedBox(height: BusyMaxSpacing.xs), - for (final item in visible) - Padding( - padding: const EdgeInsets.only(bottom: BusyMaxSpacing.xs), - child: ScheduleItemChip( - item: item, - height: itemHeight, - compact: true, - onTap: (context, [globalPosition]) => - onItemSelected(context, item, globalPosition), - onTaskCompletionChanged: item is TaskScheduleItem - ? (completed) => - onTaskCompletionChanged(item, completed) - : null, - ), + const Spacer(), + if (selected) + Builder( + builder: (anchorContext) => SizedBox.square( + dimension: 24, + child: YaruIconButton( + tooltip: context.l10n.create, + icon: const Icon(YaruIcons.plus, size: 16), + onPressed: () => onCreate(anchorContext), + ), + ), + ), + ], ), - if (showOverflow) - Align( - alignment: AlignmentDirectional.centerStart, - child: TextButton( - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 6), - minimumSize: const Size(0, moreHeight), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + const SizedBox(height: BusyMaxSpacing.xs), + for (final item in visible) + Padding( + padding: const EdgeInsets.only( + bottom: BusyMaxSpacing.xs, ), - onPressed: () => showScheduleMorePopover( - context: context, - day: day, - items: items, - onItemSelected: onItemSelected, - onTaskCompletionChanged: onTaskCompletionChanged, + child: ScheduleItemChip( + item: item, + height: itemHeight, + compact: true, + onTap: (context, [globalPosition]) => + onItemSelected(context, item, globalPosition), + onTaskCompletionChanged: item is TaskScheduleItem + ? (completed) => + onTaskCompletionChanged(item, completed) + : null, ), - child: Text(context.l10n.moreItems(overflow)), ), - ), - ], - ), - ); - }, + if (showOverflow) + Align( + alignment: AlignmentDirectional.centerStart, + child: Builder( + builder: (anchorContext) => TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 6, + ), + minimumSize: const Size(0, moreHeight), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () async { + final selection = await showScheduleMorePopover( + context: context, + anchorContext: anchorContext, + day: day, + items: items, + onTaskCompletionChanged: + onTaskCompletionChanged, + ); + if (selection == null || + !context.mounted || + !anchorContext.mounted) { + return; + } + onItemSelected( + anchorContext, + selection.item, + selection.anchorPoint, + ); + }, + child: Text(context.l10n.moreItems(overflow)), + ), + ), + ), + ], + ), + ); + }, + ), ), ), ); @@ -296,12 +327,13 @@ class _MonthDayNumber extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); return Container( + key: ValueKey('month-day-marker-${day.toIso8601String()}'), width: 26, height: 22, alignment: Alignment.center, decoration: BoxDecoration( color: selected - ? surfaceColors.controlActive + ? colorScheme.primary : today ? surfaceColors.controlActive : null, @@ -311,7 +343,7 @@ class _MonthDayNumber extends StatelessWidget { '${day.day}', style: Theme.of(context).textTheme.labelMedium?.copyWith( color: selected - ? surfaceColors.foreground + ? colorScheme.onPrimary : today ? surfaceColors.foreground : inCurrentMonth diff --git a/lib/src/features/schedule/presentation/schedule_more_popover.dart b/lib/src/features/schedule/presentation/schedule_more_popover.dart index 039bbeb..812668b 100644 --- a/lib/src/features/schedule/presentation/schedule_more_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_more_popover.dart @@ -2,64 +2,89 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_yaru_theme.dart'; import '../../../schedule/schedule_item.dart'; +import 'schedule_anchored_popover.dart'; import 'schedule_item_chip.dart'; -import 'schedule_item_selection.dart'; -Future showScheduleMorePopover({ +class ScheduleMorePopoverSelection { + const ScheduleMorePopoverSelection({ + required this.item, + required this.anchorPoint, + }); + + final ScheduleItem item; + final Offset? anchorPoint; +} + +Future showScheduleMorePopover({ required BuildContext context, + required BuildContext anchorContext, required DateTime day, required List items, - required ScheduleItemSelectionCallback onItemSelected, required void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged, -}) { +}) async { final locale = Localizations.localeOf(context).toLanguageTag(); - return showDialog( + final dayLabel = DateFormat.yMMMMEEEEd(locale).format(day); + final fallbackAnchorPoint = scheduleGlobalRectFor(anchorContext)?.center; + return showScheduleAnchoredPopover( context: context, - builder: (context) { - return Dialog( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420, maxHeight: 520), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Text( - DateFormat.yMMMMEEEEd(locale).format(day), - style: Theme.of(context).textTheme.titleMedium, - ), + anchorContext: anchorContext, + semanticLabel: dayLabel, + preferredMinimumHeight: 200, + builder: (context, arrowSide, arrowAlignment) { + return BusyMaxPopoverSurface( + color: BusyMaxSurfaceColors.of(context).popover, + arrowSide: arrowSide, + arrowAlignment: arrowAlignment, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.lg), + child: Text( + dayLabel, + style: Theme.of(context).textTheme.titleMedium, ), - const Divider(height: 1), - Flexible( - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.all(BusyMaxSpacing.md), - itemCount: items.length, - separatorBuilder: (_, _) => - const SizedBox(height: BusyMaxSpacing.sm), - itemBuilder: (context, index) { - final item = items[index]; - return ScheduleItemChip( - item: item, - height: 34, - compact: false, - onTap: (context, [globalPosition]) { - Navigator.of(context).pop(); - onItemSelected(context, item, globalPosition); - }, - onTaskCompletionChanged: item is TaskScheduleItem - ? (completed) => - onTaskCompletionChanged(item, completed) - : null, - ); - }, - ), + ), + const Divider(height: 1), + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.all(BusyMaxSpacing.md), + itemCount: items.length, + separatorBuilder: (_, _) => + const SizedBox(height: BusyMaxSpacing.sm), + itemBuilder: (context, index) { + final item = items[index]; + return ScheduleItemChip( + item: item, + height: 34, + compact: false, + onTap: (itemAnchorContext, [globalPosition]) { + Navigator.of(context).pop( + ScheduleMorePopoverSelection( + item: item, + anchorPoint: + globalPosition ?? + scheduleGlobalRectFor( + itemAnchorContext, + )?.center ?? + fallbackAnchorPoint, + ), + ); + }, + onTaskCompletionChanged: item is TaskScheduleItem + ? (completed) => + onTaskCompletionChanged(item, completed) + : null, + ); + }, ), - ], - ), + ), + ], ), ); }, diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index c177fd3..fbf0b50 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -9,6 +9,7 @@ import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_yaru_theme.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_glyphs.dart'; import '../../../calendar_providers/calendar_colors.dart'; import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_item.dart'; @@ -43,8 +44,7 @@ class ScheduleSidebar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final accounts = ref.watch(accountsStreamProvider).valueOrNull ?? const []; - return Material( - color: BusyMaxSurfaceColors.of(context).sidebar, + return BusyMaxSidebarSurface( child: Column( children: [ MiniCalendar( @@ -62,7 +62,10 @@ class ScheduleSidebar extends ConsumerWidget { padding: const EdgeInsets.symmetric(vertical: BusyMaxSpacing.sm), children: [ for (final account in accounts) - _AccountSourcesGroup(account: account), + _AccountSourcesGroup( + key: ValueKey(('schedule-account', account.id)), + account: account, + ), ], ), ), @@ -73,7 +76,7 @@ class ScheduleSidebar extends ConsumerWidget { } class _SourceRow extends ConsumerWidget { - const _SourceRow({required this.source}); + const _SourceRow({super.key, required this.source}); final CalendarSourceEntity source; @@ -101,6 +104,7 @@ class _SourceRow extends ConsumerWidget { ), menuButton: BusyMaxMenuButton( tooltip: context.l10n.options, + highlightWhenOpen: false, onSelected: (value) { switch (value) { case 'refresh': @@ -129,14 +133,14 @@ class _SourceRow extends ConsumerWidget { label: context.l10n.rename, icon: Icons.edit_outlined, enabled: !source.readOnly, - tooltip: source.readOnly ? 'Read-only calendar.' : null, + tooltip: source.readOnly ? context.l10n.readOnlyCalendar : null, ), BusyMaxMenuEntry( value: 'delete', label: context.l10n.delete, icon: YaruIcons.trash, enabled: !source.readOnly, - tooltip: source.readOnly ? 'Read-only calendar.' : null, + tooltip: source.readOnly ? context.l10n.readOnlyCalendar : null, destructive: true, ), ], @@ -238,23 +242,21 @@ class _SourceVisibilityButton extends StatelessWidget { checked: value, button: true, onTap: () => onChanged(!value), - child: YaruIconButton( + child: BusyMaxHeaderIconButton( tooltip: tooltip, iconSize: BusyMaxSizes.sidebarActionIcon, icon: Icon(YaruIcons.checkmark, color: iconColor), onPressed: () => onChanged(!value), - style: busyMaxHeaderIconButtonStyle( - foregroundColor: iconColor, - backgroundColor: busyMaxSubtleButtonBackground(context), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - ), + foregroundColor: iconColor, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), ), ); } } class _AccountSourcesGroup extends ConsumerStatefulWidget { - const _AccountSourcesGroup({required this.account}); + const _AccountSourcesGroup({super.key, required this.account}); final AccountEntity account; @@ -299,7 +301,15 @@ class _AccountSourcesGroupState extends ConsumerState<_AccountSourcesGroup> { return Column( children: [ for (final list in lists) - _TaskListScheduleRow(account: account, list: list), + _TaskListScheduleRow( + key: ValueKey(( + 'schedule-task-list', + list.accountId, + list.id, + )), + account: account, + list: list, + ), ], ); }, @@ -361,7 +371,7 @@ class _AccountHeaderRow extends StatelessWidget { ), ), const SizedBox(width: BusyMaxSpacing.xs), - YaruIconButton( + BusyMaxHeaderIconButton( tooltip: expanded ? MaterialLocalizations.of(context).expandedIconTapHint : MaterialLocalizations.of(context).collapsedIconTapHint, @@ -369,14 +379,15 @@ class _AccountHeaderRow extends StatelessWidget { icon: AnimatedRotation( turns: expanded ? 0.25 : 0, duration: const Duration(milliseconds: 160), - child: const Icon(YaruIcons.pan_end, size: 16), + child: Icon( + BusyMaxGlyphs.collapsedFor(Directionality.of(context)), + size: 16, + ), ), onPressed: onToggleExpanded, - style: busyMaxHeaderIconButtonStyle( - foregroundColor: colorScheme.onSurfaceVariant, - backgroundColor: busyMaxSubtleButtonBackground(context), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - ), + foregroundColor: colorScheme.onSurfaceVariant, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), ), ], ), @@ -412,7 +423,17 @@ class _AccountCalendarSources extends ConsumerWidget { ); } return Column( - children: [for (final source in sources) _SourceRow(source: source)], + children: [ + for (final source in sources) + _SourceRow( + key: ValueKey(( + 'schedule-calendar', + source.accountId, + source.id, + )), + source: source, + ), + ], ); }, ); @@ -444,7 +465,11 @@ class _SourceDot extends StatelessWidget { } class _TaskListScheduleRow extends ConsumerWidget { - const _TaskListScheduleRow({required this.account, required this.list}); + const _TaskListScheduleRow({ + super.key, + required this.account, + required this.list, + }); final AccountEntity account; final TaskListEntity list; @@ -456,7 +481,7 @@ class _TaskListScheduleRow extends ConsumerWidget { list.accountId, list.id, ); - final title = _taskListLabel(account, list); + final title = _taskListLabel(context, account, list); return _CompactSourceRow( title: title, leading: _SourceDot( @@ -478,6 +503,7 @@ class _TaskListScheduleRow extends ConsumerWidget { ), menuButton: BusyMaxMenuButton( tooltip: context.l10n.options, + highlightWhenOpen: false, onSelected: (value) { switch (value) { case 'refresh': @@ -527,10 +553,14 @@ class _TaskListScheduleRow extends ConsumerWidget { } } -String _taskListLabel(AccountEntity account, TaskListEntity list) { +String _taskListLabel( + BuildContext context, + AccountEntity account, + TaskListEntity list, +) { final provider = account.provider == BusyProvider.google - ? 'Google Tasks' - : 'Microsoft To Do'; + ? context.l10n.googleTasksProvider + : context.l10n.microsoftTodoProvider; final title = list.title.trim(); if (title.isEmpty || title.toLowerCase() == provider.toLowerCase() || @@ -576,8 +606,8 @@ Future _refreshCalendarSource( ) async { try { await ref - .read(calendarSyncEngineForAccountFactoryProvider)(source.accountId) - .incrementalSync(); + .read(accountSyncOperationsProvider) + .syncCalendar(source.accountId, full: false); } on Object catch (error) { if (!context.mounted) { return; @@ -593,8 +623,8 @@ Future _refreshTaskListAccount( ) async { try { await ref - .read(syncEngineForAccountFactoryProvider)(accountId) - .incrementalSync(); + .read(accountSyncOperationsProvider) + .syncTasks(accountId, full: false); } on Object catch (error) { if (!context.mounted) { return; @@ -644,8 +674,12 @@ Future _renameCalendar( label: context.l10n.title, actionLabel: context.l10n.rename, initialValue: source.summary, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); - if (title == null || title.trim().isEmpty || title.trim() == source.summary) { + if (!context.mounted || + title == null || + title.trim().isEmpty || + title.trim() == source.summary) { return; } await ref @@ -661,9 +695,10 @@ Future _deleteCalendar( final confirmed = await showBusyMaxConfirm( context, title: context.l10n.delete, - message: 'Delete "${source.summary}"?', + message: context.l10n.deleteCalendarConfirmation(source.summary), confirmLabel: context.l10n.delete, destructive: true, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!confirmed) { return; @@ -682,8 +717,12 @@ Future _renameTaskList( label: context.l10n.title, actionLabel: context.l10n.rename, initialValue: list.title, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); - if (title == null || title.trim().isEmpty || title.trim() == list.title) { + if (!context.mounted || + title == null || + title.trim().isEmpty || + title.trim() == list.title) { return; } await ref @@ -702,6 +741,7 @@ Future _deleteTaskList( message: context.l10n.deleteListConfirmation(list.title), confirmLabel: context.l10n.delete, destructive: true, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!confirmed) { return; diff --git a/lib/src/features/schedule/presentation/schedule_task_chip.dart b/lib/src/features/schedule/presentation/schedule_task_chip.dart index 898d9e5..bfd0007 100644 --- a/lib/src/features/schedule/presentation/schedule_task_chip.dart +++ b/lib/src/features/schedule/presentation/schedule_task_chip.dart @@ -62,12 +62,21 @@ class ScheduleTaskChip extends StatelessWidget { color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(BusyMaxRadius.sm), + mouseCursor: onTap == null + ? MouseCursor.defer + : SystemMouseCursors.click, onTapDown: onTap == null ? null : (details) => pointerDownPosition = details.globalPosition, onTap: onTap == null ? null : () => onTap!(context, pointerDownPosition), + onSecondaryTapDown: onTap == null + ? null + : (details) => pointerDownPosition = details.globalPosition, + onSecondaryTap: onTap == null + ? null + : () => onTap!(context, pointerDownPosition), child: Container( padding: EdgeInsets.symmetric( horizontal: horizontalPadding, @@ -77,7 +86,7 @@ class ScheduleTaskChip extends StatelessWidget { color: surfaceColors.control, borderRadius: BorderRadius.circular(BusyMaxRadius.sm), border: Border( - left: BorderSide(color: surfaceColors.subtleBorder, width: 3), + left: BorderSide(color: surfaceColors.divider, width: 3), ), ), child: showContent diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index bdcf67a..6866d9e 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -3,10 +3,23 @@ import 'package:intl/intl.dart'; import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_glyphs.dart'; +import '../../../app/busymax_shortcuts.dart'; import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../../schedule/schedule_range.dart'; import '../../../schedule/schedule_view_mode.dart'; +enum ScheduleToolbarMenuAction { + refresh, + settings, + keyboardShortcuts, + reportIssue, + about, +} + +enum _ScheduleCreateAction { event, task } + class ScheduleToolbar extends StatelessWidget { const ScheduleToolbar({ super.key, @@ -17,9 +30,18 @@ class ScheduleToolbar extends StatelessWidget { required this.onPrevious, required this.onNext, required this.onModeChanged, - required this.canCreate, - required this.onCreate, + required this.canCreateEvent, + required this.canCreateTask, + required this.onCreateEvent, + required this.onCreateTask, required this.onRefresh, + this.canRefresh = true, + this.canShowSidebar = false, + this.sidebarVisible = false, + this.onToggleSidebar, + this.onSearch, + this.onMenuSelected, + this.createMenuController, }); final ScheduleViewMode mode; @@ -29,81 +51,221 @@ class ScheduleToolbar extends StatelessWidget { final VoidCallback onPrevious; final VoidCallback onNext; final ValueChanged onModeChanged; - final bool canCreate; - final VoidCallback onCreate; + final bool canCreateEvent; + final bool canCreateTask; + final VoidCallback onCreateEvent; + final VoidCallback onCreateTask; final VoidCallback onRefresh; + final bool canRefresh; + final bool canShowSidebar; + final bool sidebarVisible; + final VoidCallback? onToggleSidebar; + final VoidCallback? onSearch; + final ValueChanged? onMenuSelected; + final BusyMaxMenuController? createMenuController; @override Widget build(BuildContext context) { final showPaging = mode != ScheduleViewMode.agenda; - return SizedBox( - height: BusyMaxSizes.toolbarHeight, - child: Row( - children: [ - const SizedBox(width: BusyMaxSpacing.sm), - BusyMaxPushButton.outlined( - onPressed: onToday, - child: Text(context.l10n.today), - ), - const SizedBox(width: BusyMaxSpacing.sm), - if (showPaging) ...[ - YaruIconButton( - tooltip: MaterialLocalizations.of(context).previousPageTooltip, - icon: const Icon(YaruIcons.arrow_left), - onPressed: onPrevious, - ), - YaruIconButton( - tooltip: MaterialLocalizations.of(context).nextPageTooltip, - icon: const Icon(YaruIcons.arrow_right), - onPressed: onNext, - ), - const SizedBox(width: BusyMaxSpacing.sm), - ], - Expanded( - child: Text( - _rangeLabel(context, mode, range, selectedDate), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - Flexible( - fit: FlexFit.loose, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 760; + return SizedBox( + height: BusyMaxSizes.toolbarHeight, + child: Row( + children: [ + const SizedBox(width: BusyMaxSpacing.sm), + if (canShowSidebar && onToggleSidebar != null) + YaruIconButton( + tooltip: _shortcutTooltip( + sidebarVisible + ? context.l10n.hideSidebar + : context.l10n.showSidebar, + BusyMaxShortcutLabels.sidebar, + ), + icon: Icon( + sidebarVisible + ? Icons.vertical_split + : Icons.vertical_split_outlined, + ), + onPressed: onToggleSidebar, + ), + Tooltip( + message: _shortcutTooltip( + context.l10n.today, + BusyMaxShortcutLabels.today, + ), + child: BusyMaxPushButton.standard( + onPressed: onToday, + child: Text(context.l10n.today), + ), + ), + const SizedBox(width: BusyMaxSpacing.sm), + if (showPaging) ...[ + YaruIconButton( + tooltip: _shortcutTooltip( + MaterialLocalizations.of(context).previousPageTooltip, + BusyMaxShortcutLabels.previousPeriod, + ), + icon: Icon( + BusyMaxGlyphs.previousFor(Directionality.of(context)), + ), + onPressed: onPrevious, + ), + YaruIconButton( + tooltip: _shortcutTooltip( + MaterialLocalizations.of(context).nextPageTooltip, + BusyMaxShortcutLabels.nextPeriod, + ), + icon: Icon(BusyMaxGlyphs.nextFor(Directionality.of(context))), + onPressed: onNext, + ), + const SizedBox(width: BusyMaxSpacing.sm), + ], + Expanded( + child: _fittingRangeTitle( + context, + _rangeLabel(context, mode, range, selectedDate), + ), + ), + BusyMaxMenuButton( + tooltip: _shortcutTooltip( + _modeLabel(context, mode), + BusyMaxShortcutLabels.forViewMode(mode), + ), + icon: Icon(_modeIcon(mode)), + entries: [ for (final value in ScheduleViewMode.values) - Padding( - padding: const EdgeInsets.only(right: BusyMaxSpacing.xs), - child: BusyMaxPushButton.outlined( - onPressed: mode == value - ? null - : () => onModeChanged(value), - child: Text(_modeLabel(context, value)), - ), + BusyMaxMenuEntry( + value: value, + label: _modeLabel(context, value), + icon: _modeIcon(value), + selected: mode == value, + shortcut: BusyMaxShortcutLabels.forViewMode(value), ), ], + onSelected: onModeChanged, ), - ), - ), - YaruIconButton( - tooltip: context.l10n.create, - icon: const Icon(YaruIcons.plus), - onPressed: canCreate ? onCreate : null, - ), - YaruIconButton( - tooltip: context.l10n.refreshAll, - icon: const Icon(YaruIcons.refresh), - onPressed: onRefresh, + if (onSearch != null) + YaruIconButton( + tooltip: _shortcutTooltip( + MaterialLocalizations.of(context).searchFieldLabel, + BusyMaxShortcutLabels.search, + ), + icon: const Icon(YaruIcons.search), + onPressed: onSearch, + ), + BusyMaxMenuButton<_ScheduleCreateAction>( + tooltip: context.l10n.create, + icon: const Icon(YaruIcons.plus), + controller: createMenuController, + enabled: canCreateEvent || canCreateTask, + entries: [ + BusyMaxMenuEntry( + value: _ScheduleCreateAction.event, + label: context.l10n.createEventAtTime, + icon: Icons.event_outlined, + enabled: canCreateEvent, + shortcut: BusyMaxShortcutLabels.newEvent, + ), + BusyMaxMenuEntry( + value: _ScheduleCreateAction.task, + label: context.l10n.createTaskAtDate, + icon: Icons.task_alt_outlined, + enabled: canCreateTask, + shortcut: BusyMaxShortcutLabels.newTask, + ), + ], + onSelected: (value) { + switch (value) { + case _ScheduleCreateAction.event: + onCreateEvent(); + case _ScheduleCreateAction.task: + onCreateTask(); + } + }, + ), + if (!compact) + YaruIconButton( + tooltip: context.l10n.refreshAll, + icon: const Icon(YaruIcons.refresh), + onPressed: canRefresh ? onRefresh : null, + ), + if (onMenuSelected != null) + BusyMaxMenuButton( + tooltip: context.l10n.mainMenu, + entries: [ + if (compact) + BusyMaxMenuEntry( + value: ScheduleToolbarMenuAction.refresh, + label: context.l10n.refreshAll, + icon: YaruIcons.refresh, + enabled: canRefresh, + ), + BusyMaxMenuEntry( + value: ScheduleToolbarMenuAction.settings, + label: context.l10n.settings, + icon: YaruIcons.settings, + shortcut: BusyMaxShortcutLabels.settings, + ), + BusyMaxMenuEntry( + value: ScheduleToolbarMenuAction.keyboardShortcuts, + label: context.l10n.keyboardShortcuts, + icon: Icons.keyboard_alt_outlined, + shortcut: BusyMaxShortcutLabels.keyboardShortcuts, + ), + BusyMaxMenuEntry( + value: ScheduleToolbarMenuAction.reportIssue, + label: context.l10n.reportAnIssue, + icon: YaruIcons.warning, + ), + BusyMaxMenuEntry( + value: ScheduleToolbarMenuAction.about, + label: context.l10n.aboutBusyMax, + icon: Icons.info_outline, + ), + ], + onSelected: onMenuSelected!, + ), + const SizedBox(width: BusyMaxSpacing.sm), + ], ), - const SizedBox(width: BusyMaxSpacing.sm), - ], - ), + ); + }, ); } } +Widget _fittingRangeTitle(BuildContext context, String title) { + final style = busyMaxHeaderTitleStyle(context); + return LayoutBuilder( + builder: (context, constraints) { + final painter = TextPainter( + text: TextSpan(text: title, style: style), + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + final titleFits = painter.width <= constraints.maxWidth; + painter.dispose(); + if (!titleFits) { + return const SizedBox.shrink(); + } + return Text(title, maxLines: 1, style: style); + }, + ); +} + +IconData _modeIcon(ScheduleViewMode mode) { + return switch (mode) { + ScheduleViewMode.day => Icons.calendar_view_day_outlined, + ScheduleViewMode.week => Icons.view_week_outlined, + ScheduleViewMode.month => Icons.calendar_view_month, + ScheduleViewMode.year => Icons.calendar_today_outlined, + ScheduleViewMode.agenda => Icons.view_agenda_outlined, + }; +} + String _rangeLabel( BuildContext context, ScheduleViewMode mode, @@ -116,21 +278,10 @@ String _rangeLabel( ScheduleViewMode.month => DateFormat.yMMMM(locale).format(selectedDate), ScheduleViewMode.year => DateFormat.y(locale).format(selectedDate), ScheduleViewMode.agenda => context.l10n.viewAgenda, - ScheduleViewMode.week => _weekRange(locale, range), + ScheduleViewMode.week => localizedScheduleRangeLabel(locale, range), }; } -String _weekRange(String locale, ScheduleRange range) { - final end = range.end.subtract(const Duration(days: 1)); - if (range.start.year == end.year && range.start.month == end.month) { - return '${DateFormat.MMMd(locale).format(range.start)}-${DateFormat.d(locale).format(end)}, ${DateFormat.y(locale).format(end)}'; - } - if (range.start.year == end.year) { - return '${DateFormat.MMMd(locale).format(range.start)} - ${DateFormat.MMMd(locale).format(end)}, ${DateFormat.y(locale).format(end)}'; - } - return '${DateFormat.yMMMd(locale).format(range.start)} - ${DateFormat.yMMMd(locale).format(end)}'; -} - String _modeLabel(BuildContext context, ScheduleViewMode mode) { return switch (mode) { ScheduleViewMode.day => context.l10n.viewDay, @@ -140,3 +291,5 @@ String _modeLabel(BuildContext context, ScheduleViewMode mode) { ScheduleViewMode.agenda => context.l10n.viewAgenda, }; } + +String _shortcutTooltip(String label, String shortcut) => '$label ($shortcut)'; diff --git a/lib/src/features/schedule/presentation/schedule_view_controller.dart b/lib/src/features/schedule/presentation/schedule_view_controller.dart deleted file mode 100644 index 238d417..0000000 --- a/lib/src/features/schedule/presentation/schedule_view_controller.dart +++ /dev/null @@ -1,26 +0,0 @@ -import '../../../schedule/schedule_scope.dart'; -import '../../../schedule/schedule_view_mode.dart'; - -class ScheduleViewState { - const ScheduleViewState({ - required this.selectedDate, - required this.mode, - required this.scope, - }); - - final DateTime selectedDate; - final ScheduleViewMode mode; - final ScheduleScope scope; - - ScheduleViewState copyWith({ - DateTime? selectedDate, - ScheduleViewMode? mode, - ScheduleScope? scope, - }) { - return ScheduleViewState( - selectedDate: selectedDate ?? this.selectedDate, - mode: mode ?? this.mode, - scope: scope ?? this.scope, - ); - } -} diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 0e98768..1ac1454 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -6,7 +6,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; -import 'package:yaru/yaru.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_about_dialog.dart'; @@ -14,11 +13,15 @@ import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../app/busymax_keyboard_shortcuts_dialog.dart'; import '../../../app/busymax_layout.dart'; +import '../../../app/busymax_shortcuts.dart'; +import '../../../app/busymax_surface_colors.dart'; import '../../../core/logging/redacting_logger.dart'; import '../../../features/accounts/data/accounts_repository.dart'; import '../../../features/calendar/data/calendar_repository.dart'; +import '../../../features/feedback/presentation/feedback_dialog.dart'; import '../../../features/sync/sync_auth_error.dart'; import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../../platform/linux_header_bar_service.dart'; import '../../../schedule/schedule_commands.dart'; import '../../../schedule/schedule_filters.dart'; @@ -37,8 +40,10 @@ import '../../tasks/data/tasks_repository.dart'; import '../../tasks/presentation/new_task_dialog.dart'; import '../../tasks/presentation/task_details_pane.dart'; import 'schedule_agenda_view.dart'; +import 'schedule_anchored_popover.dart'; import 'schedule_create_menu.dart'; import 'schedule_day_week_view.dart'; +import 'schedule_empty_states.dart'; import 'schedule_item_details_popover.dart'; import 'schedule_item_exporter.dart'; import 'schedule_item_selection.dart'; @@ -47,10 +52,95 @@ import 'schedule_sidebar.dart'; import 'schedule_toolbar.dart'; import 'schedule_year_view.dart'; +enum _ScheduleShortcut { + search, + sidebar, + dismissSearch, + previous, + next, + newEvent, + newTask, + today, + day, + week, + month, + year, + agenda, +} + +class _ScheduleShortcutIntent extends Intent { + const _ScheduleShortcutIntent(this.command); + + final _ScheduleShortcut command; +} + +const _scheduleShortcuts = { + BusyMaxShortcutActivators.search: _ScheduleShortcutIntent( + _ScheduleShortcut.search, + ), + BusyMaxShortcutActivators.sidebar: _ScheduleShortcutIntent( + _ScheduleShortcut.sidebar, + ), + BusyMaxShortcutActivators.dismiss: _ScheduleShortcutIntent( + _ScheduleShortcut.dismissSearch, + ), + SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): + _ScheduleShortcutIntent(_ScheduleShortcut.previous), + SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): + _ScheduleShortcutIntent(_ScheduleShortcut.next), + SingleActivator(LogicalKeyboardKey.keyE): _ScheduleShortcutIntent( + _ScheduleShortcut.newEvent, + ), + SingleActivator(LogicalKeyboardKey.keyT): _ScheduleShortcutIntent( + _ScheduleShortcut.newTask, + ), + SingleActivator(LogicalKeyboardKey.keyT, shift: true): + _ScheduleShortcutIntent(_ScheduleShortcut.today), + SingleActivator(LogicalKeyboardKey.digit1): _ScheduleShortcutIntent( + _ScheduleShortcut.day, + ), + SingleActivator(LogicalKeyboardKey.numpad1): _ScheduleShortcutIntent( + _ScheduleShortcut.day, + ), + SingleActivator(LogicalKeyboardKey.digit2): _ScheduleShortcutIntent( + _ScheduleShortcut.week, + ), + SingleActivator(LogicalKeyboardKey.numpad2): _ScheduleShortcutIntent( + _ScheduleShortcut.week, + ), + SingleActivator(LogicalKeyboardKey.digit3): _ScheduleShortcutIntent( + _ScheduleShortcut.month, + ), + SingleActivator(LogicalKeyboardKey.numpad3): _ScheduleShortcutIntent( + _ScheduleShortcut.month, + ), + SingleActivator(LogicalKeyboardKey.digit4): _ScheduleShortcutIntent( + _ScheduleShortcut.year, + ), + SingleActivator(LogicalKeyboardKey.numpad4): _ScheduleShortcutIntent( + _ScheduleShortcut.year, + ), + SingleActivator(LogicalKeyboardKey.digit5): _ScheduleShortcutIntent( + _ScheduleShortcut.agenda, + ), + SingleActivator(LogicalKeyboardKey.numpad5): _ScheduleShortcutIntent( + _ScheduleShortcut.agenda, + ), +}; + class ScheduleWorkspace extends ConsumerStatefulWidget { - const ScheduleWorkspace({super.key, this.initialScope = ScheduleScope.all}); + const ScheduleWorkspace({ + super.key, + this.initialScope = ScheduleScope.all, + this.initialTaskAccountId, + this.initialTaskListId, + this.initialTaskId, + }); final ScheduleScope initialScope; + final String? initialTaskAccountId; + final String? initialTaskListId; + final String? initialTaskId; @override ConsumerState createState() => _ScheduleWorkspaceState(); @@ -61,24 +151,34 @@ class _ScheduleWorkspaceState extends ConsumerState { static const _agendaPageDays = 30; static const _agendaInitialTaskBucketLimit = 8; static const _agendaTaskBucketPageSize = 8; + static const _pointerAnchorLifetime = Duration(seconds: 1); var _selectedDate = DateTime.now(); var _mode = ScheduleViewMode.week; late ScheduleScope _scope; _TaskDetailsTarget? _taskDetailsTarget; + late final LinuxHeaderBarSession _headerBarSession; StreamSubscription? _headerBarActions; + StreamSubscription? _headerBarSearchEvents; var _headerBarReady = false; var _nativeHeaderBarAvailable = false; var _sidebarCollapsed = false; var _searchActive = false; var _searchQuery = ''; final _searchController = TextEditingController(); - final _searchFocusNode = FocusNode(); + var _fallbackSearchFocusRequest = 0; var _latestCanShowSidebar = false; var _latestAccounts = const []; - var _latestVisibleSources = const []; + var _latestWritableSources = const []; + var _latestVisibleTaskLists = const []; + var _latestCanCreateTask = false; var _latestItems = const []; final _itemAnchorContexts = {}; + final _createMenuController = BusyMaxMenuController(); + BusyMaxMenuSession? _createChoiceMenuSession; + final _pointerAnchorClock = Stopwatch()..start(); + Offset? _recentSchedulePointerPosition; + Duration? _recentSchedulePointerTime; ScheduleWorkspaceCommand? _pendingAnchoredCommand; List _pendingAnchoredSources = const []; @@ -86,28 +186,47 @@ class _ScheduleWorkspaceState extends ConsumerState { var _agendaOverdueTaskLimit = _agendaInitialTaskBucketLimit; var _agendaNoDateTaskLimit = _agendaInitialTaskBucketLimit; ScheduleViewMode? _lastSettingsMode; - _HeaderBarStateSnapshot? _lastHeaderBarState; + var _initialTaskHandled = false; + var _initialTaskOpening = false; + var _initialTaskWatchGeneration = 0; + StreamSubscription? _initialTaskTargetSubscription; + var _taskDetailsDirty = false; + final _anchoredPopoverController = ScheduleAnchoredPopoverController(); + var _handlingModalHeaderAction = false; @override void initState() { super.initState(); _scope = widget.initialScope; _applyInitialScope(); - HardwareKeyboard.instance.addHandler(_handleScheduleShortcutEvent); + _headerBarSession = ref.read(linuxHeaderBarServiceProvider).claimSession(); + _headerBarActions = _headerBarSession.actions.listen( + _handleHeaderBarAction, + ); + _headerBarSearchEvents = _headerBarSession.searchEvents.listen( + _handleHeaderBarSearchEvent, + ); unawaited(_initializeHeaderBar()); + _scheduleInitialTaskWatch(); } @override void dispose() { - HardwareKeyboard.instance.removeHandler(_handleScheduleShortcutEvent); + final createChoiceMenuSession = _createChoiceMenuSession; + _createChoiceMenuSession = null; + if (createChoiceMenuSession != null) { + unawaited(createChoiceMenuSession.dismiss()); + } + _headerBarSession.dispose(); if (_taskDetailsTarget != null) { unawaited( - ref.read(linuxHeaderBarServiceProvider).setModalBarrierVisible(false), + releaseBusyMaxModalBarrier(ref.read(linuxHeaderBarServiceProvider)), ); } unawaited(_headerBarActions?.cancel()); + unawaited(_headerBarSearchEvents?.cancel()); + unawaited(_initialTaskTargetSubscription?.cancel()); _searchController.dispose(); - _searchFocusNode.dispose(); super.dispose(); } @@ -118,6 +237,13 @@ class _ScheduleWorkspaceState extends ConsumerState { _scope = widget.initialScope; _applyInitialScope(); } + if (oldWidget.initialTaskAccountId != widget.initialTaskAccountId || + oldWidget.initialTaskListId != widget.initialTaskListId || + oldWidget.initialTaskId != widget.initialTaskId) { + _initialTaskHandled = false; + _initialTaskOpening = false; + _scheduleInitialTaskWatch(); + } } @override @@ -130,246 +256,368 @@ class _ScheduleWorkspaceState extends ConsumerState { final accounts = accountsState.valueOrNull ?? const []; final accountsLoading = accountsState.isLoading && accountsState.valueOrNull == null; + final accountsUnavailable = + accountsState.hasError && accountsState.valueOrNull == null; final accountIds = accounts.map((account) => account.id).toList(); final sourcesStream = ref .watch(calendarRepositoryProvider) .watchSourcesForAccounts(accountIds); - return StreamBuilder>( - stream: sourcesStream, - builder: (context, sourcesSnapshot) { - final sourcesLoading = - sourcesSnapshot.connectionState == ConnectionState.waiting && - !sourcesSnapshot.hasData; - final sources = sourcesSnapshot.data ?? const []; - return FutureBuilder>( - future: _taskListsForAccounts(accounts), - builder: (context, listsSnapshot) { - final taskListsLoading = - listsSnapshot.connectionState == ConnectionState.waiting && - !listsSnapshot.hasData; - final taskLists = listsSnapshot.data ?? const []; - final visibility = ScheduleSourceVisibility.fromSources( - calendarSources: sources, - taskLists: taskLists, - settings: settings, - ); - final firstWeekday = _firstWeekday(context); - final visibleSources = sources - .where( - (source) => - visibility.visibleCalendarSourceIds.contains(source.id), - ) - .toList(); - _latestAccounts = accounts; - _latestVisibleSources = visibleSources; - - return FutureBuilder<_ScheduleItemsResult>( - future: _scheduleItems( - repository: ref.watch(scheduleRepositoryProvider), - range: range, - searchHasQuery: searchHasQuery, - accountIds: accountIds.toSet(), - sourceIds: visibility.visibleCalendarSourceIds, - taskListIds: visibility.visibleTaskListIds, - ), - builder: (context, snapshot) { - final itemsLoading = - snapshot.connectionState == ConnectionState.waiting && - !snapshot.hasData; - final scheduleLoading = - accountsLoading || - sourcesLoading || - taskListsLoading || - itemsLoading; - final scopedItems = ScheduleProjection.filterByScope( - snapshot.data?.items ?? const [], - _scope, + return _scheduleShortcutScope( + ScheduleAnchoredPopoverScope( + controller: _anchoredPopoverController, + child: StreamBuilder>( + stream: sourcesStream, + builder: (context, sourcesSnapshot) { + final sourcesLoading = + sourcesSnapshot.connectionState == ConnectionState.waiting && + !sourcesSnapshot.hasData; + final sourcesUnavailable = + sourcesSnapshot.hasError && !sourcesSnapshot.hasData; + final sources = + sourcesSnapshot.data ?? const []; + return FutureBuilder>( + future: _taskListsForAccounts(accounts), + builder: (context, listsSnapshot) { + final taskListsLoading = + listsSnapshot.connectionState == ConnectionState.waiting && + !listsSnapshot.hasData; + final taskListsUnavailable = + listsSnapshot.hasError && !listsSnapshot.hasData; + final taskLists = + listsSnapshot.data ?? const []; + final visibility = ScheduleSourceVisibility.fromSources( + calendarSources: sources, + taskLists: taskLists, + settings: settings, ); - final items = - !searchHasQuery && _mode == ScheduleViewMode.agenda - ? _agendaItems(scopedItems, range) - : scopedItems; - _latestItems = items; - final miniCalendarItemsFuture = ref - .watch(scheduleRepositoryProvider) - .listItems( - range: ScheduleRange.month( - _selectedDate, - firstWeekday: firstWeekday, + final firstWeekday = _firstWeekday(context); + final visibleSources = sources + .where( + (source) => visibility.visibleCalendarSourceIds.contains( + source.id, ), - filters: ScheduleFilters( - accountIds: accountIds.toSet(), - sourceIds: visibility.visibleCalendarSourceIds, - taskListIds: visibility.visibleTaskListIds, - sourceFilterActive: true, - taskListFilterActive: true, - includeCalendarEvents: _scope != ScheduleScope.tasks, - includeTasks: _scope != ScheduleScope.events, - showCompletedTasks: true, - showNoDateTasks: false, + ) + .toList(); + final writableSources = writableCalendarSources(visibleSources); + final routedTaskListKey = _initialTaskListKey; + final visibleTaskListKeys = routedTaskListKey == null + ? visibility.visibleTaskListKeys + : {routedTaskListKey}; + final visibleTaskLists = taskLists + .where( + (list) => visibleTaskListKeys.contains( + ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, + ), ), + ) + .toList(); + final canCreateTask = visibleTaskLists.isNotEmpty; + _latestAccounts = accounts; + _latestWritableSources = writableSources; + _latestVisibleTaskLists = visibleTaskLists; + _latestCanCreateTask = canCreateTask; + + return FutureBuilder<_ScheduleItemsResult>( + future: _scheduleItems( + repository: ref.watch(scheduleRepositoryProvider), + range: range, + searchHasQuery: searchHasQuery, + accountIds: accountIds.toSet(), + sourceIds: visibility.visibleCalendarSourceIds, + taskListKeys: visibleTaskListKeys, + ), + builder: (context, snapshot) { + final itemsLoading = + snapshot.connectionState == ConnectionState.waiting && + !snapshot.hasData; + final scheduleLoading = + accountsLoading || + sourcesLoading || + taskListsLoading || + itemsLoading; + final scheduleUnavailable = + accountsUnavailable || + sourcesUnavailable || + taskListsUnavailable || + (snapshot.hasError && !snapshot.hasData); + final scopedItems = ScheduleProjection.filterByScope( + snapshot.data?.items ?? const [], + _scope, ); - final displayRange = searchHasQuery - ? _rangeForSearchResults(items, range) - : range; - final displayMode = searchHasQuery - ? ScheduleViewMode.agenda - : _mode; - _consumePendingCommand(visibleSources, accounts); - final showFallbackHeader = _showFlutterHeaderFallback; - final main = Column( - children: [ - if (showFallbackHeader) ...[ - ScheduleToolbar( - mode: _mode, - range: range, - selectedDate: _selectedDate, - onToday: _goToToday, - onPrevious: _previous, - onNext: _next, - onModeChanged: _setMode, - canCreate: accounts.isNotEmpty, - onCreate: _openCreateAtSelectedDate, - onRefresh: () => unawaited(_refreshAll()), - ), - const Divider(height: 1), - ], - if (_searchActive) ...[ - _ScheduleSearchField( - controller: _searchController, - focusNode: _searchFocusNode, - onChanged: (value) => - setState(() => _searchQuery = value), - onClose: _closeSearch, - ), - const Divider(height: 1), - ], - Expanded( - child: _ScheduleBody( - isLoading: scheduleLoading, - mode: displayMode, - range: displayRange, - selectedDate: searchHasQuery - ? displayRange.start - : _selectedDate, - firstWeekday: _firstWeekday(context), - dayStartMinute: settings.scheduleDayStartMinute, - dayEndMinute: settings.scheduleDayEndMinute, - hasAnySources: - visibility.hasCalendarSources || - visibility.hasTaskLists, - items: items, - onDaySelected: _setDate, - onYearDaySelected: _openDay, - onMonthSelected: _setMonth, - onEmptySlot: (start) => unawaited( - _openCreateChoice(accounts, visibleSources, start), - ), - onCreateAtDay: (day) => unawaited( - _openCreateChoice( - accounts, - visibleSources, - DateTime(day.year, day.month, day.day, 9), + final items = + !searchHasQuery && _mode == ScheduleViewMode.agenda + ? _agendaItems(scopedItems, range) + : scopedItems; + _latestItems = items; + final miniCalendarItemsFuture = ref + .watch(scheduleRepositoryProvider) + .listItems( + range: ScheduleRange.month( + _selectedDate, + firstWeekday: firstWeekday, ), + filters: ScheduleFilters( + accountIds: accountIds.toSet(), + sourceIds: visibility.visibleCalendarSourceIds, + taskListKeys: visibleTaskListKeys, + sourceFilterActive: true, + taskListFilterActive: true, + includeCalendarEvents: + _scope != ScheduleScope.tasks, + includeTasks: _scope != ScheduleScope.events, + showCompletedTasks: true, + showNoDateTasks: false, + ), + ); + final displayRange = searchHasQuery + ? _rangeForSearchResults(items, range) + : range; + final displayMode = searchHasQuery + ? ScheduleViewMode.agenda + : _mode; + _consumePendingCommand(visibleSources, accounts); + final showFallbackHeader = _showFlutterHeaderFallback; + final canShowFallbackSidebar = + BusyMaxLayoutRules.showSidebar( + MediaQuery.sizeOf(context).width, + ); + Widget buildSidebar() { + return SizedBox( + width: BusyMaxSizes.sidebarWidth, + child: FutureBuilder>( + future: miniCalendarItemsFuture, + builder: (context, miniSnapshot) { + final miniCalendarItems = + ScheduleProjection.filterByScope( + miniSnapshot.data ?? const [], + _scope, + ); + return ScheduleSidebar( + selectedDate: _selectedDate, + firstWeekday: firstWeekday, + items: miniCalendarItems, + onDateSelected: _openDay, + onMonthSelected: _setMonth, + onYearSelected: _setYear, + onWeekSelected: _setWeek, + ); + }, ), - onNewEvent: () => unawaited( - _openNewEvent(visibleSources, _selectedDate), - ), - onNewTask: () => unawaited(_openNewTask(accounts)), - onPrevious: _previous, - onNext: _next, - onAgendaLoadMore: - !searchHasQuery && _mode == ScheduleViewMode.agenda - ? _loadMoreAgendaDays - : null, - hasMoreAgendaOverdueTasks: - !searchHasQuery && - _mode == ScheduleViewMode.agenda && - (snapshot.data?.hasMoreOverdueTasks ?? false), - hasMoreAgendaNoDateTasks: - !searchHasQuery && - _mode == ScheduleViewMode.agenda && - (snapshot.data?.hasMoreNoDateTasks ?? false), - onAgendaLoadMoreOverdue: - !searchHasQuery && _mode == ScheduleViewMode.agenda - ? _loadMoreAgendaOverdueTasks - : null, - onAgendaLoadMoreNoDate: - !searchHasQuery && _mode == ScheduleViewMode.agenda - ? _loadMoreAgendaNoDateTasks - : null, - onItemSelected: (context, item, [globalPosition]) => - unawaited( - _openItem( - context, - item, - visibleSources, - globalPosition: globalPosition, + ); + } + + final main = Column( + children: [ + if (showFallbackHeader) ...[ + if (_searchActive) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.md, + vertical: BusyMaxSpacing.sm, + ), + child: BusyMaxSearchField( + controller: _searchController, + autofocus: true, + focusRequest: _fallbackSearchFocusRequest, + hintText: MaterialLocalizations.of( + context, + ).searchFieldLabel, + onChanged: _setSearchQuery, + onClear: _clearSearchQuery, ), + ) + else + ScheduleToolbar( + mode: _mode, + range: range, + selectedDate: _selectedDate, + onToday: _goToToday, + onPrevious: _previous, + onNext: _next, + onModeChanged: _setMode, + canCreateEvent: writableSources.isNotEmpty, + canCreateTask: canCreateTask, + onCreateEvent: () => unawaited( + _openNewEvent( + writableSources, + _defaultSelectedDateStart(), + ), + ), + onCreateTask: () => unawaited( + _openNewTask( + accounts, + due: _day(_defaultSelectedDateStart()), + ), + ), + createMenuController: _createMenuController, + onRefresh: () => unawaited(_refreshAll()), + canRefresh: accounts.isNotEmpty, + canShowSidebar: canShowFallbackSidebar, + sidebarVisible: + canShowFallbackSidebar && !_sidebarCollapsed, + onToggleSidebar: () => _handleHeaderBarAction( + BusyMaxHeaderBarAction.sidebarToggle, + ), + onSearch: () => _handleHeaderBarAction( + BusyMaxHeaderBarAction.search, + ), + onMenuSelected: _handleFallbackToolbarMenu, ), - onItemAnchorAvailable: _handleItemAnchorAvailable, - onTaskCompletionChanged: _setTaskCompleted, - ), - ), - ], - ); - return Scaffold( - body: LayoutBuilder( - builder: (context, constraints) { - final showSidebar = BusyMaxLayoutRules.showSidebar( - constraints.maxWidth, - ); - _updateHeaderBarState( - context, - range: range, - accounts: accounts, - visibleSources: visibleSources, - showSidebar: showSidebar, - ); - final body = !showSidebar || _sidebarCollapsed - ? main - : Row( - children: [ - SizedBox( - width: BusyMaxSizes.sidebarWidth, - child: FutureBuilder>( - future: miniCalendarItemsFuture, - builder: (context, miniSnapshot) { - final miniCalendarItems = - ScheduleProjection.filterByScope( - miniSnapshot.data ?? - const [], - _scope, - ); - return ScheduleSidebar( - selectedDate: _selectedDate, - firstWeekday: firstWeekday, - items: miniCalendarItems, - onDateSelected: _openDay, - onMonthSelected: _setMonth, - onYearSelected: _setYear, - onWeekSelected: _setWeek, - ); - }, - ), + const Divider(height: 1), + ], + Expanded( + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _recordSchedulePointer, + child: _ScheduleBody( + isLoading: scheduleLoading, + isUnavailable: scheduleUnavailable, + mode: displayMode, + range: displayRange, + selectedDate: searchHasQuery + ? displayRange.start + : _selectedDate, + firstWeekday: _firstWeekday(context), + dayStartMinute: settings.scheduleDayStartMinute, + dayEndMinute: settings.scheduleDayEndMinute, + hasAnySources: + visibility.hasCalendarSources || + visibility.hasTaskLists, + hasAccounts: accounts.isNotEmpty, + items: items, + onOpenSettings: () => + unawaited(context.push('/settings')), + onRetry: _retrySchedule, + onRefresh: accounts.isEmpty + ? null + : () => unawaited(_refreshAll()), + onDaySelected: _setDate, + onYearDaySelected: _openDay, + onMonthSelected: _setMonth, + onWeekSelected: _setWeek, + onEmptySlot: (start) => unawaited( + _openCreateChoice( + accounts, + visibleSources, + start, + canCreateTask: canCreateTask, ), - Expanded(child: main), - ], - ); - return _ScheduleTaskDetailsOverlay( - target: _taskDetailsTarget, - onClose: _closeTaskDetails, - child: body, - ); - }, - ), + ), + onCreateAtDay: (day, {anchorContext}) => + unawaited( + _openCreateChoice( + accounts, + visibleSources, + DateTime(day.year, day.month, day.day, 9), + canCreateTask: canCreateTask, + anchorContext: anchorContext, + ), + ), + onNewEvent: () => unawaited( + _openNewEvent(visibleSources, _selectedDate), + ), + onNewTask: () => + unawaited(_openNewTask(accounts)), + onPrevious: _previous, + onNext: _next, + onAgendaLoadMore: + !searchHasQuery && + _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaDays + : null, + hasMoreAgendaOverdueTasks: + !searchHasQuery && + _mode == ScheduleViewMode.agenda && + (snapshot.data?.hasMoreOverdueTasks ?? false), + hasMoreAgendaNoDateTasks: + !searchHasQuery && + _mode == ScheduleViewMode.agenda && + (snapshot.data?.hasMoreNoDateTasks ?? false), + onAgendaLoadMoreOverdue: + !searchHasQuery && + _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaOverdueTasks + : null, + onAgendaLoadMoreNoDate: + !searchHasQuery && + _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaNoDateTasks + : null, + onItemSelected: + (context, item, [globalPosition]) => + unawaited( + _openItem( + context, + item, + visibleSources, + globalPosition: globalPosition, + ), + ), + onItemAnchorAvailable: _handleItemAnchorAvailable, + onTaskCompletionChanged: _setTaskCompleted, + canCreateEvent: writableSources.isNotEmpty, + canCreateTask: canCreateTask, + searchActive: searchHasQuery, + ), + ), + ), + ], + ); + return Scaffold( + backgroundColor: BusyMaxSurfaceColors.of(context).window, + body: LayoutBuilder( + builder: (context, constraints) { + final showSidebar = BusyMaxLayoutRules.showSidebar( + constraints.maxWidth, + ); + _updateHeaderBarState( + context, + range: range, + accounts: accounts, + canCreateEvent: writableSources.isNotEmpty, + canCreateTask: canCreateTask, + showSidebar: showSidebar, + ); + final body = !showSidebar || _sidebarCollapsed + ? main + : Row( + children: [ + buildSidebar(), + Expanded(child: main), + ], + ); + return _ScheduleTaskDetailsOverlay( + target: _taskDetailsTarget, + onClose: () => + unawaited(_requestCloseTaskDetails()), + onDirtyChanged: (dirty) { + _taskDetailsDirty = dirty; + }, + child: body, + ); + }, + ), + ); + }, ); }, ); }, - ); - }, + ), + ), + ); + } + + Widget _scheduleShortcutScope(Widget child) { + return Shortcuts( + shortcuts: _scheduleShortcuts, + child: Actions( + actions: {_ScheduleShortcutIntent: _ScheduleShortcutAction(this)}, + child: Focus(autofocus: true, child: child), + ), ); } @@ -381,19 +629,17 @@ class _ScheduleWorkspaceState extends ConsumerState { } Future _initializeHeaderBar() async { - final service = ref.read(linuxHeaderBarServiceProvider); - await service.initialize(); + await _headerBarSession.initialize(); if (!mounted) { return; } - _headerBarActions = service.actions.listen(_handleHeaderBarAction); setState(() { _headerBarReady = true; - _nativeHeaderBarAvailable = service.isAvailable; + _nativeHeaderBarAvailable = _headerBarSession.isAvailable; }); - if (service.isAvailable) { + if (_headerBarSession.isAvailable) { unawaited( - service.setOnboardingControls( + _headerBarSession.setOnboardingControls( visible: false, canGoBack: false, canContinue: false, @@ -409,7 +655,8 @@ class _ScheduleWorkspaceState extends ConsumerState { BuildContext context, { required ScheduleRange range, required List accounts, - required List visibleSources, + required bool canCreateEvent, + required bool canCreateTask, required bool showSidebar, }) { _latestCanShowSidebar = showSidebar; @@ -423,48 +670,108 @@ class _ScheduleWorkspaceState extends ConsumerState { _selectedDate, ); final sidebarVisible = showSidebar && !_sidebarCollapsed; - final canCreate = accounts.isNotEmpty || visibleSources.isNotEmpty; - final headerBarState = _HeaderBarStateSnapshot( - titleRange: titleRange, + final headerBarState = BusyMaxHeaderBarState( + title: titleRange, viewMode: _mode, canRefresh: accounts.isNotEmpty, - canCreate: canCreate, + canCreateEvent: canCreateEvent, + canCreateTask: canCreateTask, searchActive: _searchActive, + searchQuery: _searchQuery, + canShowSidebar: showSidebar, sidebarVisible: sidebarVisible, navigationVisible: _mode != ScheduleViewMode.agenda, + scheduleControlsVisible: true, + backVisible: false, ); - if (_lastHeaderBarState == headerBarState) { - return; - } - _lastHeaderBarState = headerBarState; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } - final service = ref.read(linuxHeaderBarServiceProvider); - unawaited(service.setScheduleControlsVisible(true)); - unawaited(service.setBackVisible(false)); - unawaited( - service.setOnboardingControls( - visible: false, - canGoBack: false, - canContinue: false, - backLabel: '', - continueLabel: '', - force: true, - ), - ); - unawaited(service.setTitleRange(headerBarState.titleRange)); - unawaited(service.setViewMode(headerBarState.viewMode)); - unawaited(service.setNavigationVisible(headerBarState.navigationVisible)); - unawaited(service.setCanRefresh(headerBarState.canRefresh)); - unawaited(service.setCanCreate(headerBarState.canCreate)); - unawaited(service.setSearchActive(headerBarState.searchActive)); - unawaited(service.setSidebarVisible(headerBarState.sidebarVisible)); + unawaited(_headerBarSession.updateState(headerBarState)); }); } + void _handleFallbackToolbarMenu(ScheduleToolbarMenuAction action) { + switch (action) { + case ScheduleToolbarMenuAction.refresh: + _handleHeaderBarAction(BusyMaxHeaderBarAction.refresh); + case ScheduleToolbarMenuAction.settings: + _handleHeaderBarAction(BusyMaxHeaderBarAction.settings); + case ScheduleToolbarMenuAction.keyboardShortcuts: + _handleHeaderBarAction(BusyMaxHeaderBarAction.keyboardShortcuts); + case ScheduleToolbarMenuAction.reportIssue: + _handleHeaderBarAction(BusyMaxHeaderBarAction.reportIssue); + case ScheduleToolbarMenuAction.about: + _handleHeaderBarAction(BusyMaxHeaderBarAction.aboutBusyMax); + } + } + void _handleHeaderBarAction(BusyMaxHeaderBarAction action) { + if (!_headerBarSession.isCurrent) { + return; + } + if (!_canHandleRouteShortcut()) { + if (_taskDetailsTarget != null || _anchoredPopoverController.isOpen) { + unawaited(_dismissModalThenHandleHeaderAction(action)); + } + return; + } + _dispatchHeaderBarAction(action); + } + + void _handleHeaderBarSearchEvent(BusyMaxHeaderBarSearchEvent event) { + if (!_headerBarSession.isCurrent) { + return; + } + switch (event) { + case BusyMaxHeaderBarSearchQueryChanged(:final query): + _setSearchQuery(query); + case BusyMaxHeaderBarSearchFocusChanged(): + // Native focus is presentation state. The event is still exposed by + // the route-owned bridge so callers can observe focus without making + // it part of the durable header state. + return; + case BusyMaxHeaderBarSearchCleared(): + _clearSearchQuery(); + case BusyMaxHeaderBarSearchEscapePressed(): + if (_searchActive) { + _closeSearch(); + } + } + } + + Future _dismissModalThenHandleHeaderAction( + BusyMaxHeaderBarAction action, + ) async { + if (_handlingModalHeaderAction) { + return; + } + _handlingModalHeaderAction = true; + try { + if (_taskDetailsTarget != null) { + if (!await _confirmDiscardTaskDetails()) { + return; + } + _closeTaskDetails(); + await WidgetsBinding.instance.endOfFrame; + } else if (_anchoredPopoverController.isOpen) { + await _anchoredPopoverController.dismiss(); + } else { + return; + } + if (!mounted || + !_headerBarSession.isCurrent || + !_canHandleRouteShortcut()) { + return; + } + _dispatchHeaderBarAction(action); + } finally { + _handlingModalHeaderAction = false; + } + } + + void _dispatchHeaderBarAction(BusyMaxHeaderBarAction action) { switch (action) { case BusyMaxHeaderBarAction.back: case BusyMaxHeaderBarAction.continueSetup: @@ -503,12 +810,24 @@ class _ScheduleWorkspaceState extends ConsumerState { setState(() => _searchActive = true); _focusSearch(); } - case BusyMaxHeaderBarAction.create: - _openCreateAtSelectedDate(); + case BusyMaxHeaderBarAction.createEvent: + if (_latestWritableSources.isEmpty) { + return; + } + unawaited( + _openNewEvent(_latestWritableSources, _defaultSelectedDateStart()), + ); + case BusyMaxHeaderBarAction.createTask: + if (!_latestCanCreateTask) { + return; + } + unawaited( + _openNewTask(_latestAccounts, due: _day(_defaultSelectedDateStart())), + ); case BusyMaxHeaderBarAction.refresh: unawaited(_refreshAll()); case BusyMaxHeaderBarAction.settings: - context.go('/settings'); + unawaited(context.push('/settings')); case BusyMaxHeaderBarAction.keyboardShortcuts: unawaited( showBusyMaxKeyboardShortcutsDialog( @@ -516,13 +835,18 @@ class _ScheduleWorkspaceState extends ConsumerState { headerBarService: ref.read(linuxHeaderBarServiceProvider), ), ); + case BusyMaxHeaderBarAction.reportIssue: + unawaited( + showBusyMaxFeedbackDialog( + context, + submissionService: ref.read(feedbackSubmissionServiceProvider), + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ), + ); case BusyMaxHeaderBarAction.aboutBusyMax: unawaited( showBusyMaxAboutDialog( context, - feedbackSubmissionService: ref.read( - feedbackSubmissionServiceProvider, - ), headerBarService: ref.read(linuxHeaderBarServiceProvider), ), ); @@ -582,7 +906,7 @@ class _ScheduleWorkspaceState extends ConsumerState { required bool searchHasQuery, required Set accountIds, required Set sourceIds, - required Set taskListIds, + required Set taskListKeys, }) async { final currentItems = repository.listItems( range: range, @@ -590,7 +914,7 @@ class _ScheduleWorkspaceState extends ConsumerState { query: _searchQuery, accountIds: accountIds, sourceIds: sourceIds, - taskListIds: taskListIds, + taskListKeys: taskListKeys, sourceFilterActive: true, taskListFilterActive: true, includeCalendarEvents: _scope != ScheduleScope.tasks, @@ -608,7 +932,7 @@ class _ScheduleWorkspaceState extends ConsumerState { limit: _agendaOverdueTaskLimit, filters: ScheduleFilters( accountIds: accountIds, - taskListIds: taskListIds, + taskListKeys: taskListKeys, taskListFilterActive: true, includeTasks: _scope != ScheduleScope.events, showCompletedTasks: false, @@ -618,7 +942,7 @@ class _ScheduleWorkspaceState extends ConsumerState { limit: _agendaNoDateTaskLimit, filters: ScheduleFilters( accountIds: accountIds, - taskListIds: taskListIds, + taskListKeys: taskListKeys, taskListFilterActive: true, includeTasks: _scope != ScheduleScope.events, showCompletedTasks: true, @@ -816,20 +1140,45 @@ class _ScheduleWorkspaceState extends ConsumerState { } void _focusSearch() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _searchFocusNode.requestFocus(); + if (_nativeHeaderBarAvailable) { + unawaited(_headerBarSession.focusSearch()); + return; + } + if (_showFlutterHeaderFallback) { + // The shared adapter translates this request to Yaru's private text + // entry without replacing Yaru's geometry or interaction states. + setState(() => _fallbackSearchFocusRequest += 1); + } + } + + void _setSearchQuery(String value) { + if (_searchQuery == value && _searchController.text == value) { + return; + } + setState(() { + _searchQuery = value; + if (_searchController.text != value) { + _searchController.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed(offset: value.length), + ); } }); } + void _clearSearchQuery() { + _setSearchQuery(''); + } + void _closeSearch() { setState(() { _searchActive = false; _searchQuery = ''; _searchController.clear(); }); - _searchFocusNode.unfocus(); + if (!_nativeHeaderBarAvailable) { + FocusManager.instance.primaryFocus?.unfocus(); + } } void _previous() { @@ -886,75 +1235,63 @@ class _ScheduleWorkspaceState extends ConsumerState { }); } - bool _handleScheduleShortcutEvent(KeyEvent event) { - if (event is! KeyDownEvent || !_canHandleScheduleShortcut()) { - return false; - } - final keyboard = HardwareKeyboard.instance; - if (keyboard.isControlPressed || - keyboard.isAltPressed || - keyboard.isMetaPressed) { + bool _isScheduleShortcutEnabled(_ScheduleShortcut command) { + if (!_canHandleRouteShortcut()) { return false; } + return switch (command) { + _ScheduleShortcut.search => true, + _ScheduleShortcut.sidebar => _latestCanShowSidebar, + _ScheduleShortcut.dismissSearch => _searchActive, + _ScheduleShortcut.newEvent => + _canHandleScheduleShortcut() && _latestWritableSources.isNotEmpty, + _ScheduleShortcut.newTask => + _canHandleScheduleShortcut() && _latestCanCreateTask, + _ScheduleShortcut.today || + _ScheduleShortcut.day || + _ScheduleShortcut.week || + _ScheduleShortcut.month || + _ScheduleShortcut.year || + _ScheduleShortcut.agenda => _canHandleScheduleShortcut(), + _ScheduleShortcut.previous || _ScheduleShortcut.next => + _canHandleScheduleShortcut() && _mode != ScheduleViewMode.agenda, + }; + } - switch (event.logicalKey) { - case LogicalKeyboardKey.arrowRight: - if (!keyboard.isShiftPressed || _mode == ScheduleViewMode.agenda) { - return false; - } - _next(); - return true; - case LogicalKeyboardKey.arrowLeft: - if (!keyboard.isShiftPressed || _mode == ScheduleViewMode.agenda) { - return false; + void _invokeScheduleShortcut(_ScheduleShortcut command) { + switch (command) { + case _ScheduleShortcut.search: + if (!_searchActive) { + setState(() => _searchActive = true); } + _focusSearch(); + case _ScheduleShortcut.sidebar: + setState(() => _sidebarCollapsed = !_sidebarCollapsed); + case _ScheduleShortcut.dismissSearch: + _closeSearch(); + case _ScheduleShortcut.previous: _previous(); - return true; - case LogicalKeyboardKey.keyE: - if (_latestVisibleSources.isEmpty) { - return false; - } + case _ScheduleShortcut.next: + _next(); + case _ScheduleShortcut.newEvent: unawaited( - _openNewEvent(_latestVisibleSources, _defaultSelectedDateStart()), + _openNewEvent(_latestWritableSources, _defaultSelectedDateStart()), ); - return true; - case LogicalKeyboardKey.keyT: - if (!keyboard.isShiftPressed) { - if (_latestAccounts.isEmpty) { - return false; - } - unawaited(_openNewTask(_latestAccounts, due: _day(_selectedDate))); - return true; - } + case _ScheduleShortcut.newTask: + unawaited(_openNewTask(_latestAccounts, due: _day(_selectedDate))); + case _ScheduleShortcut.today: _goToToday(); - return true; - case LogicalKeyboardKey.digit1: - case LogicalKeyboardKey.numpad1: - case LogicalKeyboardKey.keyD: + case _ScheduleShortcut.day: _setMode(ScheduleViewMode.day); - return true; - case LogicalKeyboardKey.digit2: - case LogicalKeyboardKey.numpad2: - case LogicalKeyboardKey.keyW: + case _ScheduleShortcut.week: _setMode(ScheduleViewMode.week); - return true; - case LogicalKeyboardKey.digit3: - case LogicalKeyboardKey.numpad3: - case LogicalKeyboardKey.keyM: + case _ScheduleShortcut.month: _setMode(ScheduleViewMode.month); - return true; - case LogicalKeyboardKey.digit4: - case LogicalKeyboardKey.numpad4: - case LogicalKeyboardKey.keyY: + case _ScheduleShortcut.year: _setMode(ScheduleViewMode.year); - return true; - case LogicalKeyboardKey.digit0: - case LogicalKeyboardKey.numpad0: - case LogicalKeyboardKey.keyA: + case _ScheduleShortcut.agenda: _setMode(ScheduleViewMode.agenda); - return true; } - return false; } DateTime _defaultSelectedDateStart() { @@ -967,11 +1304,7 @@ class _ScheduleWorkspaceState extends ConsumerState { } bool _canHandleScheduleShortcut() { - if (!mounted || _searchActive || _taskDetailsTarget != null) { - return false; - } - final route = ModalRoute.of(context); - if (route != null && !route.isCurrent) { + if (!_canHandleRouteShortcut() || _searchActive) { return false; } final focusContext = FocusManager.instance.primaryFocus?.context; @@ -982,6 +1315,17 @@ class _ScheduleWorkspaceState extends ConsumerState { focusContext.findAncestorWidgetOfExactType() == null; } + bool _canHandleRouteShortcut() { + if (!mounted || _taskDetailsTarget != null) { + return false; + } + final route = ModalRoute.of(context); + if (route != null && !route.isCurrent) { + return false; + } + return true; + } + void _loadMoreAgendaDays() { if (_mode != ScheduleViewMode.agenda) { return; @@ -1018,38 +1362,117 @@ class _ScheduleWorkspaceState extends ConsumerState { Future _openCreateChoice( List accounts, List sources, - DateTime start, - ) async { - final choice = await showScheduleCreateMenu(context: context); + DateTime start, { + required bool canCreateTask, + BuildContext? anchorContext, + }) async { + if (_createChoiceMenuSession != null) { + return; + } + final writableSources = writableCalendarSources(sources); + final recentAnchorPoint = _takeRecentSchedulePointerPosition(); + final anchorPoint = anchorContext == null ? recentAnchorPoint : null; + final canCreateEvent = writableSources.isNotEmpty; + if (!canCreateEvent && !canCreateTask) { + return; + } + final directChoice = singleAvailableScheduleCreateChoice( + canCreateEvent: canCreateEvent, + canCreateTask: canCreateTask, + ); + if (directChoice != null) { + await _openScheduleCreateChoice( + directChoice, + accounts: accounts, + writableSources: writableSources, + start: start, + ); + return; + } + final menuSession = BusyMaxMenuSession(); + _createChoiceMenuSession = menuSession; + ScheduleCreateChoice? choice; + try { + choice = await showScheduleCreateMenu( + context: context, + anchorContext: anchorContext?.mounted == true + ? anchorContext + : _createChoiceAnchorContext(), + anchorPoint: anchorPoint, + preferAbove: anchorContext != null, + session: menuSession, + ); + } finally { + if (identical(_createChoiceMenuSession, menuSession)) { + _createChoiceMenuSession = null; + } + } if (!mounted || choice == null) { return; } + await _openScheduleCreateChoice( + choice, + accounts: accounts, + writableSources: writableSources, + start: start, + ); + } + + Future _openScheduleCreateChoice( + ScheduleCreateChoice choice, { + required List accounts, + required List writableSources, + required DateTime start, + }) async { switch (choice) { case ScheduleCreateChoice.event: - unawaited(_openNewEvent(sources, start)); + await _openNewEvent(writableSources, start); case ScheduleCreateChoice.task: await _openNewTask(accounts, due: _day(start)); } } - void _openCreateAtSelectedDate() { - unawaited( - _openCreateChoice( - _latestAccounts, - _latestVisibleSources, - _defaultSelectedDateStart(), - ), - ); + void _recordSchedulePointer(PointerDownEvent event) { + _recentSchedulePointerPosition = event.position; + _recentSchedulePointerTime = _pointerAnchorClock.elapsed; + } + + Offset? _takeRecentSchedulePointerPosition() { + final position = _recentSchedulePointerPosition; + final recordedAt = _recentSchedulePointerTime; + _recentSchedulePointerPosition = null; + _recentSchedulePointerTime = null; + if (HardwareKeyboard.instance.logicalKeysPressed.isNotEmpty || + position == null || + recordedAt == null) { + return null; + } + final age = _pointerAnchorClock.elapsed - recordedAt; + return age <= _pointerAnchorLifetime ? position : null; + } + + BuildContext _createChoiceAnchorContext() { + final focusedContext = FocusManager.instance.primaryFocus?.context; + if (focusedContext == null || + !focusedContext.mounted || + ModalRoute.of(focusedContext) != ModalRoute.of(context)) { + return context; + } + final renderObject = focusedContext.findRenderObject(); + return renderObject is RenderBox && renderObject.hasSize + ? focusedContext + : context; } Future _openNewEvent( List sources, DateTime start, ) async { - if (sources.isEmpty) { + final writableSources = writableCalendarSources(sources); + if (writableSources.isEmpty) { return; } - final source = sources.first; + final source = writableSources.first; await _openEventEditor( EventEditorDraft.newEvent( accountId: source.accountId, @@ -1058,7 +1481,7 @@ class _ScheduleWorkspaceState extends ConsumerState { start: start, end: start.add(const Duration(hours: 1)), ), - sources, + writableSources, ); } @@ -1069,7 +1492,7 @@ class _ScheduleWorkspaceState extends ConsumerState { Offset? globalPosition, }) async { final action = await showScheduleItemDetailsPopover( - context: context, + context: anchorContext, anchorContext: anchorContext, item: item, anchorPoint: globalPosition, @@ -1081,13 +1504,20 @@ class _ScheduleWorkspaceState extends ConsumerState { case ScheduleItemDetailsAction.export: await _exportItem(item); case ScheduleItemDetailsAction.edit: - _editItem(item, sources); + if (item.capabilities.canEdit) { + _editItem(item, sources); + } case ScheduleItemDetailsAction.delete: - await _deleteItem(item); + if (item.capabilities.canDelete) { + await _deleteItem(item); + } } } void _editItem(ScheduleItem item, List sources) { + if (!item.capabilities.canEdit) { + return; + } if (item is CalendarScheduleItem) { unawaited( _openEventEditor( @@ -1151,25 +1581,281 @@ class _ScheduleWorkspaceState extends ConsumerState { } void _openTaskDetails(TaskScheduleItem item) { + unawaited( + _openTaskDetailsTarget( + _TaskDetailsTarget( + accountId: item.accountId, + taskListId: item.sourceId, + taskId: item.id, + ), + ), + ); + } + + Future _openTaskDetailsTarget( + _TaskDetailsTarget target, { + bool Function()? isRequestCurrent, + }) async { + if (_taskDetailsTarget == target) { + return true; + } + final replacingOpenTarget = _taskDetailsTarget != null; + if (replacingOpenTarget && !await _confirmDiscardTaskDetails()) { + return false; + } + if (!mounted || !(isRequestCurrent?.call() ?? true)) { + return false; + } setState(() { - _taskDetailsTarget = _TaskDetailsTarget( - accountId: item.accountId, - taskListId: item.sourceId, - taskId: item.id, - ); + _taskDetailsTarget = target; + _taskDetailsDirty = false; }); + if (replacingOpenTarget) { + return true; + } unawaited( - ref.read(linuxHeaderBarServiceProvider).setModalBarrierVisible(true), + acquireBusyMaxModalBarrier(ref.read(linuxHeaderBarServiceProvider)), + ); + return true; + } + + ScheduleTaskListKey? get _initialTaskListKey { + final accountId = widget.initialTaskAccountId?.trim(); + final taskListId = widget.initialTaskListId?.trim(); + if (accountId == null || + accountId.isEmpty || + taskListId == null || + taskListId.isEmpty) { + return null; + } + return ScheduleTaskListKey(accountId: accountId, taskListId: taskListId); + } + + ScheduleTaskTarget? get _initialTaskRouteTarget { + final listKey = _initialTaskListKey; + final taskId = widget.initialTaskId?.trim(); + if (listKey == null || taskId == null || taskId.isEmpty) { + return null; + } + return ScheduleTaskTarget( + accountId: listKey.accountId, + taskListId: listKey.taskListId, + taskId: taskId, + ); + } + + void _scheduleInitialTaskWatch() { + final generation = ++_initialTaskWatchGeneration; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && generation == _initialTaskWatchGeneration) { + unawaited(_startInitialTaskWatch(generation)); + } + }); + } + + Future _startInitialTaskWatch(int generation) async { + final previousSubscription = _initialTaskTargetSubscription; + _initialTaskTargetSubscription = null; + if (!mounted || generation != _initialTaskWatchGeneration) { + await previousSubscription?.cancel(); + return; + } + final request = _initialTaskRouteTarget; + if (request == null) { + await _reconcileTaskDetailsWithListRoute(generation); + await previousSubscription?.cancel(); + return; + } + if (!await _reconcileTaskDetailsWithTaskRoute(request, generation)) { + await previousSubscription?.cancel(); + return; + } + await previousSubscription?.cancel(); + if (!mounted || generation != _initialTaskWatchGeneration) { + return; + } + _initialTaskTargetSubscription = ref + .read(scheduleRepositoryProvider) + .watchTaskTarget( + accountId: request.accountId, + taskListId: request.taskListId, + taskId: request.taskId, + ) + .listen((target) { + if (target == null) { + unawaited(_handleInitialTaskUnavailable(request, generation)); + return; + } + unawaited(_handleInitialTaskTarget(target, generation)); + }); + } + + Future _reconcileTaskDetailsWithTaskRoute( + ScheduleTaskTarget request, + int generation, + ) async { + final visibleTarget = _taskDetailsTarget; + if (visibleTarget == null || visibleTarget.scheduleTarget == request) { + return true; + } + if (!await _confirmDiscardTaskDetails()) { + if (mounted && + generation == _initialTaskWatchGeneration && + _initialTaskRouteTarget == request) { + _goToTaskRoute(visibleTarget); + } + return false; + } + if (!mounted || + generation != _initialTaskWatchGeneration || + _initialTaskRouteTarget != request || + _taskDetailsTarget != visibleTarget) { + return false; + } + _closeTaskDetails(); + return true; + } + + Future _reconcileTaskDetailsWithListRoute(int generation) async { + final visibleTarget = _taskDetailsTarget; + if (visibleTarget == null) { + return; + } + if (!await _confirmDiscardTaskDetails()) { + if (mounted && + generation == _initialTaskWatchGeneration && + _initialTaskRouteTarget == null) { + _goToTaskRoute(visibleTarget); + } + return; + } + if (!mounted || + generation != _initialTaskWatchGeneration || + _initialTaskRouteTarget != null || + _taskDetailsTarget != visibleTarget) { + return; + } + _closeTaskDetails(); + } + + Future _handleInitialTaskUnavailable( + ScheduleTaskTarget request, + int generation, + ) async { + final visibleTarget = _taskDetailsTarget; + if (!mounted || + generation != _initialTaskWatchGeneration || + _initialTaskRouteTarget != request || + visibleTarget?.scheduleTarget != request) { + return; + } + if (!await _confirmDiscardTaskDetails()) { + return; + } + if (!mounted || + generation != _initialTaskWatchGeneration || + _initialTaskRouteTarget != request || + _taskDetailsTarget != visibleTarget) { + return; + } + _closeTaskDetails(); + } + + Future _handleInitialTaskTarget( + ScheduleTaskTarget target, + int generation, + ) async { + if (!mounted || + generation != _initialTaskWatchGeneration || + _initialTaskHandled || + _initialTaskOpening || + _initialTaskRouteTarget != target) { + return; + } + _initialTaskOpening = true; + final detailsTarget = _TaskDetailsTarget( + accountId: target.accountId, + taskListId: target.taskListId, + taskId: target.taskId, + ); + final opened = await _openTaskDetailsTarget( + detailsTarget, + isRequestCurrent: () => + mounted && + generation == _initialTaskWatchGeneration && + _initialTaskRouteTarget == target, + ); + if (!mounted || generation != _initialTaskWatchGeneration) { + return; + } + _initialTaskOpening = false; + if (opened) { + _initialTaskHandled = true; + return; + } + + // A declined discard keeps the editor and URL as one atomic state. + final visibleTarget = _taskDetailsTarget; + if (visibleTarget != null && _initialTaskRouteTarget == target) { + _goToTaskRoute(visibleTarget); + } + } + + Future _requestCloseTaskDetails() async { + if (!await _confirmDiscardTaskDetails()) { + return; + } + _closeTaskDetails(); + } + + Future _confirmDiscardTaskDetails() async { + if (!_taskDetailsDirty) { + return true; + } + return showBusyMaxConfirm( + context, + title: context.l10n.discardChanges, + message: context.l10n.discardChangesConfirmation, + confirmLabel: context.l10n.discardChangesAction, + destructive: true, + barrierColor: Colors.transparent, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); } void _closeTaskDetails() { - if (_taskDetailsTarget == null) { + final target = _taskDetailsTarget; + if (target == null) { return; } - setState(() => _taskDetailsTarget = null); + setState(() { + _taskDetailsTarget = null; + _taskDetailsDirty = false; + }); unawaited( - ref.read(linuxHeaderBarServiceProvider).setModalBarrierVisible(false), + releaseBusyMaxModalBarrier(ref.read(linuxHeaderBarServiceProvider)), + ); + if (_initialTaskRouteTarget == target.scheduleTarget) { + _goToTaskListRoute(target); + } + } + + void _goToTaskListRoute(_TaskDetailsTarget target) { + GoRouter.maybeOf(context)?.go( + _taskRouteLocation( + accountId: target.accountId, + taskListId: target.taskListId, + ), + ); + } + + void _goToTaskRoute(_TaskDetailsTarget target) { + GoRouter.maybeOf(context)?.go( + _taskRouteLocation( + accountId: target.accountId, + taskListId: target.taskListId, + taskId: target.taskId, + ), ); } @@ -1188,8 +1874,8 @@ class _ScheduleWorkspaceState extends ConsumerState { Future _syncCalendarMutation(String accountId) async { try { await ref - .read(calendarSyncEngineForAccountFactoryProvider)(accountId) - .incrementalSync(); + .read(accountSyncOperationsProvider) + .syncCalendar(accountId, full: false); } on Object catch (error) { if (!mounted) { return; @@ -1206,10 +1892,14 @@ class _ScheduleWorkspaceState extends ConsumerState { EventEditorDraft draft, List sources, ) async { + final editableSources = writableCalendarSources(sources); + if (editableSources.every((source) => source.id != draft.sourceId)) { + return; + } final result = await showBusyMaxEventEditorDialog( context, initialDraft: draft, - sources: sources, + sources: editableSources, categorySuggestionsByAccount: _categorySuggestionsByAccount(), headerBarService: ref.read(linuxHeaderBarServiceProvider), ); @@ -1238,6 +1928,9 @@ class _ScheduleWorkspaceState extends ConsumerState { } Future _deleteItem(ScheduleItem item) async { + if (!item.capabilities.canDelete) { + return; + } final confirmed = await showBusyMaxConfirm( context, title: item is CalendarScheduleItem @@ -1245,9 +1938,10 @@ class _ScheduleWorkspaceState extends ConsumerState { : context.l10n.deleteTask, message: item is TaskScheduleItem ? context.l10n.deleteTaskConfirmation(item.title) - : 'Delete "${item.title}"?', + : context.l10n.deleteCalendarConfirmation(item.title), confirmLabel: context.l10n.delete, destructive: true, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!confirmed) { return; @@ -1273,12 +1967,41 @@ class _ScheduleWorkspaceState extends ConsumerState { if (accounts.isEmpty) { return; } + TaskListEntity? initialList; + final routedListKey = _initialTaskListKey; + if (routedListKey != null) { + for (final list in _latestVisibleTaskLists) { + if (list.accountId == routedListKey.accountId && + list.id == routedListKey.taskListId) { + initialList = list; + break; + } + } + if (initialList == null && + accounts.any((account) => account.id == routedListKey.accountId)) { + final routedAccountLists = await ref + .read( + taskListsRepositoryForAccountProvider(routedListKey.accountId), + ) + .listTaskLists(); + if (!mounted) { + return; + } + for (final list in routedAccountLists) { + if (list.id == routedListKey.taskListId) { + initialList = list; + break; + } + } + } + } final draft = await showBusyMaxNewTaskDialog( context, ref: ref, accounts: accounts, - initialAccountId: ref.read(activeAccountProvider), - initialListId: null, + initialAccountId: + initialList?.accountId ?? ref.read(activeAccountProvider), + initialListId: initialList?.id, initialDueUtc: due, headerBarService: ref.read(linuxHeaderBarServiceProvider), ); @@ -1352,6 +2075,11 @@ class _ScheduleWorkspaceState extends ConsumerState { } } + void _retrySchedule() { + ref.invalidate(accountsStreamProvider); + setState(() {}); + } + void _consumePendingCommand( List sources, List accounts, @@ -1368,6 +2096,8 @@ class _ScheduleWorkspaceState extends ConsumerState { switch (command.kind) { case ScheduleWorkspaceCommandKind.today: _goToToday(); + case ScheduleWorkspaceCommandKind.agenda: + _setMode(ScheduleViewMode.agenda); case ScheduleWorkspaceCommandKind.newEvent: unawaited(_openNewEvent(sources, _selectedDate)); case ScheduleWorkspaceCommandKind.newTask: @@ -1492,91 +2222,6 @@ T? _findCommandItem( return null; } -class _ScheduleSearchField extends StatelessWidget { - const _ScheduleSearchField({ - required this.controller, - required this.focusNode, - required this.onChanged, - required this.onClose, - }); - - final TextEditingController controller; - final FocusNode focusNode; - final ValueChanged onChanged; - final VoidCallback onClose; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.md, - vertical: BusyMaxSpacing.sm, - ), - child: TextField( - controller: controller, - focusNode: focusNode, - autofocus: true, - onChanged: onChanged, - textInputAction: TextInputAction.search, - decoration: InputDecoration( - prefixIcon: const Icon(YaruIcons.search), - suffixIcon: YaruIconButton( - tooltip: MaterialLocalizations.of(context).closeButtonTooltip, - icon: const Icon(YaruIcons.window_close), - onPressed: onClose, - ), - hintText: MaterialLocalizations.of(context).searchFieldLabel, - ), - ), - ); - } -} - -@immutable -class _HeaderBarStateSnapshot { - const _HeaderBarStateSnapshot({ - required this.titleRange, - required this.viewMode, - required this.canRefresh, - required this.canCreate, - required this.searchActive, - required this.sidebarVisible, - required this.navigationVisible, - }); - - final String titleRange; - final ScheduleViewMode viewMode; - final bool canRefresh; - final bool canCreate; - final bool searchActive; - final bool sidebarVisible; - final bool navigationVisible; - - @override - bool operator ==(Object other) { - return identical(this, other) || - other is _HeaderBarStateSnapshot && - titleRange == other.titleRange && - viewMode == other.viewMode && - canRefresh == other.canRefresh && - canCreate == other.canCreate && - searchActive == other.searchActive && - sidebarVisible == other.sidebarVisible && - navigationVisible == other.navigationVisible; - } - - @override - int get hashCode => Object.hash( - titleRange, - viewMode, - canRefresh, - canCreate, - searchActive, - sidebarVisible, - navigationVisible, - ); -} - class _ScheduleItemsResult { const _ScheduleItemsResult({ required this.items, @@ -1592,6 +2237,7 @@ class _ScheduleItemsResult { class _ScheduleBody extends StatelessWidget { const _ScheduleBody({ required this.isLoading, + required this.isUnavailable, required this.mode, required this.range, required this.selectedDate, @@ -1599,10 +2245,15 @@ class _ScheduleBody extends StatelessWidget { required this.dayStartMinute, required this.dayEndMinute, required this.hasAnySources, + required this.hasAccounts, required this.items, + required this.onOpenSettings, + required this.onRetry, + required this.onRefresh, required this.onDaySelected, required this.onYearDaySelected, required this.onMonthSelected, + required this.onWeekSelected, required this.onEmptySlot, required this.onCreateAtDay, required this.onNewEvent, @@ -1617,9 +2268,13 @@ class _ScheduleBody extends StatelessWidget { required this.onItemSelected, required this.onItemAnchorAvailable, required this.onTaskCompletionChanged, + required this.canCreateEvent, + required this.canCreateTask, + required this.searchActive, }); final bool isLoading; + final bool isUnavailable; final ScheduleViewMode mode; final ScheduleRange range; final DateTime selectedDate; @@ -1627,12 +2282,17 @@ class _ScheduleBody extends StatelessWidget { final int dayStartMinute; final int dayEndMinute; final bool hasAnySources; + final bool hasAccounts; final List items; + final VoidCallback onOpenSettings; + final VoidCallback onRetry; + final VoidCallback? onRefresh; final ValueChanged onDaySelected; final ValueChanged onYearDaySelected; final ValueChanged onMonthSelected; + final ValueChanged onWeekSelected; final ValueChanged onEmptySlot; - final ValueChanged onCreateAtDay; + final ScheduleDayCreateCallback onCreateAtDay; final VoidCallback onNewEvent; final VoidCallback onNewTask; final VoidCallback onPrevious; @@ -1646,17 +2306,37 @@ class _ScheduleBody extends StatelessWidget { final ScheduleItemAnchorCallback onItemAnchorAvailable; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; + final bool canCreateEvent; + final bool canCreateTask; + final bool searchActive; @override Widget build(BuildContext context) { + if (isUnavailable) { + return ScheduleUnavailableState(onRetry: onRetry); + } if (isLoading) { - return const SizedBox.expand(); + return const ScheduleLoadingState(); } if (!hasAnySources) { - return const SizedBox.expand(); + return ScheduleNoSourcesState( + hasAccounts: hasAccounts, + onOpenSettings: onOpenSettings, + onRefresh: onRefresh, + ); + } + if (items.isEmpty && searchActive) { + return const ScheduleSearchEmptyState(); + } + if (items.isEmpty && mode == ScheduleViewMode.agenda) { + return ScheduleEmptyState( + onNewEvent: canCreateEvent ? onNewEvent : null, + onNewTask: canCreateTask ? onNewTask : null, + ); } return switch (mode) { ScheduleViewMode.day => ScheduleDayWeekView( + key: const ValueKey('schedule-day-planner'), range: range, selectedDate: selectedDate, daysShowed: 1, @@ -1669,6 +2349,7 @@ class _ScheduleBody extends StatelessWidget { onTaskCompletionChanged: onTaskCompletionChanged, ), ScheduleViewMode.week => ScheduleDayWeekView( + key: const ValueKey('schedule-week-planner'), range: range, selectedDate: selectedDate, daysShowed: 7, @@ -1703,7 +2384,8 @@ class _ScheduleBody extends StatelessWidget { firstWeekday: firstWeekday, onDaySelected: onYearDaySelected, onMonthSelected: onMonthSelected, - onCreateAtDay: onCreateAtDay, + onWeekSelected: onWeekSelected, + onCreateAtDay: (day) => onCreateAtDay(day), ), ), ScheduleViewMode.agenda => ScheduleAgendaView( @@ -1753,39 +2435,126 @@ class _HorizontalSchedulePager extends StatelessWidget { } } -class _ScheduleTaskDetailsOverlay extends StatelessWidget { +class _ScheduleTaskDetailsOverlay extends StatefulWidget { const _ScheduleTaskDetailsOverlay({ required this.child, required this.target, required this.onClose, + required this.onDirtyChanged, }); final Widget child; final _TaskDetailsTarget? target; final VoidCallback onClose; + final ValueChanged onDirtyChanged; + + @override + State<_ScheduleTaskDetailsOverlay> createState() => + _ScheduleTaskDetailsOverlayState(); +} + +class _ScheduleTaskDetailsOverlayState + extends State<_ScheduleTaskDetailsOverlay> { + final _modalFocusNode = FocusNode(debugLabel: 'scheduleTaskDetails'); + FocusNode? _previousFocus; + + @override + void initState() { + super.initState(); + if (widget.target != null) { + _previousFocus = FocusManager.instance.primaryFocus; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _modalFocusNode.requestFocus(); + } + }); + } + } + + @override + void didUpdateWidget(covariant _ScheduleTaskDetailsOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.target == null && widget.target != null) { + _previousFocus = FocusManager.instance.primaryFocus; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _modalFocusNode.requestFocus(); + } + }); + } else if (oldWidget.target != null && widget.target == null) { + final previousFocus = _previousFocus; + _previousFocus = null; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && (previousFocus?.context?.mounted ?? false)) { + previousFocus!.requestFocus(); + } + }); + } + } + + @override + void dispose() { + _modalFocusNode.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - final target = this.target; + final target = widget.target; if (target == null) { - return child; + return widget.child; } return Stack( children: [ - child, + ExcludeFocus(child: ExcludeSemantics(child: widget.child)), ModalBarrier( color: busyMaxModalBarrierColor(context), dismissible: false, ), - Center( - child: BusyMaxModalEditorSurface( - maxWidth: BusyMaxSizes.compactDetailsWidth, - maxHeight: 760, - child: TaskDetailsPane( - accountId: target.accountId, - taskListId: target.taskListId, - taskId: target.taskId, - onClose: onClose, + BlockSemantics( + child: Semantics( + scopesRoute: true, + namesRoute: true, + label: context.l10n.editTask, + explicitChildNodes: true, + child: BusyMaxModalShortcutBoundary( + child: Shortcuts( + shortcuts: const { + BusyMaxShortcutActivators.dismiss: DismissIntent(), + }, + child: Actions( + actions: { + DismissIntent: CallbackAction( + onInvoke: (_) { + widget.onClose(); + return null; + }, + ), + }, + child: FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: Focus( + autofocus: true, + focusNode: _modalFocusNode, + child: Center( + child: BusyMaxModalEditorSurface( + maxWidth: BusyMaxSizes.compactDetailsWidth, + maxHeight: 760, + child: TaskDetailsPane( + key: ValueKey(target), + accountId: target.accountId, + taskListId: target.taskListId, + taskId: target.taskId, + onClose: widget.onClose, + onDirtyChanged: widget.onDirtyChanged, + dialogBarrierColor: Colors.transparent, + ), + ), + ), + ), + ), + ), + ), ), ), ), @@ -1804,6 +2573,56 @@ class _TaskDetailsTarget { final String accountId; final String taskListId; final String taskId; + + ScheduleTaskTarget get scheduleTarget => ScheduleTaskTarget( + accountId: accountId, + taskListId: taskListId, + taskId: taskId, + ); + + @override + bool operator ==(Object other) { + return other is _TaskDetailsTarget && + other.accountId == accountId && + other.taskListId == taskListId && + other.taskId == taskId; + } + + @override + int get hashCode => Object.hash(accountId, taskListId, taskId); +} + +String _taskRouteLocation({ + required String accountId, + required String taskListId, + String? taskId, +}) { + return Uri( + pathSegments: [ + '', + 'tasks', + accountId, + taskListId, + if (taskId != null) taskId, + ], + ).toString(); +} + +class _ScheduleShortcutAction extends ContextAction<_ScheduleShortcutIntent> { + _ScheduleShortcutAction(this.state); + + final _ScheduleWorkspaceState state; + + @override + bool isEnabled(_ScheduleShortcutIntent intent, [BuildContext? context]) { + return state._isScheduleShortcutEnabled(intent.command); + } + + @override + Object? invoke(_ScheduleShortcutIntent intent, [BuildContext? context]) { + state._invokeScheduleShortcut(intent.command); + return null; + } } int _firstWeekday(BuildContext context) { @@ -1825,17 +2644,6 @@ String _scheduleRangeLabel( ScheduleViewMode.month => DateFormat.yMMMM(locale).format(selectedDate), ScheduleViewMode.year => DateFormat.y(locale).format(selectedDate), ScheduleViewMode.agenda => context.l10n.viewAgenda, - ScheduleViewMode.week => _weekRangeLabel(locale, range), + ScheduleViewMode.week => localizedScheduleRangeLabel(locale, range), }; } - -String _weekRangeLabel(String locale, ScheduleRange range) { - final end = range.end.subtract(const Duration(days: 1)); - if (range.start.year == end.year && range.start.month == end.month) { - return '${DateFormat.MMMd(locale).format(range.start)}-${DateFormat.d(locale).format(end)}, ${DateFormat.y(locale).format(end)}'; - } - if (range.start.year == end.year) { - return '${DateFormat.MMMd(locale).format(range.start)} - ${DateFormat.MMMd(locale).format(end)}, ${DateFormat.y(locale).format(end)}'; - } - return '${DateFormat.yMMMd(locale).format(range.start)} - ${DateFormat.yMMMd(locale).format(end)}'; -} diff --git a/lib/src/features/schedule/presentation/schedule_year_view.dart b/lib/src/features/schedule/presentation/schedule_year_view.dart index 8eb86c5..6c20bf1 100644 --- a/lib/src/features/schedule/presentation/schedule_year_view.dart +++ b/lib/src/features/schedule/presentation/schedule_year_view.dart @@ -1,13 +1,9 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_surface_colors.dart'; import '../../../schedule/schedule_item.dart'; -import '../../../schedule/schedule_projection.dart'; +import 'mini_calendar.dart'; class ScheduleYearView extends StatelessWidget { const ScheduleYearView({ @@ -17,7 +13,10 @@ class ScheduleYearView extends StatelessWidget { required this.firstWeekday, required this.onDaySelected, required this.onMonthSelected, + required this.onWeekSelected, required this.onCreateAtDay, + this.compact = false, + this.backgroundColor, }); final DateTime selectedDate; @@ -25,326 +24,65 @@ class ScheduleYearView extends StatelessWidget { final int firstWeekday; final ValueChanged onDaySelected; final ValueChanged onMonthSelected; + final ValueChanged onWeekSelected; final ValueChanged onCreateAtDay; + final bool compact; + final Color? backgroundColor; @override Widget build(BuildContext context) { - final grouped = ScheduleProjection.groupByDay(items); - final locale = Localizations.localeOf(context).toLanguageTag(); - + final markerColorsByDay = miniCalendarMarkerColorsForItems(context, items); return LayoutBuilder( builder: (context, constraints) { - final columns = _columnCount(constraints.maxWidth); + final columns = _columnCount(constraints.maxWidth, compact: compact); final horizontalPadding = BusyMaxSpacing.md * 2; final columnGaps = BusyMaxSpacing.md * (columns - 1); final monthWidth = (constraints.maxWidth - horizontalPadding - columnGaps) / columns; - final monthHeight = _monthPanelHeight(monthWidth); return ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: GridView.builder( - padding: const EdgeInsets.all(BusyMaxSpacing.md), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columns, - mainAxisSpacing: BusyMaxSpacing.md, - crossAxisSpacing: BusyMaxSpacing.md, - mainAxisExtent: monthHeight, - ), - itemCount: DateTime.monthsPerYear, - itemBuilder: (context, index) { - final month = DateTime(selectedDate.year, index + 1); - return _YearMonthPanel( - month: month, - selectedDate: selectedDate, - groupedItems: grouped, - firstWeekday: firstWeekday, - locale: locale, - onDaySelected: onDaySelected, - onMonthSelected: onMonthSelected, - onCreateAtDay: onCreateAtDay, - ); - }, - ), - ); - }, - ); - } -} - -class _YearMonthPanel extends StatelessWidget { - const _YearMonthPanel({ - required this.month, - required this.selectedDate, - required this.groupedItems, - required this.firstWeekday, - required this.locale, - required this.onDaySelected, - required this.onMonthSelected, - required this.onCreateAtDay, - }); - - final DateTime month; - final DateTime selectedDate; - final Map> groupedItems; - final int firstWeekday; - final String locale; - final ValueChanged onDaySelected; - final ValueChanged onMonthSelected; - final ValueChanged onCreateAtDay; - - @override - Widget build(BuildContext context) { - return BusyMaxGroupedSurface( - child: Column( - children: [ - BusyMaxActionRow( - title: DateFormat.MMMM(locale).format(month), - trailing: const Icon(YaruIcons.pan_end, size: BusyMaxSizes.iconSm), - onTap: () => onMonthSelected(month), - ), - Expanded( + color: backgroundColor ?? BusyMaxSurfaceColors.of(context).window, + child: SingleChildScrollView( child: Padding( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.sm, - 0, - BusyMaxSpacing.sm, - BusyMaxSpacing.sm, - ), - child: _YearMonthGrid( - month: month, - selectedDate: selectedDate, - groupedItems: groupedItems, - firstWeekday: firstWeekday, - locale: locale, - onDaySelected: onDaySelected, - onCreateAtDay: onCreateAtDay, - ), - ), - ), - ], - ), - ); - } -} - -class _YearMonthGrid extends StatelessWidget { - const _YearMonthGrid({ - required this.month, - required this.selectedDate, - required this.groupedItems, - required this.firstWeekday, - required this.locale, - required this.onDaySelected, - required this.onCreateAtDay, - }); - - final DateTime month; - final DateTime selectedDate; - final Map> groupedItems; - final int firstWeekday; - final String locale; - final ValueChanged onDaySelected; - final ValueChanged onCreateAtDay; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final days = _monthCells(month, firstWeekday); - - return Column( - children: [ - SizedBox( - height: 18, - child: Row( - children: [ - for (final weekday in _weekdays(firstWeekday)) - Expanded( - child: Center( - child: Text( - DateFormat.E(locale).format(_weekdayDate(weekday)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: BusyMaxSpacing.xs), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final rowHeight = math.max(0.0, constraints.maxHeight / 6); - return GridView.builder( - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: DateTime.daysPerWeek, - mainAxisExtent: rowHeight, - ), - itemCount: days.length, - itemBuilder: (context, index) { - final day = days[index]; - if (day == null) { - return const SizedBox.shrink(); - } - final key = ScheduleProjection.day(day); - return _YearDayCell( - day: day, - locale: locale, - selected: DateUtils.isSameDay(day, selectedDate), - today: DateUtils.isSameDay(day, DateTime.now()), - items: groupedItems[key] ?? const [], - onSelected: () => onDaySelected(day), - onCreate: () => onCreateAtDay(day), - ); - }, - ); - }, - ), - ), - ], - ); - } -} - -class _YearDayCell extends StatelessWidget { - const _YearDayCell({ - required this.day, - required this.locale, - required this.selected, - required this.today, - required this.items, - required this.onSelected, - required this.onCreate, - }); - - final DateTime day; - final String locale; - final bool selected; - final bool today; - final List items; - final VoidCallback onSelected; - final VoidCallback onCreate; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final surfaceColors = BusyMaxSurfaceColors.of(context); - return Tooltip( - message: DateFormat.yMMMMEEEEd(locale).format(day), - child: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxHeight <= 0) { - return const SizedBox.shrink(); - } - final canShowIndicators = - items.isNotEmpty && constraints.maxHeight >= 24; - final indicatorHeight = canShowIndicators ? 4.0 : 0.0; - final markerSize = math.min( - 22.0, - math.max( - 14.0, - constraints.maxHeight - - indicatorHeight - - (canShowIndicators ? BusyMaxSpacing.xxs : 0), - ), - ); - final textColor = (today || selected) - ? surfaceColors.foreground - : colorScheme.onSurface; - final markerColor = today - ? surfaceColors.controlActive - : selected - ? surfaceColors.controlActive - : Colors.transparent; - - return InkWell( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - onTap: onSelected, - onDoubleTap: onCreate, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: markerSize, - height: markerSize, - alignment: Alignment.center, - decoration: BoxDecoration( - color: markerColor, - shape: BoxShape.circle, - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - '${day.day}', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: textColor, - fontWeight: selected || today - ? FontWeight.w600 - : null, + padding: const EdgeInsets.all(BusyMaxSpacing.md), + child: Wrap( + spacing: BusyMaxSpacing.md, + runSpacing: BusyMaxSpacing.md, + children: [ + for (var index = 0; index < DateTime.monthsPerYear; index++) + SizedBox( + width: monthWidth, + child: BusyMaxGroupedSurface( + child: YearMonthMiniCalendar( + displayedMonth: DateTime( + selectedDate.year, + index + 1, + ), + selectedDate: selectedDate, + firstWeekday: firstWeekday, + markerColorsByDay: markerColorsByDay, + onDaySelected: onDaySelected, + onMonthSelected: onMonthSelected, + onWeekSelected: onWeekSelected, + onDayDoubleTap: onCreateAtDay, ), ), ), - ), - ), - if (canShowIndicators) ...[ - const SizedBox(height: BusyMaxSpacing.xxs), - _YearDayIndicators(items: items, height: indicatorHeight), ], - ], - ), - ); - }, - ), - ); - } -} - -class _YearDayIndicators extends StatelessWidget { - const _YearDayIndicators({required this.items, required this.height}); - - final List items; - final double height; - - @override - Widget build(BuildContext context) { - if (items.isEmpty) { - return SizedBox(height: height); - } - final brightness = Theme.of(context).brightness; - return SizedBox( - height: height, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (final item in items.take(3)) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 1), - child: DecoratedBox( - decoration: BoxDecoration( - color: ScheduleProjection.colorForItem(item, brightness), - shape: BoxShape.circle, - ), - child: const SizedBox.square(dimension: 4), ), ), - ], - ), + ), + ); + }, ); } } -int _columnCount(double width) { - if (width >= 1120) { +int _columnCount(double width, {required bool compact}) { + if (width >= 900) { return 4; } - if (width >= 760) { + if (width >= 680) { return 3; } if (width >= 520) { @@ -352,31 +90,3 @@ int _columnCount(double width) { } return 1; } - -double _monthPanelHeight(double width) { - return width < 280 ? 276 : math.min(340, math.max(292, width * 0.72)); -} - -List _monthCells(DateTime month, int firstWeekday) { - final first = DateTime(month.year, month.month); - final leading = (first.weekday - firstWeekday) % DateTime.daysPerWeek; - final daysInMonth = DateUtils.getDaysInMonth(month.year, month.month); - return [ - for (var index = 0; index < DateTime.daysPerWeek * 6; index++) - if (index >= leading && index < leading + daysInMonth) - DateTime(month.year, month.month, index - leading + 1) - else - null, - ]; -} - -List _weekdays(int firstWeekday) { - return [ - for (var offset = 0; offset < DateTime.daysPerWeek; offset++) - ((firstWeekday + offset - 1) % DateTime.daysPerWeek) + 1, - ]; -} - -DateTime _weekdayDate(int weekday) { - return DateTime(2024, 1, 1).add(Duration(days: weekday - 1)); -} diff --git a/lib/src/features/schedule/presentation/source_picker.dart b/lib/src/features/schedule/presentation/source_picker.dart deleted file mode 100644 index cf9ee9e..0000000 --- a/lib/src/features/schedule/presentation/source_picker.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:ubuntu_widgets/ubuntu_widgets.dart'; - -import '../../../app/busymax_design.dart'; -import '../../../features/calendar/data/calendar_repository.dart'; - -class SourcePicker extends StatelessWidget { - const SourcePicker({ - super.key, - required this.sources, - required this.selectedSourceId, - required this.onSelected, - this.labelText, - this.decoration, - }); - - final List sources; - final String? selectedSourceId; - final ValueChanged onSelected; - final String? labelText; - final InputDecoration? decoration; - - @override - Widget build(BuildContext context) { - final selected = sources.any((source) => source.id == selectedSourceId) - ? selectedSourceId - : sources.isEmpty - ? null - : sources.first.id; - if (selected == null) { - return const SizedBox.shrink(); - } - final label = labelText; - final inputDecoration = - decoration ?? - (label == null - ? busyMaxDropdownDecoration() - : busyMaxDropdownDecoration().copyWith( - labelText: label, - floatingLabelBehavior: FloatingLabelBehavior.auto, - )); - return MenuButtonBuilder( - selected: selected, - values: [for (final source in sources) source.id], - menuPosition: PopupMenuPosition.under, - decoration: inputDecoration, - style: busyMaxDropdownButtonStyle(context), - menuStyle: busyMaxDropdownMenuStyle(context), - itemStyle: busyMaxDropdownMenuItemStyle(context), - itemBuilder: (context, value, _) { - final source = sources.firstWhere((source) => source.id == value); - return Text(source.summary, overflow: TextOverflow.ellipsis); - }, - onSelected: (value) { - final match = sources.firstWhere((source) => source.id == value); - onSelected(match); - }, - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: Text( - sources.firstWhere((source) => source.id == selected).summary, - textAlign: TextAlign.end, - overflow: TextOverflow.ellipsis, - ), - ), - ); - } -} diff --git a/lib/src/features/settings/presentation/account_removal_dialog.dart b/lib/src/features/settings/presentation/account_removal_dialog.dart new file mode 100644 index 0000000..b5fb221 --- /dev/null +++ b/lib/src/features/settings/presentation/account_removal_dialog.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../app/busymax_design.dart'; +import '../../../app/busymax_dialogs.dart'; +import '../../../l10n/l10n.dart'; +import '../../../platform/linux_header_bar_service.dart'; + +@immutable +class AccountRemovalOptions { + const AccountRemovalOptions({required this.revokeGoogleAuthorization}); + + final bool revokeGoogleAuthorization; +} + +Future showBusyMaxAccountRemovalDialog( + BuildContext context, { + required String accountLabel, + required bool canRevokeGoogleAuthorization, + LinuxHeaderBarService? headerBarService, +}) { + return showBusyMaxModalDialog( + context, + headerBarService: headerBarService, + barrierDismissible: false, + builder: (dialogContext) => _AccountRemovalDialog( + accountLabel: accountLabel, + canRevokeGoogleAuthorization: canRevokeGoogleAuthorization, + ), + ); +} + +class _AccountRemovalDialog extends StatefulWidget { + const _AccountRemovalDialog({ + required this.accountLabel, + required this.canRevokeGoogleAuthorization, + }); + + final String accountLabel; + final bool canRevokeGoogleAuthorization; + + @override + State<_AccountRemovalDialog> createState() => _AccountRemovalDialogState(); +} + +class _AccountRemovalDialogState extends State<_AccountRemovalDialog> { + var _revokeGoogleAuthorization = false; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return BusyMaxDialogShell( + title: l10n.removeAccountTitle(widget.accountLabel), + maxWidth: 500, + actions: [ + BusyMaxPushButton.standard( + autofocus: true, + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + BusyMaxPushButton.destructive( + key: const Key('confirm-account-removal'), + context: context, + onPressed: () => Navigator.of(context).pop( + AccountRemovalOptions( + revokeGoogleAuthorization: _revokeGoogleAuthorization, + ), + ), + child: Text(l10n.removeAccountAction), + ), + ], + children: [ + Text(l10n.removeAccountConfirmation), + if (widget.canRevokeGoogleAuthorization) ...[ + const SizedBox(height: BusyMaxSpacing.lg), + YaruCheckboxListTile( + key: const Key('revoke-google-authorization'), + value: _revokeGoogleAuthorization, + onChanged: (value) { + setState(() => _revokeGoogleAuthorization = value ?? false); + }, + title: Text(l10n.revokeGoogleAccess), + subtitle: Text(l10n.revokeGoogleAccessDescription), + shape: const RoundedRectangleBorder(), + ), + ], + ], + ); + } +} diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 2d96b16..7b11ee5 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; import 'package:yaru/yaru.dart'; import '../../../app/busymax_about_dialog.dart'; @@ -11,16 +12,25 @@ import '../../../app/busymax_yaru_theme.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; +import '../../../app/busymax_glyphs.dart'; import '../../../app/busymax_keyboard_shortcuts_dialog.dart'; +import '../../../app/busymax_layout.dart'; +import '../../../core/logging/redacting_logger.dart'; import '../../../google_tasks/oauth/oauth_models.dart'; +import '../../../l10n/app_locale.dart'; import '../../../l10n/l10n.dart'; import '../../../platform/linux_header_bar_service.dart'; import '../../../task_providers/task_provider.dart'; import '../../accounts/data/accounts_repository.dart'; import '../../auth/data/auth_repository.dart'; import '../../diagnostics/presentation/diagnostics_screen.dart'; +import '../../feedback/presentation/feedback_dialog.dart'; import '../../sync/sync_auth_error.dart'; -import '../../tasks/presentation/tasks_selection_state.dart'; +import '../../tasks/presentation/desktop_date_time_fields.dart'; +import 'account_removal_dialog.dart'; + +final _settingsLogger = RedactingLogger(Logger('SettingsScreen')); +const _systemLocaleTag = 'system'; class SettingsScreen extends ConsumerStatefulWidget { const SettingsScreen({super.key, this.initialPage = SettingsPage.accounts}); @@ -33,23 +43,38 @@ class SettingsScreen extends ConsumerStatefulWidget { class _SettingsScreenState extends ConsumerState { late var _page = widget.initialPage; + late final LinuxHeaderBarSession _headerBarSession; StreamSubscription? _headerBarActions; var _headerBarReady = false; var _nativeHeaderBarAvailable = false; TaskProvider? _connectingProvider; + final _removingAccountIds = {}; @override void initState() { super.initState(); + _headerBarSession = ref.read(linuxHeaderBarServiceProvider).claimSession(); + _headerBarActions = _headerBarSession.actions.listen( + _handleHeaderBarAction, + ); unawaited(_initializeHeaderBar()); } @override void dispose() { + _headerBarSession.dispose(); unawaited(_headerBarActions?.cancel()); super.dispose(); } + @override + void didUpdateWidget(covariant SettingsScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialPage != widget.initialPage) { + _page = widget.initialPage; + } + } + @override Widget build(BuildContext context) { final selectedAccount = ref.watch(selectedAccountProvider); @@ -61,7 +86,6 @@ class _SettingsScreenState extends ConsumerState { final themeController = ref.read(busyMaxThemeControllerProvider); final l10n = context.l10n; final title = _settingsPageLabel(context, _page); - _updateSettingsHeaderBar(context, title); final pageBody = switch (_page) { SettingsPage.accounts => _AccountManagementSection( @@ -75,10 +99,9 @@ class _SettingsScreenState extends ConsumerState { onReconnect: (account) => unawaited(_connectAccount(account.provider)), onCreateTaskList: (accountId) => _createTaskList(context, ref, accountId), - onSignOut: (accountId) => _signOut(context, ref, accountId), - onDisconnect: (accountId) => _disconnect(context, ref, accountId), - onDeleteLocalData: (accountId) => - _deleteLocalData(context, ref, accountId), + removingAccountIds: _removingAccountIds, + onRemoveAccount: (account) => + unawaited(_removeAccount(context, ref, account)), ), SettingsPage.schedule => BusyMaxGroupedList( title: l10n.scheduleDisplaySettings, @@ -116,20 +139,24 @@ class _SettingsScreenState extends ConsumerState { : () => _fullSync(context, ref, accounts), ), BusyMaxSwitchRow( - title: 'Run in background when window is closed', - value: settings.runInBackgroundWhenClosed, - onChanged: settingsController.setRunInBackgroundWhenClosed, - leading: const Icon(YaruIcons.window), - ), - BusyMaxSwitchRow( - title: 'Show tray icon', + title: l10n.showTrayIcon, value: settings.showTrayIcon, onChanged: settingsController.setShowTrayIcon, leading: const Icon(YaruIcons.pin), ), BusyMaxSwitchRow( - title: 'Start minimized to tray', + title: l10n.runInBackgroundWhenClosed, + subtitle: settings.showTrayIcon ? null : l10n.requiresTrayIcon, + value: settings.runInBackgroundWhenClosed, + enabled: settings.showTrayIcon, + onChanged: settingsController.setRunInBackgroundWhenClosed, + leading: const Icon(YaruIcons.window), + ), + BusyMaxSwitchRow( + title: l10n.startMinimizedToTray, + subtitle: settings.showTrayIcon ? null : l10n.requiresTrayIcon, value: settings.startMinimizedToTray, + enabled: settings.showTrayIcon, onChanged: settingsController.setStartMinimizedToTray, leading: const Icon(YaruIcons.window_minimize), ), @@ -141,10 +168,20 @@ class _SettingsScreenState extends ConsumerState { labelFor: (value) => _themeModeLabel(context, value), onSelected: themeController.setThemeMode, ), - BusyMaxActionRow( + BusyMaxComboRow( title: l10n.currentLocale, leading: const Icon(Icons.language), - subtitle: Localizations.localeOf(context).toLanguageTag(), + values: [ + _systemLocaleTag, + for (final option in busyMaxLocaleOptions) option.tag, + ], + selected: settings.localeTag ?? _systemLocaleTag, + labelFor: (tag) => tag == _systemLocaleTag + ? l10n.themeSystem + : busyMaxLocaleEndonym(tag), + onSelected: (tag) => settingsController.setLocaleTag( + tag == _systemLocaleTag ? null : tag, + ), ), ], ), @@ -153,13 +190,13 @@ class _SettingsScreenState extends ConsumerState { filled: true, children: [ BusyMaxSwitchRow( - title: 'Event reminders', + title: l10n.eventReminders, value: settings.notifyEventReminders, onChanged: settingsController.setNotifyEventReminders, leading: const Icon(YaruIcons.calendar_day), ), BusyMaxSwitchRow( - title: 'Task reminders', + title: l10n.taskReminders, value: settings.notifyTaskReminders, onChanged: settingsController.setNotifyTaskReminders, leading: const Icon(YaruIcons.checkmark), @@ -183,19 +220,42 @@ class _SettingsScreenState extends ConsumerState { leading: const Icon(YaruIcons.warning), ), BusyMaxComboRow( - title: 'Notification detail level', + title: l10n.notificationDetailLevel, leading: const Icon(YaruIcons.eye), values: NotificationDetailLevel.values, selected: settings.notificationDetailLevel, - labelFor: _notificationDetailLabel, + labelFor: (value) => _notificationDetailLabel(context, value), onSelected: settingsController.setNotificationDetailLevel, ), BusyMaxSwitchRow( - title: 'Quiet hours', + title: l10n.quietHours, + subtitle: l10n.quietHoursDescription, value: settings.quietHoursEnabled, onChanged: settingsController.setQuietHoursEnabled, leading: const Icon(YaruIcons.clear_night), ), + DesktopTimeValueRow( + label: l10n.quietHoursStart, + time: settings.quietHoursStart, + enabled: settings.quietHoursEnabled, + allowEmpty: false, + onChanged: (time) { + if (time != null) { + unawaited(settingsController.setQuietHoursStart(time)); + } + }, + ), + DesktopTimeValueRow( + label: l10n.quietHoursEnd, + time: settings.quietHoursEnd, + enabled: settings.quietHoursEnabled, + allowEmpty: false, + onChanged: (time) { + if (time != null) { + unawaited(settingsController.setQuietHoursEnd(time)); + } + }, + ), ], ), SettingsPage.privacy => BusyMaxGroupedList( @@ -208,45 +268,68 @@ class _SettingsScreenState extends ConsumerState { onChanged: settingsController.setRedactTaskContentInDiagnostics, leading: const Icon(YaruIcons.shield_warning), ), - BusyMaxSwitchRow( - title: l10n.detailedNotifications, - value: settings.detailedNotifications, - onChanged: settingsController.setDetailedNotifications, - leading: const Icon(YaruIcons.eye), - ), ], ), SettingsPage.diagnostics => const DiagnosticsPanel(scrollable: false), }; return Scaffold( - body: Row( - children: [ - SizedBox( - width: BusyMaxSizes.sidebarWidth, - child: _SettingsSidebar( - selected: _page, - onSelected: (page) => setState(() => _page = page), - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_showFallbackHeader) - _SettingsFallbackHeader(title: title, onBack: _goBack), - Expanded( - child: BusyMaxClamp( - maxWidth: 760, - margin: EdgeInsets.zero, - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), - child: pageBody, + backgroundColor: BusyMaxSurfaceColors.of(context).window, + body: LayoutBuilder( + builder: (context, constraints) { + final showSidebar = BusyMaxLayoutRules.showSettingsSidebar( + constraints.maxWidth, + ); + _updateSettingsHeaderBar( + context, + title, + settings: settings, + showSidebar: showSidebar, + ); + final content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_showFallbackHeader) + _SettingsFallbackHeader(title: title, onBack: _goBack), + if (!showSidebar) + Padding( + padding: const EdgeInsets.fromLTRB( + BusyMaxSpacing.lg, + BusyMaxSpacing.md, + BusyMaxSpacing.lg, + 0, + ), + child: _SettingsPageSelector( + selected: _page, + onSelected: _selectPage, ), ), - ], - ), - ), - ], + Expanded( + child: BusyMaxClamp( + maxWidth: 760, + margin: EdgeInsets.zero, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + child: pageBody, + ), + ), + ], + ); + if (!showSidebar) { + return content; + } + return Row( + children: [ + SizedBox( + width: BusyMaxSizes.sidebarWidth, + child: _SettingsSidebar( + selected: _page, + onSelected: _selectPage, + ), + ), + Expanded(child: content), + ], + ); + }, ), ); } @@ -259,25 +342,38 @@ class _SettingsScreenState extends ConsumerState { } Future _initializeHeaderBar() async { - final service = ref.read(linuxHeaderBarServiceProvider); - _headerBarActions = service.actions.listen(_handleHeaderBarAction); - await service.initialize(); + await _headerBarSession.initialize(); if (!mounted) { return; } setState(() { _headerBarReady = true; - _nativeHeaderBarAvailable = service.isAvailable; + _nativeHeaderBarAvailable = _headerBarSession.isAvailable; }); + if (_headerBarSession.isAvailable) { + unawaited( + _headerBarSession.setOnboardingControls( + visible: false, + canGoBack: false, + canContinue: false, + backLabel: '', + continueLabel: '', + force: true, + ), + ); + } } void _handleHeaderBarAction(BusyMaxHeaderBarAction action) { + if (!_headerBarSession.isCurrent) { + return; + } if (action == BusyMaxHeaderBarAction.back) { _goBack(); return; } if (action == BusyMaxHeaderBarAction.settings) { - setState(() => _page = SettingsPage.accounts); + _selectPage(SettingsPage.accounts); return; } if (action == BusyMaxHeaderBarAction.keyboardShortcuts) { @@ -289,13 +385,20 @@ class _SettingsScreenState extends ConsumerState { ); return; } + if (action == BusyMaxHeaderBarAction.reportIssue) { + unawaited( + showBusyMaxFeedbackDialog( + context, + submissionService: ref.read(feedbackSubmissionServiceProvider), + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ), + ); + return; + } if (action == BusyMaxHeaderBarAction.aboutBusyMax) { unawaited( showBusyMaxAboutDialog( context, - feedbackSubmissionService: ref.read( - feedbackSubmissionServiceProvider, - ), headerBarService: ref.read(linuxHeaderBarServiceProvider), ), ); @@ -303,47 +406,74 @@ class _SettingsScreenState extends ConsumerState { } void _goBack() { + if (context.canPop()) { + context.pop(); + return; + } context.go('/schedule'); } - void _updateSettingsHeaderBar(BuildContext context, String title) { + void _selectPage(SettingsPage page) { + if (_page != page) { + setState(() => _page = page); + } + final router = GoRouter.maybeOf(context); + final uri = router?.state.uri; + if (router == null || uri == null || uri.path != '/settings') { + return; + } + final routePage = uri.queryParameters['page']; + if (routePage == settingsPageRouteValue(page)) { + return; + } + unawaited( + router.replace( + uri + .replace( + queryParameters: { + ...uri.queryParameters, + 'page': settingsPageRouteValue(page), + }, + ) + .toString(), + ), + ); + } + + void _updateSettingsHeaderBar( + BuildContext context, + String title, { + required AppSettings settings, + required bool showSidebar, + }) { + if (!_nativeHeaderBarAvailable) { + return; + } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } - final service = ref.read(linuxHeaderBarServiceProvider); - unawaited(() async { - await service.initialize(); - await service.setScheduleControlsVisible(false); - await service.setBackVisible(true); - await service.setOnboardingControls( - visible: false, - canGoBack: false, - canContinue: false, - backLabel: '', - continueLabel: '', - force: true, - ); - await service.setTitleRange(title); - await service.setCanRefresh(false); - await service.setCanCreate(false); - await service.setSearchActive(false); - await service.setSidebarVisible(true); - }()); + unawaited( + _headerBarSession.updateState( + BusyMaxHeaderBarState( + title: title, + viewMode: settings.scheduleViewMode, + canRefresh: false, + canCreateEvent: false, + canCreateTask: false, + searchActive: false, + searchQuery: '', + canShowSidebar: showSidebar, + sidebarVisible: showSidebar, + navigationVisible: false, + scheduleControlsVisible: false, + backVisible: true, + ), + ), + ); }); } - Future _signOut( - BuildContext context, - WidgetRef ref, - String accountId, - ) async { - await ref.read(authRepositoryProvider).signOut(accountId: accountId); - if (context.mounted) { - await _afterAccountRemoved(context, ref, accountId); - } - } - Future _connectAccount(TaskProvider provider) async { if (_connectingProvider != null) { return; @@ -390,41 +520,48 @@ class _SettingsScreenState extends ConsumerState { } } - Future _disconnect( + Future _removeAccount( BuildContext context, WidgetRef ref, - String accountId, + AccountEntity account, ) async { - await ref - .read(authRepositoryProvider) - .revokeAndSignOut(accountId: accountId); - if (context.mounted) { - await _afterAccountRemoved(context, ref, accountId); + if (_removingAccountIds.contains(account.id)) { + return; } - } - Future _deleteLocalData( - BuildContext context, - WidgetRef ref, - String accountId, - ) async { - final confirmed = await showBusyMaxConfirm( + final options = await showBusyMaxAccountRemovalDialog( context, - title: context.l10n.deleteLocalData, - message: context.l10n.deleteLocalDataConfirmation, - confirmLabel: context.l10n.delete, - destructive: true, + accountLabel: account.displayLabel, + canRevokeGoogleAuthorization: + account.provider == TaskProvider.google && account.isSignedIn, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); - if (!context.mounted || !confirmed) { + if (!context.mounted || options == null) { return; } - if (context.mounted) { - await ref + setState(() => _removingAccountIds.add(account.id)); + try { + final result = await ref .read(authRepositoryProvider) - .deleteLocalAccountData(accountId: accountId); + .removeAccount( + accountId: account.id, + revokeAuthorization: options.revokeGoogleAuthorization, + ); + if (context.mounted) { + if (result.authorizationRevocationFailed) { + _showMessage(context, context.l10n.accountRemovedGoogleRevokeFailed); + } + await _afterAccountRemoved(context, ref, account.id); + } + } on Object catch (error) { + _settingsLogger.warning('Account removal failed: $error'); if (context.mounted) { - await _afterAccountRemoved(context, ref, accountId); + _showMessage(context, context.l10n.removeAccountFailed); + } + } finally { + if (mounted) { + setState(() => _removingAccountIds.remove(account.id)); } } } @@ -434,7 +571,10 @@ class _SettingsScreenState extends ConsumerState { WidgetRef ref, String accountId, ) async { - final title = await _taskListTitleDialog(context); + final title = await _taskListTitleDialog( + context, + ref.read(linuxHeaderBarServiceProvider), + ); if (title == null || title.trim().isEmpty) { return; } @@ -486,18 +626,15 @@ class _SettingsSidebar extends StatelessWidget { @override Widget build(BuildContext context) { - return Material( - color: BusyMaxSurfaceColors.of(context).sidebar, - child: ListView( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.xs, - vertical: BusyMaxSpacing.md, - ), + return BusyMaxSidebarSurface( + child: BusyMaxSidebarNavigation( children: [ for (final page in SettingsPage.values) - _SettingsSidebarItem( - page: page, - selected: selected == page, + BusyMaxSidebarNavigationTile( + key: ValueKey('settings-navigation-${page.name}'), + selected: page == selected, + leading: Icon(_settingsPageIcon(page)), + title: Text(_settingsPageLabel(context, page)), onTap: () => onSelected(page), ), ], @@ -506,60 +643,57 @@ class _SettingsSidebar extends StatelessWidget { } } -class _SettingsSidebarItem extends StatelessWidget { - const _SettingsSidebarItem({ - required this.page, +class _SettingsPageSelector extends StatelessWidget { + const _SettingsPageSelector({ required this.selected, - required this.onTap, + required this.onSelected, }); - final SettingsPage page; - final bool selected; - final VoidCallback onTap; + final SettingsPage selected; + final ValueChanged onSelected; @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Material( - color: selected - ? colorScheme.onSurface.withValues(alpha: 0.08) - : Colors.transparent, - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - child: InkWell( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - onTap: onTap, - child: SizedBox( - height: 42, - child: Row( - children: [ - const SizedBox(width: BusyMaxSpacing.md), - Icon( - _settingsPageIcon(page), - size: BusyMaxSizes.iconSm, - color: selected - ? colorScheme.onSurface - : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: Text( - _settingsPageLabel(context, page), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: selected - ? colorScheme.onSurface - : colorScheme.onSurfaceVariant, + return SizedBox( + key: const ValueKey('settings-page-selector'), + width: double.infinity, + child: BusyMaxMenuButton( + tooltip: _settingsPageLabel(context, selected), + entries: [ + for (final page in SettingsPage.values) + BusyMaxMenuEntry( + value: page, + label: _settingsPageLabel(context, page), + icon: _settingsPageIcon(page), + selected: page == selected, + ), + ], + onSelected: onSelected, + triggerBuilder: (context, trigger) { + return trigger.anchor( + child: Semantics( + expanded: trigger.isOpen, + child: BusyMaxPushButton.standard( + onPressed: trigger.onPressed, + focusNode: trigger.focusNode, + child: Row( + children: [ + Icon(_settingsPageIcon(selected)), + const SizedBox(width: BusyMaxSpacing.sm), + Expanded( + child: Text( + _settingsPageLabel(context, selected), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - ), + const Icon(YaruIcons.pan_down), + ], ), - const SizedBox(width: BusyMaxSpacing.md), - ], + ), ), - ), - ), + ); + }, ), ); } @@ -580,7 +714,7 @@ class _SettingsFallbackHeader extends StatelessWidget { const SizedBox(width: BusyMaxSpacing.sm), YaruIconButton( tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: const Icon(YaruIcons.go_previous), + icon: Icon(BusyMaxGlyphs.backFor(Directionality.of(context))), onPressed: onBack, ), const SizedBox(width: BusyMaxSpacing.sm), @@ -590,7 +724,7 @@ class _SettingsFallbackHeader extends StatelessWidget { textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, + style: busyMaxHeaderTitleStyle(context), ), ), const SizedBox(width: BusyMaxSizes.headerIconButton), @@ -671,12 +805,16 @@ String _timeOfDayLabel(BuildContext context, int minute) { ); } -Future _taskListTitleDialog(BuildContext context) { +Future _taskListTitleDialog( + BuildContext context, + LinuxHeaderBarService headerBarService, +) { return showBusyMaxTextPrompt( context, title: context.l10n.newList, label: context.l10n.title, actionLabel: context.l10n.create, + headerBarService: headerBarService, ); } @@ -690,9 +828,8 @@ class _AccountManagementSection extends StatelessWidget { required this.onAddMicrosoft, required this.onReconnect, required this.onCreateTaskList, - required this.onSignOut, - required this.onDisconnect, - required this.onDeleteLocalData, + required this.removingAccountIds, + required this.onRemoveAccount, }); final List accounts; @@ -703,9 +840,8 @@ class _AccountManagementSection extends StatelessWidget { final VoidCallback onAddMicrosoft; final void Function(AccountEntity account) onReconnect; final void Function(String accountId) onCreateTaskList; - final void Function(String accountId) onSignOut; - final void Function(String accountId) onDisconnect; - final void Function(String accountId) onDeleteLocalData; + final Set removingAccountIds; + final void Function(AccountEntity account) onRemoveAccount; @override Widget build(BuildContext context) { @@ -745,11 +881,12 @@ class _AccountManagementSection extends StatelessWidget { for (final account in accounts) _AccountManagementCard( account: account, - onReconnect: connecting ? null : () => onReconnect(account), + removing: removingAccountIds.contains(account.id), + onReconnect: connecting || removingAccountIds.contains(account.id) + ? null + : () => onReconnect(account), onCreateTaskList: () => onCreateTaskList(account.id), - onSignOut: () => onSignOut(account.id), - onDisconnect: () => onDisconnect(account.id), - onDeleteLocalData: () => onDeleteLocalData(account.id), + onRemoveAccount: () => onRemoveAccount(account), ), ], ); @@ -759,19 +896,17 @@ class _AccountManagementSection extends StatelessWidget { class _AccountManagementCard extends StatelessWidget { const _AccountManagementCard({ required this.account, + required this.removing, required this.onReconnect, required this.onCreateTaskList, - required this.onSignOut, - required this.onDisconnect, - required this.onDeleteLocalData, + required this.onRemoveAccount, }); final AccountEntity account; + final bool removing; final VoidCallback? onReconnect; final VoidCallback onCreateTaskList; - final VoidCallback onSignOut; - final VoidCallback onDisconnect; - final VoidCallback onDeleteLocalData; + final VoidCallback onRemoveAccount; @override Widget build(BuildContext context) { @@ -795,27 +930,18 @@ class _AccountManagementCard extends StatelessWidget { BusyMaxActionRow( title: l10n.newList, leading: const Icon(YaruIcons.plus), - onTap: onCreateTaskList, - ), - BusyMaxActionRow( - title: l10n.signOutThisAccount, - leading: const Icon(YaruIcons.log_out), - onTap: onSignOut, + onTap: removing ? null : onCreateTaskList, ), ], BusyMaxActionRow( - title: l10n.disconnectThisAccount, - leading: const Icon(YaruIcons.insert_link), - onTap: onDisconnect, - ), - BusyMaxActionRow( - title: l10n.deleteLocalDataForThisAccount, + title: removing ? l10n.removingAccount : l10n.removeAccount, + subtitle: l10n.removeAccountDescription, leading: Icon( YaruIcons.trash, color: Theme.of(context).colorScheme.error, ), destructive: true, - onTap: onDeleteLocalData, + onTap: removing ? null : onRemoveAccount, ), ], ); @@ -827,10 +953,6 @@ Future _afterAccountRemoved( WidgetRef ref, String removedAccountId, ) async { - ref.read(selectedTaskListIdProvider.notifier).state = null; - ref.read(selectedTaskIdProvider.notifier).state = null; - ref.read(allTasksModeProvider.notifier).state = true; - final accounts = await ref .read(accountsRepositoryProvider) .listSignedInAccounts(); @@ -880,10 +1002,14 @@ String _themeModeLabel( }; } -String _notificationDetailLabel(NotificationDetailLevel level) { +String _notificationDetailLabel( + BuildContext context, + NotificationDetailLevel level, +) { + final l10n = context.l10n; return switch (level) { - NotificationDetailLevel.private => 'Private', - NotificationDetailLevel.normal => 'Normal', + NotificationDetailLevel.private => l10n.notificationDetailPrivate, + NotificationDetailLevel.normal => l10n.notificationDetailNormal, }; } diff --git a/lib/src/features/sync/account_sync_operations.dart b/lib/src/features/sync/account_sync_operations.dart new file mode 100644 index 0000000..1b72a10 --- /dev/null +++ b/lib/src/features/sync/account_sync_operations.dart @@ -0,0 +1,50 @@ +typedef AccountSyncAction = + Future Function(String accountId, {required bool full}); + +abstract interface class AccountSyncOperations { + Future syncAccount(String accountId, {required bool full}); + + Future syncTasks(String accountId, {required bool full}); + + Future syncCalendar(String accountId, {required bool full}); +} + +final class DelegatingAccountSyncOperations implements AccountSyncOperations { + const DelegatingAccountSyncOperations({ + required AccountSyncAction syncTasks, + required AccountSyncAction syncCalendar, + }) : _syncTasks = syncTasks, + _syncCalendar = syncCalendar; + + final AccountSyncAction _syncTasks; + final AccountSyncAction _syncCalendar; + + @override + Future syncAccount(String accountId, {required bool full}) async { + await syncTasks(accountId, full: full); + await syncCalendar(accountId, full: full); + } + + @override + Future syncTasks(String accountId, {required bool full}) { + return _syncTasks(accountId, full: full); + } + + @override + Future syncCalendar(String accountId, {required bool full}) { + return _syncCalendar(accountId, full: full); + } +} + +final class DisabledAccountSyncOperations implements AccountSyncOperations { + const DisabledAccountSyncOperations(); + + @override + Future syncAccount(String accountId, {required bool full}) async {} + + @override + Future syncTasks(String accountId, {required bool full}) async {} + + @override + Future syncCalendar(String accountId, {required bool full}) async {} +} diff --git a/lib/src/features/task_lists/presentation/task_lists_sidebar.dart b/lib/src/features/task_lists/presentation/task_lists_sidebar.dart deleted file mode 100644 index 75c8f24..0000000 --- a/lib/src/features/task_lists/presentation/task_lists_sidebar.dart +++ /dev/null @@ -1,684 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:yaru/yaru.dart'; - -import '../../../app/app_bootstrap.dart'; -import '../../../app/busymax_yaru_theme.dart'; -import '../../../app/busymax_design.dart'; -import '../../../app/busymax_dialogs.dart'; -import '../../../features/accounts/data/accounts_repository.dart'; -import '../../../l10n/l10n.dart'; -import '../../../task_providers/task_provider.dart'; -import '../../tasks/presentation/tasks_selection_state.dart'; -import '../../sync/sync_auth_error.dart'; -import '../data/task_lists_repository.dart'; - -class TaskListsSidebar extends ConsumerWidget { - const TaskListsSidebar({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final selectedListId = ref.watch(selectedTaskListIdProvider); - final selectedAccount = ref.watch(selectedAccountProvider); - final allTasksMode = ref.watch(allTasksModeProvider); - final accounts = ref.watch(accountsStreamProvider).valueOrNull ?? const []; - final l10n = context.l10n; - - return SizedBox( - width: BusyMaxSizes.sidebarWidth, - child: Material( - color: BusyMaxSurfaceColors.of(context).sidebar, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const _SidebarHeader(), - const Divider(height: 1), - Expanded( - child: accounts.isEmpty - ? Center(child: Text(l10n.signInToViewTaskLists)) - : ListView( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.sm, - BusyMaxSpacing.sm, - BusyMaxSpacing.sm, - BusyMaxSpacing.sm, - ), - children: [ - _AllTasksRow( - selected: allTasksMode, - onTap: () => _selectAllTasks(ref), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.sm, - BusyMaxSpacing.lg, - BusyMaxSpacing.sm, - BusyMaxSpacing.xs, - ), - child: Text( - l10n.accounts, - style: busyMaxSectionHeaderStyle(context), - ), - ), - for (final account in accounts) - _AccountTaskListsSection( - key: ValueKey(account.id), - account: account, - repository: ref.watch( - taskListsRepositoryForAccountProvider(account.id), - ), - selected: - !allTasksMode && - account.id == selectedAccount?.id, - selectedListId: - !allTasksMode && - account.id == selectedAccount?.id - ? selectedListId - : null, - onSelectList: (list) => - _selectTaskList(ref, account.id, list.id), - onCreateList: (repository) => - _createList(context, repository), - onRenameList: (repository, list) => - _renameList(context, repository, list), - onDeleteList: (repository, list) => - _deleteList(context, ref, repository, list), - ), - ], - ), - ), - const Divider(height: 1), - Padding( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.sm, - BusyMaxSpacing.sm, - BusyMaxSpacing.sm, - BusyMaxSpacing.sm, - ), - child: _SidebarFooterButton( - icon: YaruIcons.settings, - label: l10n.settings, - onTap: () => context.go('/settings'), - ), - ), - ], - ), - ), - ); - } - - Future _renameList( - BuildContext context, - TaskListsRepository repository, - TaskListEntity list, - ) async { - final title = await showBusyMaxTextPrompt( - context, - title: context.l10n.renameList, - label: context.l10n.title, - actionLabel: context.l10n.rename, - initialValue: list.title, - ); - if (title == null || title.trim().isEmpty || title.trim() == list.title) { - return; - } - await repository.renameTaskList(list.id, title.trim()); - } - - Future _createList( - BuildContext context, - TaskListsRepository repository, - ) async { - final title = await showBusyMaxTextPrompt( - context, - title: context.l10n.newList, - label: context.l10n.title, - actionLabel: context.l10n.create, - ); - if (title == null || title.trim().isEmpty) { - return; - } - await repository.createTaskList(title.trim()); - } - - Future _deleteList( - BuildContext context, - WidgetRef ref, - TaskListsRepository repository, - TaskListEntity list, - ) async { - final confirmed = await showBusyMaxConfirm( - context, - title: context.l10n.deleteList, - message: context.l10n.deleteListConfirmation(list.title), - confirmLabel: context.l10n.delete, - destructive: true, - ); - if (!confirmed) { - return; - } - await repository.deleteTaskList(list.id); - final selectedListId = ref.read(selectedTaskListIdProvider); - if (selectedListId == list.id) { - ref.read(selectedTaskListIdProvider.notifier).state = null; - ref.read(selectedTaskIdProvider.notifier).state = null; - ref.read(allTasksModeProvider.notifier).state = true; - } - } - - void _selectAllTasks(WidgetRef ref) { - ref.read(selectedTaskListIdProvider.notifier).state = null; - ref.read(selectedTaskIdProvider.notifier).state = null; - ref.read(allTasksModeProvider.notifier).state = true; - } - - void _selectTaskList(WidgetRef ref, String accountId, String taskListId) { - final currentAccountId = ref.read(selectedAccountProvider)?.id; - if (currentAccountId != accountId) { - ref.read(selectedAccountIdProvider.notifier).state = accountId; - unawaited(_syncSelectedAccount(ref, accountId)); - } - - ref.read(selectedTaskListIdProvider.notifier).state = taskListId; - ref.read(selectedTaskIdProvider.notifier).state = null; - ref.read(allTasksModeProvider.notifier).state = false; - } -} - -Future _syncSelectedAccount(WidgetRef ref, String accountId) async { - try { - await ref.read(signedInSyncRunnerProvider)(accountId, false); - } on Object catch (error) { - if (!isMissingOAuthTokenError(error)) { - return; - } - try { - await ref.read(authRepositoryProvider).markReconnectRequired(accountId); - } on Object { - // The selected-account change should not surface an unhandled sync error. - } - } -} - -class _AccountTaskListsSection extends StatefulWidget { - const _AccountTaskListsSection({ - super.key, - required this.account, - required this.repository, - required this.selected, - required this.selectedListId, - required this.onSelectList, - required this.onCreateList, - required this.onRenameList, - required this.onDeleteList, - }); - - final AccountEntity account; - final TaskListsRepository repository; - final bool selected; - final String? selectedListId; - final void Function(TaskListEntity list) onSelectList; - final Future Function(TaskListsRepository repository) onCreateList; - final Future Function( - TaskListsRepository repository, - TaskListEntity list, - ) - onRenameList; - final Future Function( - TaskListsRepository repository, - TaskListEntity list, - ) - onDeleteList; - - @override - State<_AccountTaskListsSection> createState() => - _AccountTaskListsSectionState(); -} - -class _AccountTaskListsSectionState extends State<_AccountTaskListsSection> { - late Stream> _taskLists; - var _expanded = true; - - @override - void initState() { - super.initState(); - _taskLists = _watchTaskLists(); - } - - @override - void didUpdateWidget(covariant _AccountTaskListsSection oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.repository != widget.repository || - oldWidget.account.id != widget.account.id) { - _taskLists = _watchTaskLists(); - _expanded = true; - } - } - - Stream> _watchTaskLists() { - return widget.repository.watchTaskLists(); - } - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - - return Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _AccountSectionHeader( - account: widget.account, - expanded: _expanded, - onCreateList: () => widget.onCreateList(widget.repository), - onTap: () { - setState(() { - _expanded = !_expanded; - }); - }, - ), - StreamBuilder>( - stream: _taskLists, - builder: (context, snapshot) { - if (!_expanded) { - return const SizedBox.shrink(); - } - - final lists = snapshot.data ?? const []; - if (lists.isEmpty) { - return Padding( - padding: const EdgeInsets.fromLTRB(48, 6, 10, 8), - child: Text( - l10n.noTaskListsSynced, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [for (final list in lists) _buildListRow(list)], - ); - }, - ), - ], - ), - ); - } - - Widget _buildListRow(TaskListEntity list) { - final isMicrosoft = widget.account.provider == TaskProvider.microsoft; - final canRenameDelete = !isMicrosoft || list.canRenameOrDeleteForMicrosoft; - return _TaskListRow( - list: list, - selected: widget.selected && list.id == widget.selectedListId, - isMicrosoft: isMicrosoft, - canRenameDelete: canRenameDelete, - onTap: () => widget.onSelectList(list), - onRename: canRenameDelete - ? () => widget.onRenameList(widget.repository, list) - : null, - onDelete: canRenameDelete - ? () => widget.onDeleteList(widget.repository, list) - : null, - ); - } -} - -class _AllTasksRow extends StatelessWidget { - const _AllTasksRow({required this.selected, required this.onTap}); - - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return _SidebarSelectableTile( - selected: selected, - padding: EdgeInsets.zero, - onTap: onTap, - leading: const Icon(Icons.all_inbox, size: 18), - title: Text(context.l10n.allTasks), - ); - } -} - -class _AccountSectionHeader extends StatelessWidget { - const _AccountSectionHeader({ - required this.account, - required this.expanded, - required this.onTap, - required this.onCreateList, - }); - - final AccountEntity account; - final bool expanded; - final VoidCallback onTap; - final VoidCallback onCreateList; - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.zero, - child: YaruListTile( - onTap: onTap, - leading: Icon(_providerIcon(account.provider), size: 18), - title: Text( - _providerHeaderLabel(context, account.provider), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - _accountIdentityLabel(context, account), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - YaruIconButton( - tooltip: context.l10n.newList, - iconSize: 28, - icon: const Icon(YaruIcons.plus), - onPressed: onCreateList, - ), - AnimatedRotation( - turns: expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 160), - child: const Icon(YaruIcons.pan_end, size: 16), - ), - ], - ), - ), - ); - } -} - -class _SidebarSelectableTile extends StatelessWidget { - const _SidebarSelectableTile({ - required this.selected, - required this.title, - this.leading, - this.trailing, - this.onTap, - this.padding = const EdgeInsets.symmetric(horizontal: 8), - }); - - final bool selected; - final Widget title; - final Widget? leading; - final Widget? trailing; - final VoidCallback? onTap; - final EdgeInsetsGeometry padding; - - @override - Widget build(BuildContext context) { - return Padding( - padding: padding, - child: YaruSelectableContainer( - selected: selected, - selectionColor: Theme.of(context).listTileTheme.selectedTileColor, - padding: EdgeInsets.zero, - onTap: onTap, - child: YaruListTile(leading: leading, title: title, trailing: trailing), - ), - ); - } -} - -class _SidebarHeader extends StatelessWidget { - const _SidebarHeader(); - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 12, 12), - child: LayoutBuilder( - builder: (context, constraints) { - final logo = Image.asset( - 'assets/branding/busymax-logo.png', - width: 26, - height: 26, - errorBuilder: (context, error, stackTrace) => - const Icon(YaruIcons.task_list_filled, size: 26), - ); - if (constraints.maxWidth < 74) { - return Align( - alignment: AlignmentDirectional.centerStart, - child: FittedBox(fit: BoxFit.scaleDown, child: logo), - ); - } - - return Row( - children: [ - logo, - const SizedBox(width: 10), - Expanded( - child: Text( - l10n.appTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - ], - ); - }, - ), - ); - } -} - -class _TaskListRow extends StatelessWidget { - const _TaskListRow({ - required this.list, - required this.selected, - required this.isMicrosoft, - required this.canRenameDelete, - required this.onTap, - required this.onRename, - required this.onDelete, - }); - - final TaskListEntity list; - final bool selected; - final bool isMicrosoft; - final bool canRenameDelete; - final VoidCallback onTap; - final Future Function()? onRename; - final Future Function()? onDelete; - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - return _SidebarSelectableTile( - selected: selected, - padding: const EdgeInsets.only( - left: 34, - top: BusyMaxSpacing.xxs, - bottom: BusyMaxSpacing.xxs, - ), - onTap: onTap, - leading: Icon( - list.localDirty ? YaruIcons.sync_error : YaruIcons.task_list, - size: 17, - ), - title: Text(list.title, maxLines: 1, overflow: TextOverflow.ellipsis), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isMicrosoft && list.isMicrosoftBuiltIn) - Tooltip( - message: l10n.builtInMicrosoftListCannotRenameDelete, - child: SizedBox( - width: 58, - child: BusyMaxInlineBadge( - label: l10n.builtInMicrosoftList, - tooltip: l10n.builtInMicrosoftListCannotRenameDelete, - ), - ), - ), - if (list.pendingDelete) - const Icon(YaruIcons.trash, size: 16) - else - Opacity( - opacity: 0.72, - child: BusyMaxMenuButton<_TaskListAction>( - tooltip: l10n.listActions, - onSelected: (action) { - switch (action) { - case _TaskListAction.rename: - if (canRenameDelete) { - onRename?.call(); - } - break; - case _TaskListAction.delete: - if (canRenameDelete) { - onDelete?.call(); - } - break; - } - }, - entries: [ - BusyMaxMenuEntry( - value: _TaskListAction.rename, - label: l10n.rename, - icon: Icons.edit_outlined, - enabled: canRenameDelete, - ), - BusyMaxMenuEntry( - value: _TaskListAction.delete, - label: l10n.delete, - icon: YaruIcons.trash, - enabled: canRenameDelete, - destructive: true, - ), - ], - ), - ), - ], - ), - ); - } -} - -enum _TaskListAction { rename, delete } - -class _SidebarFooterButton extends StatelessWidget { - const _SidebarFooterButton({ - required this.icon, - required this.label, - required this.onTap, - }); - - final IconData icon; - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return YaruListTile( - titleText: label, - leading: Icon(icon, size: 17), - onTap: onTap, - ); - } -} - -IconData _providerIcon(TaskProvider? provider) { - return switch (provider) { - TaskProvider.microsoft => Icons.account_circle_outlined, - TaskProvider.google => YaruIcons.globe, - null => YaruIcons.user, - }; -} - -String _providerHeaderLabel(BuildContext context, TaskProvider provider) { - final l10n = context.l10n; - return switch (provider) { - TaskProvider.google => l10n.googleTasksProvider, - TaskProvider.microsoft => l10n.microsoftTodoProvider, - }; -} - -String _accountIdentityLabel(BuildContext context, AccountEntity account) { - final metadata = _accountMetadata(account); - final name = _firstAccountIdentityValue([ - account.displayName, - metadata['displayName'], - metadata['name'], - ]); - final email = _firstAccountIdentityValue([ - account.email, - metadata['email'], - metadata['mail'], - metadata['userPrincipalName'], - ]); - final providerName = account.provider.displayName.trim(); - - if (name != null && - name.isNotEmpty && - email != null && - email.isNotEmpty && - name != email) { - return '$name · $email'; - } - - if (email != null && email.isNotEmpty) { - return email; - } - - final providerAccountId = account.providerAccountId?.trim(); - if (providerAccountId != null && - providerAccountId.isNotEmpty && - providerAccountId.contains('@')) { - return providerAccountId; - } - - if (name != null && name.isNotEmpty && name != providerName) { - return name; - } - - return context.l10n.signedInAccount; -} - -Map _accountMetadata(AccountEntity account) { - final jsonText = account.providerMetadataJson; - if (jsonText == null || jsonText.trim().isEmpty) { - return const {}; - } - - try { - final decoded = jsonDecode(jsonText); - if (decoded is! Map) { - return const {}; - } - return { - for (final entry in decoded.entries) - if (entry.key is String && - entry.value is String && - (entry.value as String).trim().isNotEmpty) - entry.key as String: (entry.value as String).trim(), - }; - } on FormatException { - return const {}; - } -} - -String? _firstAccountIdentityValue(Iterable values) { - for (final value in values) { - final trimmed = value?.trim(); - if (trimmed != null && trimmed.isNotEmpty) { - return trimmed; - } - } - return null; -} diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index 50d6734..a873463 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -2,13 +2,36 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_glyphs.dart'; +import 'package:busymax/src/app/busymax_surface_colors.dart'; +import 'package:busymax/src/core/time/local_time_zone.dart'; +import 'package:busymax/src/core/time/time_zone_catalog.dart'; import 'package:busymax/src/l10n/l10n.dart'; +import 'package:busymax/src/features/schedule/presentation/mini_calendar.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_anchored_popover.dart'; +import 'package:busymax/src/features/tasks/presentation/time_zone_selection_dialog.dart'; import 'package:yaru/yaru.dart'; @visibleForTesting const nativeDateTimePickerChannelName = 'busymax/native_date_time_picker'; const _nativeDateTimePicker = NativeDateTimePicker(); +const _dateTimePickerMaxWidth = 340.0; +const _dateTimePickerContentMaxHeight = 320.0; +const _dateTimePickerMinimumFittedContentHeight = 180.0; +const _dateTimePickerPopoverMinimumHeight = 300.0; +const _dateTimePickerPopoverPadding = EdgeInsets.all(BusyMaxSpacing.lg); +const _timePickerMaxWidth = 260.0; +const _timePickerMinimumWidth = 240.0; +const _timePickerPopoverMinimumHeight = 220.0; +const _timePickerPopoverPadding = EdgeInsets.all(BusyMaxSpacing.md); +const _timePickerInputControlSize = BusyMaxSizes.popoverActionButton; +const _timePickerInputColumnWidth = BusyMaxSizes.popoverActionButton; +const _timePickerEditableCursorWidth = 2.0; +// RenderEditable reserves this gap plus cursorWidth after a single line. +const _timePickerEditableCursorGap = 1.0; +const _timePickerEditableLeadingInset = + _timePickerEditableCursorWidth + _timePickerEditableCursorGap; class NativeDateTimePicker { const NativeDateTimePicker(); @@ -29,15 +52,27 @@ class NativeDateTimePicker { }); } + Future pickTime({ + required String title, + required String? initialTime, + required String cancelLabel, + required String okLabel, + }) async { + return _invoke('pickTime', { + 'title': title, + 'initialTime': initialTime, + 'cancelLabel': cancelLabel, + 'okLabel': okLabel, + }); + } + Future _invoke( String method, Map arguments, ) async { try { - return NativeDatePickResult( - available: true, - date: await _channel.invokeMethod(method, arguments), - ); + final value = await _channel.invokeMethod(method, arguments); + return NativeDatePickResult(available: true, date: value, time: value); } on MissingPluginException { return const NativeDatePickResult(available: false); } @@ -45,10 +80,11 @@ class NativeDateTimePicker { } class NativeDatePickResult { - const NativeDatePickResult({required this.available, this.date}); + const NativeDatePickResult({required this.available, this.date, this.time}); final bool available; final String? date; + final String? time; } class DesktopDateField extends StatefulWidget { @@ -59,8 +95,7 @@ class DesktopDateField extends StatefulWidget { required this.onChanged, this.enabled = true, this.onClear, - this.emptyLabel, - this.useNativePicker = true, + this.useNativePicker = false, }); final String label; @@ -68,7 +103,6 @@ class DesktopDateField extends StatefulWidget { final ValueChanged onChanged; final bool enabled; final VoidCallback? onClear; - final String? emptyLabel; final bool useNativePicker; @override @@ -83,8 +117,7 @@ class DesktopDateValueRow extends StatelessWidget { required this.onChanged, this.enabled = true, this.onClear, - this.emptyLabel, - this.useNativePicker = true, + this.useNativePicker = false, }); final String label; @@ -92,161 +125,116 @@ class DesktopDateValueRow extends StatelessWidget { final ValueChanged onChanged; final bool enabled; final VoidCallback? onClear; - final String? emptyLabel; final bool useNativePicker; @override Widget build(BuildContext context) { - final formatted = formatDesktopDate(context, date); - final displayValue = formatted.isEmpty - ? emptyLabel ?? context.l10n.noneValue - : formatted; - final canClear = date != null && date!.isNotEmpty && onClear != null; - - return BusyMaxCalendarValueRow( + return DesktopDateField( label: label, - value: displayValue, - leading: const Icon(YaruIcons.calendar), + date: date, + onChanged: onChanged, enabled: enabled, - onTap: () => _pickNativeDate(context), - trailingIcons: [ - if (canClear) - YaruIconButton( - tooltip: MaterialLocalizations.of(context).deleteButtonTooltip, - iconSize: BusyMaxSizes.iconMd, - icon: const Icon(YaruIcons.window_close), - onPressed: enabled ? onClear : null, - ), - const Icon(Icons.edit_outlined, size: BusyMaxSizes.iconMd), - const Icon(YaruIcons.calendar, size: BusyMaxSizes.iconMd), - ], + onClear: onClear, + useNativePicker: useNativePicker, ); } - - Future _pickNativeDate(BuildContext context) async { - if (!enabled) { - return; - } - if (!useNativePicker) { - final fallbackPicked = await showBusyMaxDateValueDialog( - context, - label: label, - initialDate: date, - ); - if (context.mounted && fallbackPicked != null) { - onChanged(fallbackPicked); - } - return; - } - final localizations = MaterialLocalizations.of(context); - final picked = await _nativeDateTimePicker.pickDate( - title: label, - initialDate: date, - cancelLabel: localizations.cancelButtonLabel, - okLabel: localizations.okButtonLabel, - ); - if (!context.mounted) { - return; - } - if (picked.date != null) { - onChanged(picked.date!); - return; - } - if (picked.available) { - return; - } - final fallbackPicked = await showBusyMaxDateValueDialog( - context, - label: label, - initialDate: date, - ); - if (context.mounted && fallbackPicked != null) { - onChanged(fallbackPicked); - } - } } class _DesktopDateFieldState extends State { - late final YaruDateTimeEntryController _controller; - var _syncingController = false; + late final TextEditingController _controller; @override void initState() { super.initState(); - _controller = YaruDateTimeEntryController( - dateTime: parseDateOnly(widget.date), - ); + _controller = TextEditingController(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _syncVisibleValue(); } @override void didUpdateWidget(covariant DesktopDateField oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.date != widget.date) { - final nextDate = parseDateOnly(widget.date); - final currentDate = _controller.dateTime; - if (!isSameDate(currentDate, nextDate)) { - _syncingController = true; - _controller.dateTime = nextDate; - _syncingController = false; - } + _syncVisibleValue(); } } @override - Widget build(BuildContext context) { - final dateEntry = _withoutInternalDateTimeEntryLabel( - context, - YaruDateTimeEntry( - controller: _controller, - includeTime: false, - firstDateTime: DateTime(1900), - lastDateTime: DateTime(2100, 12, 31), - acceptEmpty: true, - clearIconSemanticLabel: widget.label, - onChanged: (date) { - if (_syncingController) { - return; - } - if (date == null) { - widget.onClear?.call(); - return; - } - widget.onChanged(encodeDateOnly(date)); - }, - ), - ); + void dispose() { + _controller.dispose(); + super.dispose(); + } - return YaruListTile.square( - leading: const Icon(YaruIcons.calendar), - titleText: widget.label, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 190, - child: widget.enabled - ? dateEntry - : Opacity(opacity: 0.6, child: IgnorePointer(child: dateEntry)), + @override + Widget build(BuildContext context) { + final canClear = widget.date?.isNotEmpty ?? false; + return BusyMaxCalendarValueRow( + label: widget.label, + entry: Builder( + builder: (fieldContext) => TextFormField( + controller: _controller, + readOnly: true, + showCursor: false, + enableInteractiveSelection: false, + enabled: widget.enabled, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: widget.label, ), + onTap: widget.enabled + ? () => _pickNativeDate(context, fieldContext) + : null, + ), + ), + trailingIcons: [ + if (canClear && widget.onClear != null) YaruIconButton( + tooltip: MaterialLocalizations.of(context).deleteButtonTooltip, + onPressed: widget.enabled ? widget.onClear : null, + icon: const Icon(YaruIcons.window_close), + ), + Builder( + builder: (buttonContext) => YaruIconButton( tooltip: widget.label, - iconSize: 28, - onPressed: widget.enabled ? () => _pickNativeDate(context) : null, + onPressed: widget.enabled + ? () => _pickNativeDate(context, buttonContext) + : null, icon: const Icon(YaruIcons.calendar), ), - ], - ), + ), + ], enabled: widget.enabled, - onTap: widget.enabled ? () => _pickNativeDate(context) : null, ); } - Future _pickNativeDate(BuildContext context) async { + void _syncVisibleValue() { + final formatted = formatDesktopDate(context, widget.date); + if (_controller.text == formatted) { + return; + } + _controller.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } + + Future _pickNativeDate( + BuildContext context, + BuildContext anchorContext, + ) async { + if (!widget.enabled) { + return; + } if (!widget.useNativePicker) { final fallbackPicked = await showBusyMaxDateValueDialog( context, label: widget.label, initialDate: widget.date, + anchorContext: anchorContext, ); if (mounted && fallbackPicked != null) { _applyPickedDate(fallbackPicked); @@ -274,6 +262,7 @@ class _DesktopDateFieldState extends State { context, label: widget.label, initialDate: widget.date, + anchorContext: anchorContext, ); if (mounted && fallbackPicked != null) { _applyPickedDate(fallbackPicked); @@ -281,21 +270,12 @@ class _DesktopDateFieldState extends State { } void _applyPickedDate(String picked) { - final pickedDate = parseDateOnly(picked); - if (!isSameDate(_controller.dateTime, pickedDate)) { - _syncingController = true; - _controller.dateTime = pickedDate; - _syncingController = false; - } + final formatted = formatDesktopDate(context, picked); + _controller.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); widget.onChanged(picked); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || isSameDate(_controller.dateTime, pickedDate)) { - return; - } - _syncingController = true; - _controller.dateTime = pickedDate; - _syncingController = false; - }); } } @@ -303,13 +283,51 @@ Future showBusyMaxDateValueDialog( BuildContext context, { required String label, required String? initialDate, + BuildContext? anchorContext, }) { - return showDialog( + return showScheduleAnchoredPopover( context: context, - barrierColor: Colors.transparent, - builder: (context) { - return _DesktopDateValueDialog(label: label, initialDate: initialDate); - }, + anchorContext: anchorContext ?? context, + semanticLabel: label, + preferredWidth: _dateTimePickerMaxWidth, + minimumWidth: _dateTimePickerMaxWidth, + preferredMinimumHeight: _dateTimePickerPopoverMinimumHeight, + builder: (context, arrowSide, arrowAlignment) => _DesktopDateValueDialog( + label: label, + initialDate: initialDate, + arrowSide: arrowSide, + arrowAlignment: arrowAlignment, + ), + ); +} + +Future showBusyMaxTimeValueDialog( + BuildContext context, { + required String label, + required String? initialTime, + required bool allowEmpty, + String? initialTimeZone, + ValueChanged? onTimeChanged, + ValueChanged? onTimeZoneChanged, + BuildContext? anchorContext, +}) { + return showScheduleAnchoredPopover( + context: context, + anchorContext: anchorContext ?? context, + semanticLabel: label, + preferredWidth: _timePickerMaxWidth, + minimumWidth: _timePickerMinimumWidth, + preferredMinimumHeight: _timePickerPopoverMinimumHeight, + builder: (context, arrowSide, arrowAlignment) => _DesktopTimeValueDialog( + label: label, + initialTime: initialTime, + initialTimeZone: initialTimeZone, + allowEmpty: allowEmpty, + onTimeChanged: onTimeChanged, + onTimeZoneChanged: onTimeZoneChanged, + arrowSide: arrowSide, + arrowAlignment: arrowAlignment, + ), ); } @@ -317,10 +335,14 @@ class _DesktopDateValueDialog extends StatefulWidget { const _DesktopDateValueDialog({ required this.label, required this.initialDate, + required this.arrowSide, + required this.arrowAlignment, }); final String label; final String? initialDate; + final BusyMaxPopoverArrowSide arrowSide; + final double arrowAlignment; @override State<_DesktopDateValueDialog> createState() => @@ -328,61 +350,274 @@ class _DesktopDateValueDialog extends StatefulWidget { } class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { - late final YaruDateTimeEntryController _controller; - DateTime? _selected; + static final _firstDate = DateTime(1900); + static final _lastDate = DateTime(2100, 12, 31); + late DateTime _selected; + late DateTime _displayedMonth; @override void initState() { super.initState(); - _selected = parseDateOnly(widget.initialDate) ?? _today(); - _controller = YaruDateTimeEntryController(dateTime: _selected); + _selected = _supportedInitialDate(widget.initialDate); + _displayedMonth = DateTime(_selected.year, _selected.month); } @override Widget build(BuildContext context) { - return BusyMaxDialogShell( - title: widget.label, - maxWidth: 360, - actions: [ - BusyMaxPushButton.outlined( - onPressed: () => Navigator.of(context).pop(), - child: Text(context.l10n.cancel), - ), - BusyMaxPushButton.filled( - onPressed: _selected == null ? null : _submit, - child: Text(MaterialLocalizations.of(context).okButtonLabel), - ), - ], - children: [ - _withoutInternalDateTimeEntryLabel( - context, - YaruDateTimeEntry( - controller: _controller, - includeTime: false, - firstDateTime: DateTime(1900), - lastDateTime: DateTime(2100, 12, 31), - acceptEmpty: false, - clearIconSemanticLabel: widget.label, - onChanged: (date) { - setState(() { - _selected = date; - }); - }, - ), - ), - ], + return LayoutBuilder( + builder: (context, constraints) { + final contentHeight = _calculateDateTimePickerPopupHeight(constraints); + Widget calendarGrid({bool shrinkToFitHeight = false}) { + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + BusyMaxSpacing.headerInset, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.md, + ), + child: MiniCalendarGrid( + displayedMonth: _displayedMonth, + selectedDate: _selected, + firstWeekday: _firstWeekday(context), + shrinkToFitHeight: shrinkToFitHeight, + onDaySelected: (date) => _setSelectedDate( + date, + submit: + date.year == _displayedMonth.year && + date.month == _displayedMonth.month, + ), + ), + ); + } + + final content = Column( + mainAxisSize: MainAxisSize.min, + children: [_buildDateModeHeader(context), calendarGrid()], + ); + final Widget pickerContent; + if (contentHeight >= _dateTimePickerContentMaxHeight) { + pickerContent = content; + } else if (contentHeight >= _dateTimePickerMinimumFittedContentHeight) { + pickerContent = SizedBox( + height: contentHeight, + child: Column( + children: [ + _buildDateModeHeader(context), + Expanded(child: calendarGrid(shrinkToFitHeight: true)), + ], + ), + ); + } else { + pickerContent = ScrollConfiguration( + behavior: ScrollConfiguration.of( + context, + ).copyWith(scrollbars: false), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: contentHeight), + child: SingleChildScrollView(child: content), + ), + ); + } + return BusyMaxContentPopoverSurface( + arrowSide: widget.arrowSide, + arrowAlignment: widget.arrowAlignment, + padding: _dateTimePickerPopoverPadding, + child: pickerContent, + ); + }, ); } + void _setSelectedDate(DateTime value, {bool submit = false}) { + final adjusted = _coerceSupportedRange(value); + setState(() { + _selected = adjusted; + _displayedMonth = DateTime(adjusted.year, adjusted.month); + }); + if (submit) { + _submit(); + } + } + void _submit() { - final selected = _selected; - if (selected == null) { - return; + Navigator.of(context).pop(encodeDateOnly(_selected)); + } + + Widget _buildDateModeHeader(BuildContext context) { + final locale = Localizations.localeOf(context).toLanguageTag(); + final monthLabel = DateFormat.MMMM(locale).format(_displayedMonth); + final colorScheme = Theme.of(context).colorScheme; + final direction = Directionality.of(context); + + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + BusyMaxSpacing.headerInset, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.sm, + ), + child: Row( + children: [ + _stepButton( + context, + colorScheme: colorScheme, + tooltip: context.l10n.previousMonth, + icon: BusyMaxGlyphs.startFor(direction), + onPressed: () => _showMonth( + DateTime(_displayedMonth.year, _displayedMonth.month - 1), + ), + ), + Expanded( + flex: BusyMaxCalendarHeaderLayout.monthControlFlex, + child: FittedBox( + fit: BoxFit.scaleDown, + child: _stepLabel(context, monthLabel, null), + ), + ), + _stepButton( + context, + colorScheme: colorScheme, + tooltip: context.l10n.nextMonth, + icon: BusyMaxGlyphs.endFor(direction), + onPressed: () => _showMonth( + DateTime(_displayedMonth.year, _displayedMonth.month + 1), + ), + ), + const SizedBox(width: BusyMaxSpacing.sm), + _stepButton( + context, + colorScheme: colorScheme, + tooltip: context.l10n.previousYear, + icon: BusyMaxGlyphs.startFor(direction), + onPressed: () => _showMonth( + DateTime(_displayedMonth.year - 1, _displayedMonth.month), + ), + ), + Expanded( + flex: BusyMaxCalendarHeaderLayout.yearControlFlex, + child: FittedBox( + fit: BoxFit.scaleDown, + child: _stepLabel(context, '${_displayedMonth.year}', null), + ), + ), + _stepButton( + context, + colorScheme: colorScheme, + tooltip: context.l10n.nextYear, + icon: BusyMaxGlyphs.endFor(direction), + onPressed: () => _showMonth( + DateTime(_displayedMonth.year + 1, _displayedMonth.month), + ), + ), + ], + ), + ); + } + + void _showMonth(DateTime value) { + final firstMonth = DateTime(_firstDate.year, _firstDate.month); + final lastMonth = DateTime(_lastDate.year, _lastDate.month); + final month = DateTime(value.year, value.month); + final adjusted = month.isBefore(firstMonth) + ? firstMonth + : month.isAfter(lastMonth) + ? lastMonth + : month; + setState(() => _displayedMonth = adjusted); + } + + Widget _stepButton( + BuildContext context, { + required ColorScheme colorScheme, + required String tooltip, + required IconData icon, + required VoidCallback onPressed, + }) { + return BusyMaxHeaderIconButton( + tooltip: tooltip, + iconSize: BusyMaxSizes.headerIcon, + icon: Icon(icon), + onPressed: onPressed, + foregroundColor: colorScheme.onSurfaceVariant, + backgroundColor: WidgetStatePropertyAll(Colors.transparent), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + ); + } + + Widget _stepLabel( + BuildContext context, + String label, + VoidCallback? onPressed, + ) { + final colorScheme = Theme.of(context).colorScheme; + final labelStyle = + (busyMaxSectionHeaderStyle(context) ?? + Theme.of(context).textTheme.titleSmall) + ?.copyWith(color: colorScheme.onSurface); + if (onPressed == null) { + return Text( + label, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: labelStyle, + ); } - Navigator.of(context).pop(encodeDateOnly(selected)); + return TextButton( + onPressed: onPressed, + style: busyMaxHeaderTextButtonStyle( + context, + foregroundColor: colorScheme.onSurface, + backgroundColor: WidgetStatePropertyAll(Colors.transparent), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: labelStyle, + ), + ); + } + + DateTime _coerceSupportedRange(DateTime date) { + if (date.isBefore(_firstDate)) { + return _firstDate; + } + if (date.isAfter(_lastDate)) { + return _lastDate; + } + return date; + } + + int _firstWeekday(BuildContext context) { + final index = MaterialLocalizations.of(context).firstDayOfWeekIndex; + return index == 0 ? DateTime.sunday : index; + } + + DateTime _supportedInitialDate(String? encodedDate) { + final date = parseDateOnly(encodedDate) ?? _today(); + if (date.isBefore(_firstDate)) { + return _firstDate; + } + if (date.isAfter(_lastDate)) { + return _lastDate; + } + return date; } } +double _calculateDateTimePickerPopupHeight(BoxConstraints constraints) { + final availableHeight = + constraints.maxHeight - + (_dateTimePickerPopoverPadding.vertical + + BusyMaxSizes.popoverArrowHeight); + if (constraints.maxHeight <= 0 || constraints.maxHeight.isInfinite) { + return _dateTimePickerContentMaxHeight; + } + return availableHeight.clamp(0, _dateTimePickerContentMaxHeight); +} + class DesktopTimeField extends StatefulWidget { const DesktopTimeField({ super.key, @@ -390,12 +625,22 @@ class DesktopTimeField extends StatefulWidget { required this.time, required this.onChanged, this.enabled = true, + this.allowEmpty = true, + this.onValidityChanged, + this.useNativePicker = false, + this.timeZone, + this.onTimeZoneChanged, }); final String label; final String? time; final ValueChanged onChanged; final bool enabled; + final bool allowEmpty; + final ValueChanged? onValidityChanged; + final bool useNativePicker; + final String? timeZone; + final ValueChanged? onTimeZoneChanged; @override State createState() => _DesktopTimeFieldState(); @@ -408,334 +653,748 @@ class DesktopTimeValueRow extends StatelessWidget { required this.time, required this.onChanged, this.enabled = true, - this.emptyLabel, this.allowEmpty = true, + this.onValidityChanged, + this.useNativePicker = false, + this.timeZone, + this.onTimeZoneChanged, }); final String label; final String? time; final ValueChanged onChanged; final bool enabled; - final String? emptyLabel; final bool allowEmpty; + final ValueChanged? onValidityChanged; + final bool useNativePicker; + final String? timeZone; + final ValueChanged? onTimeZoneChanged; @override Widget build(BuildContext context) { - final formatted = formatDesktopTime(context, time); - final displayValue = formatted.isEmpty - ? emptyLabel ?? context.l10n.noneValue - : formatted; - - return BusyMaxCalendarValueRow( + return DesktopTimeField( label: label, - value: displayValue, - leading: const Icon(Icons.schedule), + time: time, + onChanged: onChanged, enabled: enabled, - onTap: () => _editTime(context), - trailingIcons: const [ - Icon(Icons.edit_outlined, size: BusyMaxSizes.iconMd), - Icon(Icons.schedule, size: BusyMaxSizes.iconMd), - ], - ); - } - - Future _editTime(BuildContext context) async { - if (!enabled) { - return; - } - await showDialog( - context: context, - builder: (context) { - return _DesktopTimeValueDialog( - label: label, - time: time, - onChanged: onChanged, - allowEmpty: allowEmpty, - ); - }, + allowEmpty: allowEmpty, + onValidityChanged: onValidityChanged, + useNativePicker: useNativePicker, + timeZone: timeZone, + onTimeZoneChanged: onTimeZoneChanged, ); } } -class _DesktopTimeValueDialog extends StatefulWidget { - const _DesktopTimeValueDialog({ - required this.label, - required this.time, - required this.onChanged, - required this.allowEmpty, - }); - - final String label; - final String? time; - final ValueChanged onChanged; - final bool allowEmpty; - - @override - State<_DesktopTimeValueDialog> createState() => - _DesktopTimeValueDialogState(); -} - -class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { +class _DesktopTimeFieldState extends State { late final TextEditingController _controller; - final _focusNode = FocusNode(); - TimeOfDay? _selected; - var _invalid = false; + late final FocusNode _focusNode; + var _syncingText = false; + var _inputValid = true; + bool? _reportedValidity; + var _hasPendingEmission = false; + String? _pendingEmission; @override void initState() { super.initState(); - _selected = parseTimeOfDay(widget.time); - _controller = TextEditingController( - text: _selected == null ? '' : encodeTimeOfDay(_selected!), - ); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _focusNode.requestFocus(); - _controller.selection = TextSelection( - baseOffset: 0, - extentOffset: _controller.text.length, - ); + _inputValid = _storedTimeIsValid(widget.time, widget.allowEmpty); + _controller = TextEditingController(); + _focusNode = FocusNode(debugLabel: widget.label) + ..addListener(_handleFocusChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_focusNode.hasFocus) { + _syncVisibleValue(); + } + _reportValidityAfterBuild(); + } + + @override + void didUpdateWidget(covariant DesktopTimeField oldWidget) { + super.didUpdateWidget(oldWidget); + final timeChanged = oldWidget.time != widget.time; + final timeZoneChanged = oldWidget.timeZone != widget.timeZone; + final policyChanged = oldWidget.allowEmpty != widget.allowEmpty; + final availabilityChanged = oldWidget.enabled != widget.enabled; + final validityCallbackAdded = + oldWidget.onValidityChanged == null && widget.onValidityChanged != null; + if (validityCallbackAdded) { + _reportedValidity = null; + } + if (!timeChanged && + !timeZoneChanged && + !policyChanged && + !availabilityChanged && + !validityCallbackAdded) { + return; + } + if (!timeChanged && + !timeZoneChanged && + !policyChanged && + !availabilityChanged) { + _reportValidityAfterBuild(); + return; + } + if (timeZoneChanged && + !timeChanged && + !policyChanged && + !availabilityChanged) { + if (!_focusNode.hasFocus && _inputValid) { + _syncVisibleValue(); } - }); + _reportValidityAfterBuild(); + return; + } + + final acceptedLocalEmission = + timeChanged && + !availabilityChanged && + _hasPendingEmission && + widget.time == _pendingEmission; + _hasPendingEmission = false; + _pendingEmission = null; + + if (!acceptedLocalEmission || availabilityChanged) { + _inputValid = _storedTimeIsValid(widget.time, widget.allowEmpty); + _syncVisibleValue(); + _reportValidityAfterBuild(); + } else if (!_focusNode.hasFocus) { + _syncVisibleValue(); + } } @override void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); _controller.dispose(); - _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final timeEntry = _BusyMaxTimeTextEntry( - controller: _controller, - focusNode: _focusNode, + return BusyMaxCalendarValueRow( label: widget.label, - errorText: _invalid - ? MaterialLocalizations.of(context).invalidTimeLabel - : null, - onChanged: _setSelectedTimeText, - onSubmitted: (_) => _submit(), - ); - return BusyMaxDialogShell( - title: widget.label, - maxWidth: 360, - actions: [ - BusyMaxPushButton.outlined( - onPressed: () => Navigator.of(context).pop(), - child: Text(context.l10n.cancel), + entry: TextFormField( + controller: _controller, + focusNode: _focusNode, + enabled: widget.enabled, + keyboardType: TextInputType.datetime, + textInputAction: TextInputAction.done, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: widget.label, + errorText: _inputValid + ? null + : MaterialLocalizations.of(context).invalidTimeLabel, ), - BusyMaxPushButton.filled( - onPressed: !_invalid && (widget.allowEmpty || _selected != null) - ? _submit - : null, - child: Text(MaterialLocalizations.of(context).okButtonLabel), + onChanged: _handleTextChanged, + onFieldSubmitted: (_) => _normalizeOrRestore(), + ), + trailingIcons: [ + Builder( + builder: (buttonContext) => YaruIconButton( + tooltip: widget.label, + onPressed: widget.enabled + ? () => _pickNativeTime(context, buttonContext) + : null, + icon: const Icon(YaruIcons.clock), + ), ), ], - children: [_withoutInternalDateTimeEntryLabel(context, timeEntry)], + enabled: widget.enabled, ); } - void _setSelectedTimeText(String value) { - final trimmed = value.trim(); - final parsed = parseTimeInput(trimmed); - setState(() { - _selected = parsed; - _invalid = trimmed.isNotEmpty && parsed == null; + Future _pickNativeTime( + BuildContext context, + BuildContext anchorContext, + ) async { + if (!widget.enabled) { + return; + } + final localizations = MaterialLocalizations.of(context); + if (widget.useNativePicker) { + final picked = await _nativeDateTimePicker.pickTime( + title: widget.label, + initialTime: widget.time, + cancelLabel: localizations.cancelButtonLabel, + okLabel: localizations.okButtonLabel, + ); + if (!context.mounted) { + return; + } + if (picked.time != null) { + _emitTime(picked.time!); + _focusNode.requestFocus(); + return; + } + if (picked.available) { + return; + } + } + + final picked = await showBusyMaxTimeValueDialog( + context, + label: widget.label, + initialTime: widget.time, + allowEmpty: widget.allowEmpty, + initialTimeZone: widget.timeZone, + onTimeChanged: _emitTime, + onTimeZoneChanged: widget.onTimeZoneChanged, + anchorContext: anchorContext, + ); + if (!context.mounted) { + return; + } + if (picked != null) { + _emitTime(picked); + _focusNode.requestFocus(); + } + } + + void _handleTextChanged(String input) { + if (_syncingText) { + return; + } + final trimmed = input.trim(); + if (trimmed.isEmpty) { + _setInputValidity(widget.allowEmpty); + if (widget.allowEmpty) { + _emitTime(null); + } + return; + } + final parsed = parseDesktopTimeInput(context, trimmed); + if (parsed == null) { + _setInputValidity(false); + return; + } + _setInputValidity(true); + _emitTime(encodeTimeOfDay(parsed)); + } + + void _handleFocusChanged() { + if (_focusNode.hasFocus) { + if (_inputValid) { + _syncVisibleValue(includeTimeZone: false); + } + return; + } + if (_restoreRejectedPendingEmission()) { + return; + } + _normalizeOrRestore(); + } + + void _normalizeOrRestore() { + final input = _controller.text.trim(); + if (input.isEmpty && widget.allowEmpty) { + _setInputValidity(true); + _emitTime(null); + _syncVisibleValue(); + return; + } + final parsed = parseDesktopTimeInput(context, input); + if (parsed == null) { + _setInputValidity(false); + return; + } + _setInputValidity(true); + _emitTime(encodeTimeOfDay(parsed)); + _syncVisibleValue(time: parsed); + } + + void _syncVisibleValue({TimeOfDay? time, bool? includeTimeZone}) { + final parsed = time ?? parseTimeOfDay(widget.time); + final formatted = parsed == null + ? '' + : _formatVisibleTime( + parsed, + includeTimeZone: includeTimeZone ?? !_focusNode.hasFocus, + ); + if (_controller.text == formatted) { + return; + } + _syncingText = true; + _controller.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + _syncingText = false; + } + + String _formatVisibleTime(TimeOfDay time, {required bool includeTimeZone}) { + final formatted = formatMaterialTime(context, time); + if (!includeTimeZone) { + return formatted; + } + final timeZone = widget.timeZone; + if (timeZone == null || timeZone.isEmpty) { + return formatted; + } + final code = BusyMaxTimeZoneCatalog.location(timeZone).code; + return '$formatted ($code)'; + } + + void _emitTime(String? value) { + if (value == widget.time || + _hasPendingEmission && value == _pendingEmission) { + return; + } + _hasPendingEmission = true; + _pendingEmission = value; + widget.onChanged(value); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + !_hasPendingEmission || + _focusNode.hasFocus || + widget.time == _pendingEmission) { + return; + } + _restoreRejectedPendingEmission(); }); } - void _submit() { - if (_invalid || (!widget.allowEmpty && _selected == null)) { + bool _restoreRejectedPendingEmission() { + if (!_hasPendingEmission || widget.time == _pendingEmission) { + return false; + } + _hasPendingEmission = false; + _pendingEmission = null; + _inputValid = _storedTimeIsValid(widget.time, widget.allowEmpty); + _syncVisibleValue(); + _reportValidity(_inputValid); + setState(() {}); + return true; + } + + void _setInputValidity(bool valid) { + if (_inputValid != valid) { + setState(() { + _inputValid = valid; + }); + } + _reportValidity(valid); + } + + void _reportValidityAfterBuild() { + final validity = _inputValid; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _inputValid == validity) { + _reportValidity(validity); + } + }); + } + + void _reportValidity(bool valid) { + if (_reportedValidity == valid) { return; } - widget.onChanged(_selected == null ? null : encodeTimeOfDay(_selected!)); - Navigator.of(context).pop(); + _reportedValidity = valid; + widget.onValidityChanged?.call(valid); } } -class _DesktopTimeFieldState extends State { - late final TextEditingController _controller; - var _syncingController = false; +class _DesktopTimeValueDialog extends StatefulWidget { + const _DesktopTimeValueDialog({ + required this.label, + required this.initialTime, + required this.initialTimeZone, + required this.allowEmpty, + required this.onTimeChanged, + required this.onTimeZoneChanged, + required this.arrowSide, + required this.arrowAlignment, + }); + + final String label; + final String? initialTime; + final String? initialTimeZone; + final bool allowEmpty; + final ValueChanged? onTimeChanged; + final ValueChanged? onTimeZoneChanged; + final BusyMaxPopoverArrowSide arrowSide; + final double arrowAlignment; + + @override + State<_DesktopTimeValueDialog> createState() => + _DesktopTimeValueDialogState(); +} + +class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { + late final TextEditingController _hourController; + late final TextEditingController _minuteController; + late final FocusNode _hourFocusNode; + late final FocusNode _minuteFocusNode; + bool _syncingText = false; + bool _inputValid = true; + late String _selectedTimeZone; @override void initState() { super.initState(); - final time = parseTimeOfDay(widget.time); - _controller = TextEditingController( - text: time == null ? '' : encodeTimeOfDay(time), - ); + _hourController = TextEditingController(); + _minuteController = TextEditingController(); + _hourFocusNode = FocusNode(debugLabel: 'Time picker hour'); + _minuteFocusNode = FocusNode(debugLabel: 'Time picker minute'); + _inputValid = + widget.allowEmpty || parseTimeOfDay(widget.initialTime) != null; + _selectedTimeZone = widget.initialTimeZone ?? localIanaTimeZone(); + _syncVisibleValue(); } @override - void didUpdateWidget(covariant DesktopTimeField oldWidget) { + void didUpdateWidget(covariant _DesktopTimeValueDialog oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.time != widget.time) { - final nextTime = parseTimeOfDay(widget.time); - final nextText = nextTime == null ? '' : encodeTimeOfDay(nextTime); - if (_controller.text != nextText) { - _syncingController = true; - _controller.text = nextText; - _syncingController = false; - } + if (oldWidget.initialTime != widget.initialTime) { + _syncVisibleValue(); + } + if (oldWidget.initialTimeZone != widget.initialTimeZone && + widget.initialTimeZone != null) { + _selectedTimeZone = widget.initialTimeZone!; } } @override void dispose() { - _controller.dispose(); + _hourController.dispose(); + _minuteController.dispose(); + _hourFocusNode.dispose(); + _minuteFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final timeEntry = _withoutInternalDateTimeEntryLabel( - context, - _BusyMaxTimeTextEntry( - controller: _controller, - label: widget.label, - onChanged: (time) { - if (_syncingController) { - return; - } - final parsed = parseTimeInput(time); - if (time.trim().isEmpty || parsed != null) { - widget.onChanged(parsed == null ? null : encodeTimeOfDay(parsed)); - } - }, - ), - ); - return YaruListTile.square( - leading: const Icon(Icons.schedule), - titleText: widget.label, - trailing: SizedBox( - width: 168, - child: widget.enabled - ? timeEntry - : Opacity(opacity: 0.6, child: IgnorePointer(child: timeEntry)), + final materialLocalizations = MaterialLocalizations.of(context); + return BusyMaxContentPopoverSurface( + arrowSide: widget.arrowSide, + arrowAlignment: widget.arrowAlignment, + padding: _timePickerPopoverPadding, + child: FocusTraversalGroup( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: _timePickerInputColumnWidth, + child: _timeInputSection( + context: context, + buttonWidth: _timePickerInputColumnWidth, + controller: _hourController, + focusNode: _hourFocusNode, + label: materialLocalizations.timePickerHourLabel, + onIncrement: () => _changeHour(1), + onDecrement: () => _changeHour(-1), + ), + ), + const SizedBox(width: BusyMaxSpacing.xs), + SizedBox( + width: BusyMaxSpacing.sm, + child: Center( + child: Text( + ':', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + const SizedBox(width: BusyMaxSpacing.xs), + SizedBox( + width: _timePickerInputColumnWidth, + child: _timeInputSection( + context: context, + buttonWidth: _timePickerInputColumnWidth, + controller: _minuteController, + focusNode: _minuteFocusNode, + label: materialLocalizations.timePickerMinuteLabel, + onIncrement: () => _changeMinute(1), + onDecrement: () => _changeMinute(-1), + ), + ), + ], + ), + if (!_inputValid) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.md, + vertical: BusyMaxSpacing.sm, + ), + child: Text( + MaterialLocalizations.of(context).invalidTimeLabel, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + const SizedBox(height: BusyMaxSpacing.md), + BusyMaxPushButton.standard( + onPressed: _openTimezoneDialog, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.public, + size: BusyMaxSizes.popoverActionIcon, + ), + const SizedBox(width: BusyMaxSpacing.xs), + Flexible( + child: Text( + _timezoneDisplayLabel(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + ), + ), + ], + ), + ), + ], + ), ), - enabled: widget.enabled, ); } -} -class _BusyMaxTimeTextEntry extends StatelessWidget { - const _BusyMaxTimeTextEntry({ - required this.controller, - required this.label, - required this.onChanged, - this.focusNode, - this.errorText, - this.onSubmitted, - }); + Future _openTimezoneDialog() async { + final selected = await showBusyMaxTimeZoneSelectionDialog( + context, + selectedTimeZone: _selectedTimeZone, + ); + if (!mounted || selected == null || selected == _selectedTimeZone) { + return; + } + setState(() => _selectedTimeZone = selected); + widget.onTimeZoneChanged?.call(selected); + } - final TextEditingController controller; - final FocusNode? focusNode; - final String label; - final ValueChanged onChanged; - final ValueChanged? onSubmitted; - final String? errorText; + String _timezoneDisplayLabel(BuildContext context) { + return BusyMaxTimeZoneCatalog.location(_selectedTimeZone).displayLabel; + } - @override - Widget build(BuildContext context) { - return TextFormField( - controller: controller, - focusNode: focusNode, - keyboardType: TextInputType.datetime, - textInputAction: TextInputAction.done, - textAlign: TextAlign.center, - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp('[0-9:]')), - const _BusyMaxTimeInputFormatter(), - ], - decoration: InputDecoration( - hintText: '--:--', - labelText: label, - isDense: true, - errorText: errorText, + Widget _timeInputSection({ + required BuildContext context, + required double buttonWidth, + required TextEditingController controller, + required FocusNode focusNode, + required String label, + required VoidCallback onIncrement, + required VoidCallback onDecrement, + }) { + final surfaceColors = BusyMaxSurfaceColors.of(context); + final controlFill = Color.alphaBlend( + surfaceColors.control, + surfaceColors.popover, + ); + final dividerColor = surfaceColors.divider; + final inputTextStyle = + Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.normal, + height: 1, + ) ?? + const TextStyle(fontWeight: FontWeight.normal, height: 1); + + return FocusTraversalOrder( + order: const NumericFocusOrder(0), + child: Container( + key: ValueKey(('time-input-section', label)), + decoration: BoxDecoration( + color: controlFill, + borderRadius: BorderRadius.circular(BusyMaxRadius.sm), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + BusyMaxHeaderIconButton( + onPressed: onIncrement, + icon: const Icon(Icons.add), + tooltip: label, + iconSize: BusyMaxSizes.popoverActionIcon, + fixedSize: Size(buttonWidth, _timePickerInputControlSize), + foregroundColor: Theme.of(context).colorScheme.onSurface, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(BusyMaxRadius.sm), + ), + ), + ), + Divider(height: 1, thickness: 1, color: dividerColor), + SizedBox( + key: ValueKey(('time-input', label)), + height: _timePickerInputControlSize, + child: MouseRegion( + cursor: SystemMouseCursors.text, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: focusNode.requestFocus, + child: Center( + child: SizedBox( + width: buttonWidth, + child: Padding( + padding: const EdgeInsets.only( + left: _timePickerEditableLeadingInset, + ), + child: EditableText( + controller: controller, + focusNode: focusNode, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + maxLines: 1, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(2), + ], + style: inputTextStyle, + strutStyle: StrutStyle.fromTextStyle( + inputTextStyle, + forceStrutHeight: true, + ), + cursorWidth: _timePickerEditableCursorWidth, + cursorColor: Theme.of(context).colorScheme.primary, + backgroundCursorColor: + surfaceColors.disabledForeground, + selectionColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.28), + onChanged: (_) => _handleTimeInputChanged(), + onSubmitted: (_) => _handleTimeInputChanged(), + ), + ), + ), + ), + ), + ), + ), + Divider(height: 1, thickness: 1, color: dividerColor), + BusyMaxHeaderIconButton( + onPressed: onDecrement, + icon: const Icon(Icons.remove), + tooltip: label, + iconSize: BusyMaxSizes.popoverActionIcon, + fixedSize: Size(buttonWidth, _timePickerInputControlSize), + foregroundColor: Theme.of(context).colorScheme.onSurface, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(BusyMaxRadius.sm), + ), + ), + ), + ], + ), ), - onChanged: onChanged, - onFieldSubmitted: onSubmitted, ); } -} -class _BusyMaxTimeInputFormatter extends TextInputFormatter { - const _BusyMaxTimeInputFormatter(); + void _handleTimeInputChanged() { + if (_syncingText) { + return; + } + final parsed = _currentSelection(); + final bothBlank = + _hourController.text.trim().isEmpty && + _minuteController.text.trim().isEmpty; + if (bothBlank) { + _setInputValidity(widget.allowEmpty); + if (widget.allowEmpty) { + widget.onTimeChanged?.call(null); + } + return; + } + _setInputValidity(parsed != null); + if (parsed != null) { + widget.onTimeChanged?.call(encodeTimeOfDay(parsed)); + } + } - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - final text = _formatTimeEntryInput(newValue.text); - return TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: text.length), - ); + void _changeHour(int delta) { + final next = _normalizeHour(_hourController.text, 0) + delta; + final value = next % 24; + _setComponent(_hourController, value < 0 ? value + 24 : value); } -} -String _formatTimeEntryInput(String value) { - if (value.contains(':')) { - return value.length <= 5 ? value : value.substring(0, 5); + void _changeMinute(int delta) { + final next = _normalizeMinute(_minuteController.text, 0) + delta; + final value = next % 60; + _setComponent(_minuteController, value < 0 ? value + 60 : value); + _handleTimeInputChanged(); } - final digits = value.length <= 4 ? value : value.substring(0, 4); - if (digits.length <= 2) { - return digits; + int _normalizeHour(String input, int fallback) { + final parsed = int.tryParse(input.trim()); + if (parsed == null || parsed < 0 || parsed > 23) { + return fallback; + } + return parsed; } - if (digits.length == 3) { - final twoDigitHour = int.tryParse(digits.substring(0, 2)); - if (twoDigitHour != null && twoDigitHour <= 23) { - return '${digits.substring(0, 2)}:${digits.substring(2)}'; + int _normalizeMinute(String input, int fallback) { + final parsed = int.tryParse(input.trim()); + if (parsed == null || parsed < 0 || parsed > 59) { + return fallback; } - return '${digits.substring(0, 1)}:${digits.substring(1)}'; + return parsed; } - return '${digits.substring(0, 2)}:${digits.substring(2)}'; -} + void _setComponent(TextEditingController controller, int value) { + _syncingText = true; + _ensureTwoDigits(controller, value); + _syncingText = false; + _handleTimeInputChanged(); + } -Widget _withoutInternalDateTimeEntryLabel(BuildContext context, Widget child) { - // The date/time rows already provide the visible label through - // YaruListTile.square, so field labels are hidden here to avoid duplicates. - final theme = Theme.of(context); - const hiddenLabelStyle = TextStyle( - color: Colors.transparent, - fontSize: 0, - height: 0, - ); + void _ensureTwoDigits(TextEditingController controller, int value) { + final valueText = value.toString().padLeft(2, '0'); + if (controller.text == valueText) { + return; + } + controller.value = TextEditingValue( + text: valueText, + selection: TextSelection.collapsed(offset: valueText.length), + ); + } - return Theme( - data: theme.copyWith( - inputDecorationTheme: theme.inputDecorationTheme.copyWith( - floatingLabelBehavior: FloatingLabelBehavior.never, - labelStyle: hiddenLabelStyle, - floatingLabelStyle: hiddenLabelStyle, - ), - ), - child: child, - ); -} + TimeOfDay? _currentSelection() { + final hour = _normalizeHour(_hourController.text, -1); + final minute = _normalizeMinute(_minuteController.text, -1); + if (hour < 0 || minute < 0) { + return null; + } + return TimeOfDay(hour: hour, minute: minute); + } -String formatDesktopDate(BuildContext context, String? date) { - final parsed = parseDateOnly(date); - if (parsed == null) { - return ''; + void _syncVisibleValue() { + final initial = parseTimeOfDay(widget.initialTime); + _syncingText = true; + if (initial == null) { + _hourController.text = ''; + _minuteController.text = ''; + } else { + _hourController.text = initial.hour.toString().padLeft(2, '0'); + _minuteController.text = initial.minute.toString().padLeft(2, '0'); + } + _syncingText = false; } - return DateFormat.yMMMd( - Localizations.localeOf(context).toLanguageTag(), - ).format(parsed); -} -String formatDesktopTime(BuildContext context, String? time) { - final parsed = parseTimeOfDay(time); - if (parsed == null) { - return ''; + void _setInputValidity(bool valid) { + if (_inputValid == valid) { + return; + } + setState(() { + _inputValid = valid; + }); } - return formatMaterialTime(context, parsed); } String formatDesktopDateTime(BuildContext context, String? dateTime) { @@ -757,6 +1416,49 @@ String formatMaterialTime(BuildContext context, TimeOfDay time) { ); } +String formatDesktopDate(BuildContext context, String? date) { + final parsed = parseDateOnly(date); + if (parsed == null) { + return ''; + } + return DateFormat.yMMMd( + Localizations.localeOf(context).toLanguageTag(), + ).format(parsed); +} + +@visibleForTesting +TimeOfDay? parseDesktopTimeInput(BuildContext context, String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) { + return null; + } + if (_providerTimePattern.hasMatch(trimmed)) { + return parseTimeOfDay(trimmed); + } + + final normalized = trimmed + .replaceAll('\u00A0', ' ') + .replaceAll('\u202F', ' ') + .replaceAll(RegExp(r'\s+'), ' '); + final locale = Localizations.localeOf(context).toLanguageTag(); + for (final candidate in {trimmed, normalized}) { + for (final format in [ + DateFormat.Hm(locale), + DateFormat.jm(locale), + DateFormat('H:mm', locale), + DateFormat('h:mm a', locale), + ]) { + try { + final parsed = format.parseStrict(candidate); + return TimeOfDay(hour: parsed.hour, minute: parsed.minute); + } on FormatException { + // Try the other native locale representation. + } + } + } + return null; +} + DateTime? parseDateOnly(String? date) { if (date == null || date.length < 10) { return null; @@ -780,11 +1482,15 @@ DateTime? parseGraphLocalDateTime(String? dateTime) { } TimeOfDay? parseTimeOfDay(String? time) { - if (time == null || time.length < 5) { + if (time == null) { return null; } - final hour = int.tryParse(time.substring(0, 2)); - final minute = int.tryParse(time.substring(3, 5)); + final match = _providerTimePattern.firstMatch(time); + if (match == null) { + return null; + } + final hour = int.tryParse(match.group(1)!); + final minute = int.tryParse(match.group(2)!); if (hour == null || minute == null || hour < 0 || @@ -796,45 +1502,10 @@ TimeOfDay? parseTimeOfDay(String? time) { return TimeOfDay(hour: hour, minute: minute); } -TimeOfDay? parseTimeInput(String? time) { - final trimmed = time?.trim(); - if (trimmed == null || trimmed.isEmpty) { - return null; - } - - if (trimmed.contains(':')) { - final parts = trimmed.split(':'); - if (parts.length != 2 || parts.first.isEmpty || parts.last.isEmpty) { - return null; - } - return _timeOfDayFromParts(parts.first, parts.last); - } +final _providerTimePattern = RegExp(r'^(\d{2}):(\d{2})$'); - if (trimmed.length <= 2) { - return _timeOfDayFromParts(trimmed, '00'); - } - if (trimmed.length == 3) { - return _timeOfDayFromParts(trimmed.substring(0, 1), trimmed.substring(1)); - } - if (trimmed.length == 4) { - return _timeOfDayFromParts(trimmed.substring(0, 2), trimmed.substring(2)); - } - - return null; -} - -TimeOfDay? _timeOfDayFromParts(String hourText, String minuteText) { - final hour = int.tryParse(hourText); - final minute = int.tryParse(minuteText); - if (hour == null || - minute == null || - hour < 0 || - hour > 23 || - minute < 0 || - minute > 59) { - return null; - } - return TimeOfDay(hour: hour, minute: minute); +bool _storedTimeIsValid(String? time, bool allowEmpty) { + return time == null ? allowEmpty : parseTimeOfDay(time) != null; } String encodeDateOnly(DateTime date) { diff --git a/lib/src/features/tasks/presentation/new_task_dialog.dart b/lib/src/features/tasks/presentation/new_task_dialog.dart index a8eca2c..4d3fba6 100644 --- a/lib/src/features/tasks/presentation/new_task_dialog.dart +++ b/lib/src/features/tasks/presentation/new_task_dialog.dart @@ -72,7 +72,7 @@ class NewTaskEditorPanel extends ConsumerStatefulWidget { this.initialListId, this.initialDueUtc, this.categorySuggestionsForAccount, - this.useNativeDatePicker = true, + this.useNativeDatePicker = false, }); final List accounts; @@ -159,6 +159,7 @@ class _NewTaskEditorPanelState extends ConsumerState { showDeleteAction: false, confirmTaskSwitch: false, useNativeDatePicker: widget.useNativeDatePicker, + headerBarService: ref.read(linuxHeaderBarServiceProvider), categorySuggestions: categorySuggestions, canSaveDraft: (draft) => draft.taskListId.isNotEmpty, onDraftChanged: (draft) { diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index 091904b..e4f210d 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -7,8 +7,10 @@ import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; +import '../../../app/busymax_glyphs.dart'; import '../../../google_tasks/api/google_tasks_json.dart'; import '../../../l10n/l10n.dart'; +import '../../../platform/linux_header_bar_service.dart'; import '../../../task_providers/task_provider.dart'; import '../../task_lists/data/task_lists_repository.dart'; import '../data/tasks_repository.dart'; @@ -46,8 +48,9 @@ class TaskDetailsEditor extends StatefulWidget { this.showAdvancedActions = true, this.showDeleteAction = true, this.confirmTaskSwitch = true, - this.useNativeDatePicker = true, + this.useNativeDatePicker = false, this.dialogBarrierColor, + this.headerBarService, this.canSaveDraft, }); @@ -85,6 +88,7 @@ class TaskDetailsEditor extends StatefulWidget { final bool confirmTaskSwitch; final bool useNativeDatePicker; final Color? dialogBarrierColor; + final LinuxHeaderBarService? headerBarService; final bool Function(TaskDetailsDraft draft)? canSaveDraft; @override @@ -94,8 +98,8 @@ class TaskDetailsEditor extends StatefulWidget { class _TaskDetailsEditorState extends State { final _titleController = TextEditingController(); final _notesController = TextEditingController(); - final _categoryController = TextEditingController(); final _shortcutFocusNode = FocusNode(debugLabel: 'Task editor shortcuts'); + final _invalidTimeFields = <_TaskTimeField>{}; TaskDetailsDraft? _draft; TaskDetailsDraft? _cleanDraftBaseline; @@ -118,7 +122,7 @@ class _TaskDetailsEditorState extends State { super.didUpdateWidget(oldWidget); final nextKey = _taskKey(widget.task); final sameKey = _loadedTaskKey == nextKey; - final hasChanges = _hasDraftChanges(_draft); + final hasChanges = _hasEditorChanges(_draft); if (!sameKey) { if (hasChanges && widget.confirmTaskSwitch) { @@ -139,7 +143,6 @@ class _TaskDetailsEditorState extends State { _shortcutFocusNode.dispose(); _titleController.dispose(); _notesController.dispose(); - _categoryController.dispose(); super.dispose(); } @@ -148,14 +151,15 @@ class _TaskDetailsEditorState extends State { final draft = _draft ?? TaskDetailsDraft.fromTask(_editingTask, widget.localTimeZone); final l10n = context.l10n; - final hasChanges = _hasDraftChanges(draft); + final hasChanges = _hasEditorChanges(draft); + final scheduledAllDay = _isScheduledAllDay(draft); final canSave = draft.title.trim().isNotEmpty && hasChanges && !_saving && + _timeFieldsAreValid(draft, scheduledAllDay) && (widget.canSaveDraft?.call(draft) ?? true); final currentList = _listTitle(draft.taskListId); - final scheduledAllDay = _isScheduledAllDay(draft); final listValue = [ currentList, widget.accountLabel, @@ -185,14 +189,8 @@ class _TaskDetailsEditorState extends State { onCancel: _cancel, onSave: _save, ), - const SizedBox(height: BusyMaxSpacing.headerInset), Expanded( - child: BusyMaxClamp( - maxWidth: 640, - margin: EdgeInsets.zero, - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.lg, - ), + child: BusyMaxEditorScrollBody( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -200,10 +198,9 @@ class _TaskDetailsEditorState extends State { filled: true, children: [ YaruListTile.square( - hoverColor: busyMaxEditorRowHoverColor(context), title: TextField( controller: _titleController, - decoration: _plainTaskFieldDecoration( + decoration: busyMaxGroupedTextFieldDecoration( context, labelText: l10n.title, ), @@ -235,7 +232,6 @@ class _TaskDetailsEditorState extends State { DesktopDateValueRow( label: l10n.dueDate, date: draft.dueDate, - emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft(draft.copyWith(dueDate: value)), useNativePicker: widget.useNativeDatePicker, @@ -254,10 +250,18 @@ class _TaskDetailsEditorState extends State { DesktopTimeValueRow( label: l10n.dueTime, time: draft.microsoftDueTime, - emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft( draft.copyWith(microsoftDueTime: value), ), + timeZone: draft.microsoftDueTimeZone, + onTimeZoneChanged: (value) => _updateDraft( + draft.copyWith(microsoftDueTimeZone: value), + ), + onValidityChanged: (valid) => _setTimeFieldValidity( + _TaskTimeField.due, + valid, + ), + useNativePicker: widget.useNativeDatePicker, ), ], ), @@ -275,7 +279,6 @@ class _TaskDetailsEditorState extends State { ), if (widget.capabilities.supportsRecurrence) BusyMaxGroupedList( - title: l10n.repeat, filled: true, children: [_repeatRow(draft)], ), @@ -295,12 +298,11 @@ class _TaskDetailsEditorState extends State { filled: true, children: [ YaruListTile.square( - hoverColor: busyMaxEditorRowHoverColor(context), title: TextField( controller: _notesController, minLines: 3, maxLines: 5, - decoration: _plainTaskFieldDecoration( + decoration: busyMaxGroupedTextFieldDecoration( context, labelText: l10n.notes, alignLabelWithHint: true, @@ -319,7 +321,11 @@ class _TaskDetailsEditorState extends State { children: [ BusyMaxActionRow( title: l10n.createSubtask, - leading: const Icon(Icons.subdirectory_arrow_right), + leading: Icon( + BusyMaxGlyphs.subdirectoryFor( + Directionality.of(context), + ), + ), onTap: _createSubtask, ), BusyMaxActionRow( @@ -401,17 +407,10 @@ class _TaskDetailsEditorState extends State { return BusyMaxComboRow( title: l10n.account, leading: const Icon(YaruIcons.user), + subtitle: secondaryLabelFor?.call(widget.selectedAccountId!), values: widget.accountIds, selected: widget.selectedAccountId!, labelFor: labelFor, - menuItemBuilder: (context, value) => _TaskEditorAccountIdentity( - label: labelFor(value), - secondaryLabel: secondaryLabelFor?.call(value), - ), - selectedBuilder: (context, value) => _TaskEditorAccountIdentity( - label: labelFor(value), - secondaryLabel: secondaryLabelFor?.call(value), - ), onSelected: widget.onAccountSelected!, ); } @@ -421,7 +420,7 @@ class _TaskDetailsEditorState extends State { if (widget.taskLists.isEmpty) { return BusyMaxActionRow( title: l10n.list, - leading: const Icon(Icons.drive_file_move_outline), + leading: const Icon(YaruIcons.task_list), subtitle: listValue.isEmpty ? null : listValue, enabled: false, ); @@ -432,7 +431,7 @@ class _TaskDetailsEditorState extends State { if (!canSelectList) { return BusyMaxActionRow( title: l10n.list, - leading: const Icon(Icons.drive_file_move_outline), + leading: const Icon(YaruIcons.task_list), subtitle: listValue.isEmpty ? null : listValue, enabled: false, tooltip: widget.capabilities.supportsCrossListMove @@ -442,15 +441,11 @@ class _TaskDetailsEditorState extends State { } return BusyMaxComboRow( title: l10n.list, - leading: const Icon(Icons.drive_file_move_outline), + leading: const Icon(YaruIcons.task_list), subtitle: widget.accountLabel, values: [for (final list in widget.taskLists) list.id], selected: draft.taskListId, labelFor: (value) => _listTitle(value) ?? l10n.noneValue, - selectedBuilder: (context, value) => _taskEditorSelectedValue( - context, - _listTitle(value) ?? l10n.noneValue, - ), onSelected: (value) => _updateDraft(draft.copyWith(taskListId: value)), ); } @@ -461,7 +456,6 @@ class _TaskDetailsEditorState extends State { DesktopDateValueRow( label: l10n.startDate, date: draft.microsoftStartDate, - emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft(draft.copyWith(microsoftStartDate: value)), useNativePicker: widget.useNativeDatePicker, @@ -473,9 +467,14 @@ class _TaskDetailsEditorState extends State { DesktopTimeValueRow( label: l10n.startTime, time: draft.microsoftStartTime, - emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft(draft.copyWith(microsoftStartTime: value)), + timeZone: draft.microsoftStartTimeZone, + onTimeZoneChanged: (value) => + _updateDraft(draft.copyWith(microsoftStartTimeZone: value)), + onValidityChanged: (valid) => + _setTimeFieldValidity(_TaskTimeField.start, valid), + useNativePicker: widget.useNativeDatePicker, ), ]; } @@ -496,6 +495,9 @@ class _TaskDetailsEditorState extends State { void _setScheduledAllDay(TaskDetailsDraft draft, bool allDay) { if (allDay) { + _invalidTimeFields + ..remove(_TaskTimeField.due) + ..remove(_TaskTimeField.start); _updateDraft( draft.copyWith( microsoftDueTime: widget.capabilities.supportsDueTime @@ -536,12 +538,10 @@ class _TaskDetailsEditorState extends State { final options = _repeatOptions(context); return BusyMaxComboRow( title: l10n.repeat, - leading: const Icon(Icons.repeat), + leading: const Icon(YaruIcons.repeat), values: options.keys.toList(), selected: type, labelFor: (value) => options[value] ?? l10n.repeatNone, - selectedBuilder: (context, value) => - _taskEditorSelectedValue(context, options[value] ?? l10n.repeatNone), onSelected: (value) => _updateDraft( draft.copyWith(recurrenceJson: _recurrenceJsonFor(value, draft)), ), @@ -557,14 +557,10 @@ class _TaskDetailsEditorState extends State { }; return BusyMaxComboRow( title: l10n.importance, - leading: const Icon(Icons.priority_high_outlined), + leading: const Icon(YaruIcons.task_important), values: labels.keys.toList(), selected: draft.importance, labelFor: (value) => labels[value] ?? l10n.importanceNormal, - selectedBuilder: (context, value) => _taskEditorSelectedValue( - context, - labels[value] ?? l10n.importanceNormal, - ), onSelected: (value) => _updateDraft(draft.copyWith(importance: value)), ); } @@ -577,7 +573,6 @@ class _TaskDetailsEditorState extends State { categories: draft.categories, suggestions: widget.categorySuggestions, adding: _addingCategory, - controller: _categoryController, inputKey: const Key('task-category-input'), onAddPressed: () { setState(() { @@ -586,7 +581,6 @@ class _TaskDetailsEditorState extends State { }, onSubmitted: (value) => _addCategory(draft, value), onCancelAdding: () { - _categoryController.clear(); setState(() { _addingCategory = false; }); @@ -598,10 +592,12 @@ class _TaskDetailsEditorState extends State { void _addCategory(TaskDetailsDraft draft, String value) { final currentDraft = _draft ?? draft; final category = value.trim(); - if (category.isEmpty || currentDraft.categories.contains(category)) { + if (category.isEmpty || + currentDraft.categories.any( + (existing) => existing.toLowerCase() == category.toLowerCase(), + )) { return; } - _categoryController.clear(); setState(() { _addingCategory = false; }); @@ -624,7 +620,9 @@ class _TaskDetailsEditorState extends State { Future _save() async { final draft = _draft; - if (draft == null || _saving) { + if (draft == null || + _saving || + !_timeFieldsAreValid(draft, _isScheduledAllDay(draft))) { return; } final patch = draft.toPatch( @@ -654,15 +652,16 @@ class _TaskDetailsEditorState extends State { Future _cancel() async { final draft = _draft; - final hasChanges = _hasDraftChanges(draft); + final hasChanges = _hasEditorChanges(draft); if (hasChanges) { final discard = await showBusyMaxConfirm( context, title: context.l10n.discardChanges, message: context.l10n.discardChangesConfirmation, - confirmLabel: context.l10n.discard, + confirmLabel: context.l10n.discardChangesAction, destructive: true, barrierColor: widget.dialogBarrierColor, + headerBarService: widget.headerBarService, ); if (!discard || !mounted) { return; @@ -682,9 +681,10 @@ class _TaskDetailsEditorState extends State { context, title: context.l10n.discardChanges, message: context.l10n.discardChangesConfirmation, - confirmLabel: context.l10n.discard, + confirmLabel: context.l10n.discardChangesAction, destructive: true, barrierColor: widget.dialogBarrierColor, + headerBarService: widget.headerBarService, ); if (!mounted) { return; @@ -704,6 +704,7 @@ class _TaskDetailsEditorState extends State { label: context.l10n.title, actionLabel: context.l10n.create, barrierColor: widget.dialogBarrierColor, + headerBarService: widget.headerBarService, ); if (title == null || title.trim().isEmpty) { return; @@ -724,6 +725,7 @@ class _TaskDetailsEditorState extends State { confirmLabel: context.l10n.delete, destructive: true, barrierColor: widget.dialogBarrierColor, + headerBarService: widget.headerBarService, ); if (confirmed) { await widget.onDelete(); @@ -743,6 +745,7 @@ class _TaskDetailsEditorState extends State { TaskDetailsDraft.fromTask(task, widget.localTimeZone); _editingTask = task; _loadedTaskKey = taskKey; + _invalidTimeFields.clear(); _draft = draft; _cleanDraftBaseline = draft; _titleController.text = draft.title; @@ -755,7 +758,43 @@ class _TaskDetailsEditorState extends State { _draft = draft; }); widget.onDraftChanged?.call(draft); - widget.onDirtyChanged?.call(_hasDraftChanges(draft)); + widget.onDirtyChanged?.call(_hasEditorChanges(draft)); + } + + void _setTimeFieldValidity(_TaskTimeField field, bool valid) { + final changed = valid + ? _invalidTimeFields.remove(field) + : _invalidTimeFields.add(field); + if (changed) { + setState(() {}); + widget.onDirtyChanged?.call(_hasEditorChanges(_draft)); + } + } + + bool _hasEditorChanges(TaskDetailsDraft? draft) { + if (draft == null) { + return false; + } + return _hasDraftChanges(draft) || + !_timeFieldsAreValid(draft, _isScheduledAllDay(draft)); + } + + bool _timeFieldsAreValid(TaskDetailsDraft draft, bool scheduledAllDay) { + if (!scheduledAllDay && + widget.capabilities.supportsDueTime && + _invalidTimeFields.contains(_TaskTimeField.due)) { + return false; + } + if (!scheduledAllDay && + widget.capabilities.supportsStartDateTime && + _invalidTimeFields.contains(_TaskTimeField.start)) { + return false; + } + if (draft.microsoftReminderEnabled && + _invalidTimeFields.contains(_TaskTimeField.reminder)) { + return false; + } + return true; } bool _hasDraftChanges(TaskDetailsDraft? draft) { @@ -817,7 +856,6 @@ class _TaskDetailsEditorState extends State { DesktopDateValueRow( label: l10n.reminderDate, date: draft.microsoftReminderDate, - emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft(draft.copyWith(microsoftReminderDate: value)), useNativePicker: widget.useNativeDatePicker, @@ -827,109 +865,30 @@ class _TaskDetailsEditorState extends State { DesktopTimeValueRow( label: l10n.reminderTime, time: draft.microsoftReminderTime, - emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft(draft.copyWith(microsoftReminderTime: value)), + timeZone: draft.microsoftReminderTimeZone, + onTimeZoneChanged: (value) => + _updateDraft(draft.copyWith(microsoftReminderTimeZone: value)), + onValidityChanged: (valid) => + _setTimeFieldValidity(_TaskTimeField.reminder, valid), + useNativePicker: widget.useNativeDatePicker, ), BusyMaxActionRow( title: l10n.removeReminder, leading: const Icon(YaruIcons.window_close), - onTap: () => _updateDraft( - draft.copyWith( - microsoftReminderEnabled: false, - microsoftReminderDate: null, - microsoftReminderTime: null, - ), - ), - ), - ]; - } -} - -InputDecoration _plainTaskFieldDecoration( - BuildContext context, { - required String labelText, - String? errorText, - bool alignLabelWithHint = false, -}) { - final colorScheme = Theme.of(context).colorScheme; - final labelColor = errorText == null - ? colorScheme.onSurfaceVariant - : colorScheme.error; - final labelStyle = Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: labelColor); - return InputDecoration( - filled: false, - fillColor: Colors.transparent, - hoverColor: Colors.transparent, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - labelText: labelText, - labelStyle: labelStyle, - floatingLabelStyle: labelStyle, - floatingLabelBehavior: FloatingLabelBehavior.auto, - alignLabelWithHint: alignLabelWithHint, - errorText: errorText, - ); -} - -Widget _taskEditorSelectedValue(BuildContext context, String value) { - return Align( - alignment: Alignment.centerRight, - child: Text( - value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.end, - ), - ); -} - -class _TaskEditorAccountIdentity extends StatelessWidget { - const _TaskEditorAccountIdentity({required this.label, this.secondaryLabel}); - - final String label; - final String? secondaryLabel; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final secondary = secondaryLabel?.trim(); - final primaryStyle = Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(height: 1.05); - final secondaryStyle = Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - height: 1.05, - ); - return Align( - alignment: Alignment.centerLeft, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: primaryStyle, - ), - if (secondary != null && secondary.isNotEmpty) - Text( - secondary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: secondaryStyle, + onTap: () { + _invalidTimeFields.remove(_TaskTimeField.reminder); + _updateDraft( + draft.copyWith( + microsoftReminderEnabled: false, + microsoftReminderDate: null, + microsoftReminderTime: null, ), - ], + ); + }, ), - ); + ]; } } @@ -943,6 +902,8 @@ TextStyle? _taskEditorProminentActionStyle( ).textTheme.labelLarge?.copyWith(color: color, fontWeight: fontWeight); } +enum _TaskTimeField { due, start, reminder } + class _TaskDetailsHeader extends StatelessWidget { const _TaskDetailsHeader({ required this.title, diff --git a/lib/src/features/tasks/presentation/task_details_pane.dart b/lib/src/features/tasks/presentation/task_details_pane.dart index 6047934..130ed80 100644 --- a/lib/src/features/tasks/presentation/task_details_pane.dart +++ b/lib/src/features/tasks/presentation/task_details_pane.dart @@ -123,15 +123,33 @@ class _TaskDetailsPaneState extends ConsumerState { final repository = _tasksRepository(ref, _effectiveAccountId); final listsRepository = _taskListsRepository(ref, _effectiveAccountId); final localTimeZone = ref.watch(localTimeZoneProvider); - final accounts = ref.watch(accountsStreamProvider).valueOrNull ?? const []; - final account = _accountForId(accounts, _effectiveAccountId); - final provider = - account?.provider ?? _providerForAccountId(_effectiveAccountId); - final capabilities = capabilitiesForProvider(provider); + final accounts = ref.watch(accountsStreamProvider); + final storedAccount = _accountForId( + accounts.valueOrNull ?? const [], + _effectiveAccountId, + ); + final cachedAccount = _lastAccount?.id == _effectiveAccountId + ? _lastAccount + : null; + final account = + storedAccount ?? + ((accounts.isLoading || accounts.hasError || _editorDirty) + ? cachedAccount + : null); final taskStream = _watchTask(repository); final listsStream = _watchTaskLists(listsRepository); final categorySuggestionsStream = _watchCategorySuggestions(repository); + if (account == null) { + if (accounts.hasValue && !accounts.isLoading) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => widget.onClose?.call(), + ); + } + return const SizedBox.shrink(); + } + final capabilities = capabilitiesForProvider(account.provider); + return StreamBuilder( stream: taskStream, builder: (context, taskSnapshot) { @@ -144,7 +162,7 @@ class _TaskDetailsPaneState extends ConsumerState { taskLists: _lastTaskLists, capabilities: _lastCapabilities ?? capabilities, localTimeZone: _lastLocalTimeZone ?? localTimeZone, - account: _lastAccount, + account: cachedAccount ?? account, categorySuggestions: _lastCategorySuggestions, ); } @@ -256,7 +274,7 @@ class _TaskDetailsPaneState extends ConsumerState { required List taskLists, required TaskProviderCapabilities capabilities, required String localTimeZone, - required AccountEntity? account, + required AccountEntity account, required List categorySuggestions, }) { return TaskDetailsEditor( @@ -265,11 +283,7 @@ class _TaskDetailsPaneState extends ConsumerState { capabilities: capabilities, localTimeZone: localTimeZone, categorySuggestions: categorySuggestions, - accountLabel: _accountEditorLabel( - context, - account, - account?.provider ?? _providerForAccountId(_effectiveAccountId), - ), + accountLabel: _accountEditorLabel(context, account), onRefresh: () { unawaited(_refreshTask(repository, task)); }, @@ -290,6 +304,7 @@ class _TaskDetailsPaneState extends ConsumerState { onTaskSwitchCancelled: widget.onTaskSwitchCancelled, onDirtyChanged: _setEditorDirty, dialogBarrierColor: widget.dialogBarrierColor, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); } @@ -381,7 +396,7 @@ class _TaskDetailsPaneState extends ConsumerState { context, title: context.l10n.discardChanges, message: context.l10n.discardChangesConfirmation, - confirmLabel: context.l10n.discard, + confirmLabel: context.l10n.discardChangesAction, destructive: true, ); if (!mounted) { @@ -456,21 +471,7 @@ AccountEntity? _accountForId(List accounts, String accountId) { return null; } -TaskProvider _providerForAccountId(String accountId) { - return accountId.startsWith('microsoft:') - ? TaskProvider.microsoft - : TaskProvider.google; -} - -String _accountEditorLabel( - BuildContext context, - AccountEntity? account, - TaskProvider provider, -) { - if (account == null) { - return _providerEditorLabel(context, provider); - } - +String _accountEditorLabel(BuildContext context, AccountEntity account) { final metadata = _accountMetadata(account); final email = _firstDisplayIdentity([ account.email, diff --git a/lib/src/features/tasks/presentation/task_filters.dart b/lib/src/features/tasks/presentation/task_filters.dart deleted file mode 100644 index ad2be04..0000000 --- a/lib/src/features/tasks/presentation/task_filters.dart +++ /dev/null @@ -1,181 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:yaru/yaru.dart'; - -import '../../../app/app_bootstrap.dart'; -import '../../../app/busymax_design.dart'; -import '../../../l10n/l10n.dart'; -import '../../../task_providers/task_provider.dart'; -import '../data/tasks_repository.dart'; -import 'tasks_selection_state.dart'; - -final taskViewFilterProvider = StateProvider( - (ref) => const TaskViewFilter(), -); - -final visibleTaskFilterCapabilitiesProvider = - Provider((ref) { - final allTasksMode = ref.watch(allTasksModeProvider); - if (!allTasksMode) { - return ref.watch(selectedAccountCapabilitiesProvider); - } - - final accounts = - ref.watch(accountsStreamProvider).valueOrNull ?? const []; - final providers = accounts.map((account) => account.provider).toSet(); - final capabilities = providers.map(capabilitiesForProvider).toList(); - - return TaskProviderCapabilities( - supportsDueDate: capabilities.any((item) => item.supportsDueDate), - supportsDueTime: capabilities.any((item) => item.supportsDueTime), - supportsStartDateTime: capabilities.any( - (item) => item.supportsStartDateTime, - ), - supportsReminderDateTime: capabilities.any( - (item) => item.supportsReminderDateTime, - ), - supportsRecurrence: capabilities.any((item) => item.supportsRecurrence), - supportsImportance: capabilities.any((item) => item.supportsImportance), - supportsCategories: capabilities.any((item) => item.supportsCategories), - supportsTaskHierarchy: capabilities.any( - (item) => item.supportsTaskHierarchy, - ), - supportsCrossListMove: capabilities.any( - (item) => item.supportsCrossListMove, - ), - supportsClearCompleted: capabilities.any( - (item) => item.supportsClearCompleted, - ), - supportsHiddenTasks: capabilities.any( - (item) => item.supportsHiddenTasks, - ), - supportsAssignedTasks: capabilities.any( - (item) => item.supportsAssignedTasks, - ), - supportsListRename: capabilities.any((item) => item.supportsListRename), - supportsListDelete: capabilities.any((item) => item.supportsListDelete), - ); - }); - -class TaskFiltersBar extends ConsumerStatefulWidget { - const TaskFiltersBar({super.key}); - - @override - ConsumerState createState() => _TaskFiltersBarState(); -} - -class _TaskFiltersBarState extends ConsumerState { - final _searchController = TextEditingController(); - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final filter = ref.watch(taskViewFilterProvider); - final notifier = ref.read(taskViewFilterProvider.notifier); - final capabilities = ref.watch(visibleTaskFilterCapabilitiesProvider); - final l10n = context.l10n; - - if (_searchController.text != filter.searchQuery) { - _searchController.value = TextEditingValue( - text: filter.searchQuery, - selection: TextSelection.collapsed(offset: filter.searchQuery.length), - ); - } - - return Row( - children: [ - Expanded( - child: YaruSearchField( - controller: _searchController, - autofocus: false, - hintText: l10n.searchTasks, - clearIconSemanticLabel: l10n.cancel, - onClear: () { - notifier.state = filter.copyWith(searchQuery: ''); - }, - onChanged: (value) { - notifier.state = filter.copyWith(searchQuery: value); - }, - ), - ), - const SizedBox(width: BusyMaxSpacing.sm), - BusyMaxMenuButton<_FilterToggle>( - tooltip: l10n.advancedFilters, - onSelected: (toggle) { - switch (toggle) { - case _FilterToggle.completed: - notifier.state = filter.copyWith( - showCompleted: !filter.showCompleted, - ); - break; - case _FilterToggle.hidden: - notifier.state = filter.copyWith( - showHidden: !filter.showHidden, - ); - break; - case _FilterToggle.assigned: - notifier.state = filter.copyWith( - showAssigned: !filter.showAssigned, - ); - break; - } - }, - entries: [ - BusyMaxMenuEntry( - value: _FilterToggle.completed, - label: l10n.showCompleted, - checked: filter.showCompleted, - ), - BusyMaxMenuEntry( - value: _FilterToggle.hidden, - label: l10n.showHidden, - checked: filter.showHidden, - enabled: capabilities.supportsHiddenTasks, - ), - BusyMaxMenuEntry( - value: _FilterToggle.assigned, - label: l10n.showAssigned, - checked: filter.showAssigned, - enabled: capabilities.supportsAssignedTasks, - ), - ], - ), - ], - ); - } -} - -extension TaskViewFilterCopy on TaskViewFilter { - TaskViewFilter copyWith({ - bool? showCompleted, - bool? showDeleted, - bool? showHidden, - bool? showAssigned, - String? searchQuery, - DateTime? completedMin, - DateTime? completedMax, - DateTime? dueMin, - DateTime? dueMax, - DateTime? updatedMin, - }) { - return TaskViewFilter( - showCompleted: showCompleted ?? this.showCompleted, - showDeleted: showDeleted ?? this.showDeleted, - showHidden: showHidden ?? this.showHidden, - showAssigned: showAssigned ?? this.showAssigned, - searchQuery: searchQuery ?? this.searchQuery, - completedMin: completedMin ?? this.completedMin, - completedMax: completedMax ?? this.completedMax, - dueMin: dueMin ?? this.dueMin, - dueMax: dueMax ?? this.dueMax, - updatedMin: updatedMin ?? this.updatedMin, - ); - } -} - -enum _FilterToggle { completed, hidden, assigned } diff --git a/lib/src/features/tasks/presentation/task_row.dart b/lib/src/features/tasks/presentation/task_row.dart deleted file mode 100644 index 1ac69fb..0000000 --- a/lib/src/features/tasks/presentation/task_row.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaru/yaru.dart'; - -import '../../../app/busymax_design.dart'; - -class BusyMaxTaskRow extends StatelessWidget { - const BusyMaxTaskRow({ - super.key, - required this.title, - required this.completed, - required this.selected, - required this.checkbox, - this.metadata, - this.trailing, - this.depth = 0, - this.onTap, - }); - - final String title; - final bool completed; - final bool selected; - final Widget checkbox; - final String? metadata; - final Widget? trailing; - final int depth; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - final titleStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( - decoration: completed ? TextDecoration.lineThrough : null, - ); - final metadataStyle = Theme.of(context).textTheme.bodySmall?.copyWith( - decoration: completed ? TextDecoration.lineThrough : null, - ); - - final content = YaruSelectableContainer( - selected: selected, - onTap: onTap, - padding: EdgeInsets.zero, - selectionColor: Theme.of(context).listTileTheme.selectedTileColor, - child: YaruListTile.square( - leading: Align(alignment: Alignment.topCenter, child: checkbox), - title: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: titleStyle, - ), - subtitle: metadata == null || metadata!.isEmpty - ? null - : Text( - metadata!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: metadataStyle, - ), - trailing: trailing, - ), - ); - - return Padding( - padding: EdgeInsets.only( - left: BusyMaxSpacing.sm + depth * 22, - right: BusyMaxSpacing.sm, - top: BusyMaxSpacing.xxs, - bottom: BusyMaxSpacing.xxs, - ), - child: ConstrainedBox( - constraints: const BoxConstraints( - minHeight: BusyMaxSizes.taskRowMinHeight, - ), - child: content, - ), - ); - } -} diff --git a/lib/src/features/tasks/presentation/task_tree_view.dart b/lib/src/features/tasks/presentation/task_tree_view.dart deleted file mode 100644 index 2272da3..0000000 --- a/lib/src/features/tasks/presentation/task_tree_view.dart +++ /dev/null @@ -1,463 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:yaru/yaru.dart'; - -import '../../../app/busymax_design.dart'; -import '../../../app/app_bootstrap.dart'; -import '../../../google_tasks/api/google_tasks_json.dart'; -import '../../../l10n/l10n.dart'; -import '../../../task_providers/task_provider.dart'; -import '../data/tasks_repository.dart'; -import 'desktop_date_time_fields.dart'; -import 'task_filters.dart'; -import 'task_row.dart'; -import 'tasks_selection_state.dart'; - -class TaskTreeView extends ConsumerWidget { - const TaskTreeView({ - super.key, - required this.taskListId, - required this.showAllTasks, - this.selectedTaskId, - this.onOpenTask, - this.onCreateTask, - this.onRefreshAll, - }); - - final String? taskListId; - final bool showAllTasks; - final String? selectedTaskId; - final void Function( - TaskEntity task, - String taskListId, - bool stayInAllTasksMode, - )? - onOpenTask; - final VoidCallback? onCreateTask; - final VoidCallback? onRefreshAll; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final selectedTaskListId = taskListId; - final repository = ref.watch(tasksRepositoryProvider); - final filter = ref.watch(taskViewFilterProvider); - final l10n = context.l10n; - if (repository == null) { - return Center(child: Text(l10n.signInToViewTasks)); - } - - if (showAllTasks) { - final accounts = - ref.watch(accountsStreamProvider).valueOrNull ?? const []; - if (accounts.isEmpty) { - return BusyMaxEmptyState( - icon: YaruIcons.user, - title: l10n.signInToViewTasks, - ); - } - return StreamBuilder>( - stream: repository.watchAllTaskTreeGroups([ - for (final account in accounts) account.id, - ], filter), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - final groups = snapshot.data ?? const []; - if (groups.isEmpty) { - if (filter.searchQuery.trim().isNotEmpty) { - return _SearchEmptyState(label: l10n.noTasks); - } - return BusyMaxEmptyState( - icon: YaruIcons.task_list, - title: l10n.noTasksYet, - message: l10n.noTasksYetMessage, - actions: [ - if (onCreateTask != null) - BusyMaxToolbarButton( - icon: YaruIcons.plus, - label: l10n.newTask, - tooltip: l10n.newTask, - primary: true, - onPressed: onCreateTask, - ), - if (onRefreshAll != null) - BusyMaxToolbarButton( - icon: YaruIcons.refresh, - label: l10n.refreshAll, - tooltip: l10n.refreshAll, - onPressed: onRefreshAll, - ), - ], - ); - } - final entries = _sortedAllTaskEntries(groups); - final sections = _bucketAllTaskEntries(context, entries); - return ListView( - padding: const EdgeInsets.symmetric(vertical: BusyMaxSpacing.sm), - children: [ - for (final section in sections) ...[ - _TaskSectionHeader(section.title), - for (final entry in section.entries) - _TaskNodeTile( - node: entry.node, - taskListId: entry.group.taskListId, - selectedTaskId: selectedTaskId, - capabilities: capabilitiesForProvider(entry.group.provider), - stayInAllTasksMode: true, - sourceLabel: entry.sourceLabel, - onOpenTask: onOpenTask, - ), - ], - ], - ); - }, - ); - } - - if (selectedTaskListId == null) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text(l10n.selectOrCreateTaskList), - ), - ); - } - - return StreamBuilder>( - stream: repository.watchTaskTree(selectedTaskListId, filter), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - final nodes = snapshot.data ?? const []; - if (nodes.isEmpty) { - if (filter.searchQuery.trim().isNotEmpty) { - return _SearchEmptyState(label: l10n.noTasks); - } - return BusyMaxEmptyState( - icon: YaruIcons.task_list, - title: l10n.noTasksInList, - actions: [ - if (onCreateTask != null) - BusyMaxToolbarButton( - icon: YaruIcons.plus, - label: l10n.newTask, - tooltip: l10n.newTask, - primary: true, - onPressed: onCreateTask, - ), - ], - ); - } - return ListView( - padding: const EdgeInsets.symmetric(vertical: BusyMaxSpacing.sm), - children: [ - for (final node in nodes) - _TaskNodeTile( - node: node, - taskListId: selectedTaskListId, - selectedTaskId: selectedTaskId, - capabilities: ref.watch(selectedAccountCapabilitiesProvider), - stayInAllTasksMode: false, - onOpenTask: onOpenTask, - ), - ], - ); - }, - ); - } -} - -class _SearchEmptyState extends StatelessWidget { - const _SearchEmptyState({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return Center( - child: Text( - label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ); - } -} - -class _AllTaskNodeEntry { - const _AllTaskNodeEntry({required this.group, required this.node}); - - final TaskTreeGroup group; - final TaskTreeNode node; - - String get sourceLabel => - '${group.provider.displayName} · ${group.accountLabel} · ${group.taskListTitle}'; -} - -class _AllTasksSection { - const _AllTasksSection({required this.title, required this.entries}); - - final String title; - final List<_AllTaskNodeEntry> entries; -} - -List<_AllTaskNodeEntry> _sortedAllTaskEntries(List groups) { - final entries = [ - for (final group in groups) - for (final node in group.nodes) - _AllTaskNodeEntry(group: group, node: node), - ]; - entries.sort(_compareAllTaskEntries); - return entries; -} - -int _compareAllTaskEntries(_AllTaskNodeEntry left, _AllTaskNodeEntry right) { - final dueComparison = _compareNullableDate( - _sortDate(left.node.task), - _sortDate(right.node.task), - ); - if (dueComparison != 0) { - return dueComparison; - } - - return _compareStrings( - [ - left.node.task.title, - left.group.provider.displayName, - left.group.accountLabel, - left.group.taskListTitle, - left.node.task.id, - ], - [ - right.node.task.title, - right.group.provider.displayName, - right.group.accountLabel, - right.group.taskListTitle, - right.node.task.id, - ], - ); -} - -List<_AllTasksSection> _bucketAllTaskEntries( - BuildContext context, - List<_AllTaskNodeEntry> entries, -) { - final buckets = <_TaskDueBucket, List<_AllTaskNodeEntry>>{ - for (final bucket in _TaskDueBucket.values) bucket: [], - }; - for (final entry in entries) { - buckets[_bucketFor(entry.node.task)]!.add(entry); - } - - final l10n = context.l10n; - final titles = { - _TaskDueBucket.overdue: l10n.overdue, - _TaskDueBucket.today: l10n.today, - _TaskDueBucket.tomorrow: l10n.tomorrow, - _TaskDueBucket.upcoming: l10n.upcoming, - _TaskDueBucket.noDate: l10n.noDate, - _TaskDueBucket.completed: l10n.completed, - }; - - return [ - for (final bucket in _TaskDueBucket.values) - if (buckets[bucket]!.isNotEmpty) - _AllTasksSection(title: titles[bucket]!, entries: buckets[bucket]!), - ]; -} - -enum _TaskDueBucket { overdue, today, tomorrow, upcoming, noDate, completed } - -_TaskDueBucket _bucketFor(TaskEntity task) { - if (task.status == 'completed') { - return _TaskDueBucket.completed; - } - final due = _sortDate(task); - if (due == null) { - return _TaskDueBucket.noDate; - } - final today = DateTime.now(); - final dueDate = DateTime(due.year, due.month, due.day); - final todayDate = DateTime(today.year, today.month, today.day); - if (dueDate.isBefore(todayDate)) { - return _TaskDueBucket.overdue; - } - if (isSameDate(dueDate, todayDate)) { - return _TaskDueBucket.today; - } - if (isSameDate(dueDate, todayDate.add(const Duration(days: 1)))) { - return _TaskDueBucket.tomorrow; - } - return _TaskDueBucket.upcoming; -} - -DateTime? _sortDate(TaskEntity task) { - final due = task.dueUtc; - if (due == null || due.isEmpty) { - return null; - } - return DateTime.tryParse(due); -} - -int _compareNullableDate(DateTime? left, DateTime? right) { - if (left == null && right == null) { - return 0; - } - if (left == null) { - return 1; - } - if (right == null) { - return -1; - } - return left.compareTo(right); -} - -int _compareStrings(List left, List right) { - for (var index = 0; index < left.length; index += 1) { - final comparison = left[index].toLowerCase().compareTo( - right[index].toLowerCase(), - ); - if (comparison != 0) { - return comparison; - } - } - return 0; -} - -class _TaskNodeTile extends ConsumerWidget { - const _TaskNodeTile({ - required this.node, - required this.taskListId, - required this.selectedTaskId, - required this.capabilities, - required this.stayInAllTasksMode, - this.onOpenTask, - this.sourceLabel, - this.depth = 0, - }); - - final TaskTreeNode node; - final String taskListId; - final String? selectedTaskId; - final TaskProviderCapabilities capabilities; - final bool stayInAllTasksMode; - final void Function( - TaskEntity task, - String taskListId, - bool stayInAllTasksMode, - )? - onOpenTask; - final String? sourceLabel; - final int depth; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final task = node.task; - final completed = task.status == 'completed'; - final repository = ref.watch( - tasksRepositoryForAccountProvider(task.accountId), - ); - final localTimeZone = ref.watch(localTimeZoneProvider); - final l10n = context.l10n; - - final selected = task.id == selectedTaskId; - final dueDate = formatDesktopDate(context, task.dueUtc); - final source = sourceLabel; - - final metadata = [ - if (dueDate.isNotEmpty) dueDate, - if (source != null) source, - ].join(' · '); - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - BusyMaxTaskRow( - key: ValueKey('task-row-${task.accountId}/$taskListId/${task.id}'), - selected: selected, - depth: depth, - completed: completed, - title: task.title, - metadata: metadata.isEmpty ? null : metadata, - onTap: () { - ref.read(selectedAccountIdProvider.notifier).state = task.accountId; - ref.read(selectedTaskListIdProvider.notifier).state = taskListId; - ref.read(selectedTaskIdProvider.notifier).state = task.id; - ref.read(allTasksModeProvider.notifier).state = stayInAllTasksMode; - onOpenTask?.call(task, taskListId, stayInAllTasksMode); - }, - checkbox: YaruCheckbox( - value: completed, - onChanged: (value) { - final status = value == true ? 'completed' : 'needsAction'; - final fields = { - 'status': status, - 'completed': value == true - ? DateTime.now().toUtc().toIso8601String() - : null, - }; - if (capabilities.supportsDueTime) { - final timeZone = - task.microsoftCompletedTimeZone ?? localTimeZone; - fields['microsoftCompletedTimeZone'] = timeZone; - fields['microsoftCompletedDateTime'] = value == true - ? _graphDateTimeNow(timeZone) - : null; - } - repository.patchTask(taskListId, task.id, TaskPatchInput(fields)); - }, - ), - trailing: task.localDirty || task.pendingMove - ? Tooltip( - message: l10n.pendingSync, - child: const Icon(Icons.sync_problem, size: 16), - ) - : null, - ), - for (final child in node.children) - _TaskNodeTile( - node: child, - taskListId: taskListId, - selectedTaskId: selectedTaskId, - capabilities: capabilities, - stayInAllTasksMode: stayInAllTasksMode, - onOpenTask: onOpenTask, - sourceLabel: sourceLabel, - depth: depth + 1, - ), - ], - ); - } -} - -class _TaskSectionHeader extends StatelessWidget { - const _TaskSectionHeader(this.title); - - final String title; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.lg, - BusyMaxSpacing.md, - BusyMaxSpacing.lg, - BusyMaxSpacing.xs, - ), - child: Text(title, style: busyMaxSectionHeaderStyle(context)), - ); - } -} - -Map _graphDateTimeNow(String timeZone) { - final now = DateTime.now(); - final date = encodeGoogleDateOnly(now); - final time = - '${now.hour.toString().padLeft(2, '0')}:' - '${now.minute.toString().padLeft(2, '0')}:' - '${now.second.toString().padLeft(2, '0')}'; - return {'dateTime': '${date}T$time', 'timeZone': timeZone}; -} diff --git a/lib/src/features/tasks/presentation/tasks_selection_state.dart b/lib/src/features/tasks/presentation/tasks_selection_state.dart deleted file mode 100644 index 5de37b2..0000000 --- a/lib/src/features/tasks/presentation/tasks_selection_state.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final allTasksModeProvider = StateProvider((ref) => true); -final selectedTaskListIdProvider = StateProvider((ref) => null); -final selectedTaskIdProvider = StateProvider((ref) => null); diff --git a/lib/src/features/tasks/presentation/tasks_workspace.dart b/lib/src/features/tasks/presentation/tasks_workspace.dart deleted file mode 100644 index 1a14861..0000000 --- a/lib/src/features/tasks/presentation/tasks_workspace.dart +++ /dev/null @@ -1,505 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter/services.dart'; -import 'package:yaru/yaru.dart'; - -import '../../../app/app_bootstrap.dart'; -import '../../../app/busymax_design.dart'; -import '../../../app/busymax_dialogs.dart'; -import '../../../app/busymax_layout.dart'; -import '../../../l10n/l10n.dart'; -import '../../../platform/linux_header_bar_service.dart'; -import '../../accounts/data/accounts_repository.dart'; -import '../../sync/sync_auth_error.dart'; -import '../../sync/sync_engine.dart'; -import '../../task_lists/data/task_lists_repository.dart'; -import '../../task_lists/presentation/task_lists_sidebar.dart'; -import '../data/tasks_repository.dart'; -import 'task_details_pane.dart'; -import 'task_filters.dart'; -import 'new_task_dialog.dart'; -import 'tasks_selection_state.dart'; -import 'task_tree_view.dart'; - -class TasksWorkspace extends ConsumerStatefulWidget { - const TasksWorkspace({super.key, this.selectedListId, this.selectedTaskId}); - - final String? selectedListId; - final String? selectedTaskId; - - @override - ConsumerState createState() => _TasksWorkspaceState(); -} - -class _TasksWorkspaceState extends ConsumerState { - _TaskDetailsTarget? _detailsTarget; - var _detailsDirty = false; - late final LinuxHeaderBarService _headerBarService; - - @override - void initState() { - super.initState(); - _headerBarService = ref.read(linuxHeaderBarServiceProvider); - _scheduleRouteSelectionSeed(); - } - - @override - void didUpdateWidget(covariant TasksWorkspace oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.selectedListId != widget.selectedListId || - oldWidget.selectedTaskId != widget.selectedTaskId) { - _scheduleRouteSelectionSeed(); - } - } - - @override - void dispose() { - if (_detailsTarget != null) { - unawaited(_headerBarService.setModalBarrierVisible(false)); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final allTasksMode = ref.watch(allTasksModeProvider); - final selectedListId = ref.watch(selectedTaskListIdProvider); - final selectedTaskId = ref.watch(selectedTaskIdProvider); - final showAllTasks = - widget.selectedListId == null && - (selectedListId == null || allTasksMode); - - return Scaffold( - body: LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - final showSidebar = BusyMaxLayoutRules.showSidebar(width); - final taskContent = Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _TasksToolbar( - selectedListId: selectedListId, - showAllTasks: showAllTasks, - ), - const Divider(height: 1), - const Padding( - padding: EdgeInsets.fromLTRB(12, 10, 12, 10), - child: TaskFiltersBar(), - ), - const Divider(height: 1), - Expanded( - child: TaskTreeView( - taskListId: selectedListId, - showAllTasks: showAllTasks, - selectedTaskId: selectedTaskId, - onOpenTask: _openTaskDetails, - onCreateTask: () => _createTaskFromWorkspace( - context, - ref, - selectedListId: selectedListId, - ), - onRefreshAll: showAllTasks - ? () => _refreshAllAccounts(context, ref) - : null, - ), - ), - ], - ); - final workspaceContent = showSidebar - ? YaruPanedView( - pane: const TaskListsSidebar(), - page: taskContent, - layoutDelegate: const YaruResizablePaneDelegate( - initialPaneSize: BusyMaxSizes.sidebarWidth, - minPaneSize: BusyMaxSizes.sidebarWidth, - minPageSize: BusyMaxLayoutRules.taskPageMinWidth, - paneSide: YaruPaneSide.start, - ), - ) - : taskContent; - final workspace = _TaskContentWithDetailsOverlay( - taskContent: workspaceContent, - target: _detailsTarget, - onClose: _closeTaskDetails, - onDirtyChanged: (dirty) { - _detailsDirty = dirty; - }, - onTaskSwitchCancelled: _restoreTaskSelection, - ); - return CallbackShortcuts( - bindings: { - const SingleActivator(LogicalKeyboardKey.escape): () { - unawaited(_requestCloseTaskDetails(context)); - }, - }, - child: Focus(autofocus: true, child: workspace), - ); - }, - ), - ); - } - - void _scheduleRouteSelectionSeed() { - final routeListId = widget.selectedListId; - final routeTaskId = widget.selectedTaskId; - if (routeListId == null && routeTaskId == null) { - return; - } - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { - return; - } - ref.read(selectedTaskListIdProvider.notifier).state = routeListId; - ref.read(selectedTaskIdProvider.notifier).state = routeTaskId; - ref.read(allTasksModeProvider.notifier).state = routeListId == null; - }); - } - - void _restoreTaskSelection(TaskEntity task) { - ref.read(selectedAccountIdProvider.notifier).state = task.accountId; - ref.read(selectedTaskListIdProvider.notifier).state = task.taskListId; - ref.read(selectedTaskIdProvider.notifier).state = task.id; - setState(() { - _detailsTarget = _TaskDetailsTarget( - accountId: task.accountId, - taskListId: task.taskListId, - taskId: task.id, - ); - _detailsDirty = false; - }); - unawaited(_headerBarService.setModalBarrierVisible(true)); - } - - void _openTaskDetails( - TaskEntity task, - String taskListId, - bool stayInAllTasksMode, - ) { - ref.read(selectedAccountIdProvider.notifier).state = task.accountId; - ref.read(selectedTaskListIdProvider.notifier).state = taskListId; - ref.read(selectedTaskIdProvider.notifier).state = task.id; - ref.read(allTasksModeProvider.notifier).state = stayInAllTasksMode; - setState(() { - _detailsTarget = _TaskDetailsTarget( - accountId: task.accountId, - taskListId: taskListId, - taskId: task.id, - ); - _detailsDirty = false; - }); - unawaited(_headerBarService.setModalBarrierVisible(true)); - } - - void _closeTaskDetails() { - if (_detailsTarget == null) { - return; - } - setState(() { - _detailsTarget = null; - _detailsDirty = false; - }); - unawaited(_headerBarService.setModalBarrierVisible(false)); - } - - Future _requestCloseTaskDetails(BuildContext context) async { - if (_detailsTarget == null) { - return; - } - if (_detailsDirty) { - final discard = await showBusyMaxConfirm( - context, - title: context.l10n.discardChanges, - message: context.l10n.discardChangesConfirmation, - confirmLabel: context.l10n.discard, - destructive: true, - ); - if (!discard || !mounted) { - return; - } - } - _closeTaskDetails(); - } -} - -class _TaskDetailsTarget { - const _TaskDetailsTarget({ - required this.accountId, - required this.taskListId, - required this.taskId, - }); - - final String accountId; - final String taskListId; - final String taskId; -} - -class _TaskContentWithDetailsOverlay extends StatelessWidget { - const _TaskContentWithDetailsOverlay({ - required this.taskContent, - required this.target, - required this.onClose, - required this.onDirtyChanged, - required this.onTaskSwitchCancelled, - }); - - final Widget taskContent; - final _TaskDetailsTarget? target; - final VoidCallback onClose; - final ValueChanged onDirtyChanged; - final ValueChanged onTaskSwitchCancelled; - - @override - Widget build(BuildContext context) { - final target = this.target; - if (target == null) { - return taskContent; - } - - return LayoutBuilder( - builder: (context, constraints) { - final maxWidth = (constraints.maxWidth - 24) - .clamp(0, BusyMaxSizes.compactDetailsWidth) - .toDouble(); - final minWidth = maxWidth < 560 ? maxWidth : 560.0; - return Stack( - children: [ - taskContent, - ModalBarrier( - color: busyMaxModalBarrierColor(context), - dismissible: false, - ), - Center( - child: BusyMaxModalEditorSurface( - minWidth: minWidth, - maxWidth: maxWidth, - maxHeight: constraints.maxHeight - 24, - child: TaskDetailsPane( - accountId: target.accountId, - taskListId: target.taskListId, - taskId: target.taskId, - onClose: onClose, - onDirtyChanged: onDirtyChanged, - onTaskSwitchCancelled: onTaskSwitchCancelled, - ), - ), - ), - ], - ); - }, - ); - } -} - -class _TasksToolbar extends ConsumerWidget { - const _TasksToolbar({ - required this.selectedListId, - required this.showAllTasks, - }); - - final String? selectedListId; - final bool showAllTasks; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final tasksRepository = ref.watch(tasksRepositoryProvider); - final listsRepository = ref.watch(taskListsRepositoryProvider); - final syncEngine = ref.watch(syncEngineProvider); - final syncEngineForAccount = ref.watch(syncEngineForAccountFactoryProvider); - final accounts = ref.watch(accountsStreamProvider).valueOrNull ?? const []; - final capabilities = ref.watch(selectedAccountCapabilitiesProvider); - final hasList = selectedListId != null && !showAllTasks; - final hasAccounts = accounts.isNotEmpty; - final refreshLabel = showAllTasks - ? context.l10n.refreshAll - : context.l10n.refreshList; - final l10n = context.l10n; - - return LayoutBuilder( - builder: (context, constraints) { - final compactActions = constraints.maxWidth < 720; - return _ToolbarTitle( - selectedListId: selectedListId, - showAllTasks: showAllTasks, - repository: listsRepository, - actions: [ - BusyMaxToolbarButton( - compact: compactActions, - primary: true, - tooltip: l10n.newTask, - label: l10n.newTask, - icon: YaruIcons.plus, - onPressed: showAllTasks - ? hasAccounts - ? () => _createTaskFromWorkspace( - context, - ref, - selectedListId: selectedListId, - ) - : null - : !hasList || tasksRepository == null - ? null - : () => _createTaskFromWorkspace( - context, - ref, - selectedListId: selectedListId, - ), - ), - if (!showAllTasks && capabilities.supportsClearCompleted) - BusyMaxToolbarButton( - compact: compactActions, - tooltip: l10n.clearCompleted, - label: l10n.clearCompleted, - icon: Icons.cleaning_services, - onPressed: !hasList || tasksRepository == null - ? null - : () => tasksRepository.clearCompleted(selectedListId!), - ), - BusyMaxToolbarButton( - compact: compactActions, - tooltip: refreshLabel, - label: refreshLabel, - icon: YaruIcons.refresh, - onPressed: showAllTasks - ? hasAccounts - ? () => _refreshAll( - context, - accounts, - syncEngineForAccount, - ) - : null - : !hasList || syncEngine == null - ? null - : () => _refreshList(context, syncEngine), - ), - ], - ); - }, - ); - } -} - -class _ToolbarTitle extends StatelessWidget { - const _ToolbarTitle({ - required this.selectedListId, - required this.showAllTasks, - required this.repository, - required this.actions, - }); - - final String? selectedListId; - final bool showAllTasks; - final TaskListsRepository? repository; - final List actions; - - @override - Widget build(BuildContext context) { - if (showAllTasks) { - return BusyMaxToolbar(title: context.l10n.allTasks, actions: actions); - } - - final listId = selectedListId; - final listsRepository = repository; - if (listId == null || listsRepository == null) { - return BusyMaxToolbar(title: context.l10n.tasks, actions: actions); - } - - return StreamBuilder( - stream: listsRepository.watchTaskList(listId), - builder: (context, snapshot) { - final title = snapshot.data?.title ?? listId; - return BusyMaxToolbar( - title: context.l10n.tasksInList(title), - actions: actions, - ); - }, - ); - } -} - -Future _createTaskFromWorkspace( - BuildContext context, - WidgetRef ref, { - required String? selectedListId, -}) async { - final accounts = ref.read(accountsStreamProvider).valueOrNull ?? const []; - if (accounts.isEmpty) { - return; - } - final draft = await showBusyMaxNewTaskDialog( - context, - ref: ref, - accounts: accounts, - initialAccountId: ref.read(selectedAccountProvider)?.id, - initialListId: selectedListId, - headerBarService: ref.read(linuxHeaderBarServiceProvider), - ); - if (draft == null) { - return; - } - await ref - .read(tasksRepositoryForAccountProvider(draft.accountId)) - .createTask(draft.taskListId, draft.input); -} - -Future _refreshList(BuildContext context, SyncEngine syncEngine) async { - try { - await syncEngine.incrementalSync(); - if (!context.mounted) { - return; - } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.listRefreshed))); - } on Object catch (error) { - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.refreshFailed(syncFailureMessage(error))), - ), - ); - } -} - -Future _refreshAllAccounts(BuildContext context, WidgetRef ref) async { - final accounts = ref.read(accountsStreamProvider).valueOrNull ?? const []; - if (accounts.isEmpty) { - return; - } - await _refreshAll( - context, - accounts, - ref.read(syncEngineForAccountFactoryProvider), - ); -} - -Future _refreshAll( - BuildContext context, - List accounts, - SyncEngineForAccountFactory syncEngineForAccount, -) async { - try { - for (final account in accounts) { - await syncEngineForAccount(account.id).incrementalSync(); - } - if (!context.mounted) { - return; - } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.allTasksRefreshed))); - } on Object catch (error) { - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.refreshFailed(syncFailureMessage(error))), - ), - ); - } -} diff --git a/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart b/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart new file mode 100644 index 0000000..7855128 --- /dev/null +++ b/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart @@ -0,0 +1,255 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../app/busymax_dialogs.dart'; +import '../../../app/busymax_design.dart'; +import '../../../app/busymax_surface_colors.dart'; +import '../../../core/time/time_zone_catalog.dart'; +import '../../../l10n/l10n.dart'; +import '../../../platform/native_dialog_service.dart'; + +const _timeZoneDialogContentHeight = 420.0; + +Future showBusyMaxTimeZoneSelectionDialog( + BuildContext context, { + required String selectedTimeZone, +}) async { + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.linux) { + await BusyMaxTimeZoneCatalog.prepareLocationSearch(); + if (!context.mounted) { + return null; + } + final l10n = context.l10n; + final theme = Theme.of(context); + final surfaceColors = BusyMaxSurfaceColors.of(context); + final nativeResult = await const NativeDialogService().selectTimeZone( + title: l10n.selectTimeZone, + searchPlaceholder: l10n.searchLocations, + noResultsLabel: l10n.noLocationsFound, + selectedTimeZone: selectedTimeZone, + options: [ + for (final result in BusyMaxTimeZoneCatalog.preparedLocationOptions) + NativeTimeZoneOption( + id: result.location.id, + region: result.location.region, + name: result.name, + englishName: result.englishName ?? result.name, + title: result.title, + subtitle: result.subtitle, + searchText: result.searchText, + ), + ], + groupedListStyle: NativeGroupedListStyle( + surfaceColor: busyMaxGroupedSurfaceColor( + context, + parentRole: BusyMaxSurfaceRole.dialog, + ), + dividerColor: surfaceColors.cardShade, + sectionHeaderColor: theme.colorScheme.onSurfaceVariant, + primaryTextColor: theme.colorScheme.onSurface, + secondaryTextColor: surfaceColors.mutedForeground, + hoverColor: busyMaxRowHoverColor(context), + shadowColor: + CardTheme.of(context).shadowColor ?? theme.colorScheme.shadow, + outlineColor: theme.colorScheme.outline, + highContrast: MediaQuery.highContrastOf(context), + radius: BusyMaxRadius.md.round(), + sectionTopSpacing: BusyMaxSpacing.lg.round(), + sectionHorizontalPadding: BusyMaxSpacing.xs.round(), + titleBottomSpacing: BusyMaxSpacing.sm.round(), + ), + ); + if (nativeResult.available) { + return nativeResult.selectedTimeZone; + } + if (!context.mounted) { + return null; + } + } + + return showBusyMaxModalDialog( + context, + builder: (dialogContext) => + BusyMaxTimeZoneSelectionDialog(selectedTimeZone: selectedTimeZone), + ); +} + +class BusyMaxTimeZoneSelectionDialog extends StatefulWidget { + const BusyMaxTimeZoneSelectionDialog({ + super.key, + required this.selectedTimeZone, + }); + + final String selectedTimeZone; + + @override + State createState() => + _BusyMaxTimeZoneSelectionDialogState(); +} + +class _BusyMaxTimeZoneSelectionDialogState + extends State { + final _searchController = TextEditingController(); + final _resultsController = ScrollController(); + var _query = ''; + var _searchGeneration = 0; + var _isSearching = false; + List _results = const []; + + @override + void initState() { + super.initState(); + unawaited(BusyMaxTimeZoneCatalog.prepareLocationSearch()); + } + + @override + void dispose() { + _searchController.dispose(); + _resultsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final sections = >{}; + for (final result in _results) { + sections.putIfAbsent(result.location.region, () => []).add(result); + } + + return BusyMaxDialogShell( + title: l10n.selectTimeZone, + maxWidth: 520, + header: BusyMaxDialogTitleBar( + title: Text( + l10n.selectTimeZone, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ), + ), + children: [ + SizedBox( + key: const ValueKey('timezone-dialog-content'), + height: _timeZoneDialogContentHeight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BusyMaxSearchField( + controller: _searchController, + hintText: l10n.searchLocations, + autofocus: true, + onChanged: _search, + onClear: () => _search(''), + ), + const SizedBox(height: BusyMaxSpacing.md), + Expanded( + child: _isSearching + ? const Center(child: YaruCircularProgressIndicator()) + : _results.isEmpty + ? _query.trim().isEmpty + ? const SizedBox.shrink() + : Center( + child: Text( + l10n.noLocationsFound, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ) + : Scrollbar( + controller: _resultsController, + thumbVisibility: true, + child: ListView( + key: const ValueKey('timezone-results-list'), + controller: _resultsController, + primary: false, + padding: EdgeInsets.zero, + children: [ + for (final section in sections.entries) + BusyMaxGroupedList( + title: section.key, + filled: true, + children: [ + for (final result in section.value) + BusyMaxActionRow( + title: result.title, + subtitle: result.subtitle, + leading: const Icon( + Icons.public, + size: BusyMaxSizes.iconSm, + ), + trailing: + result.location.id == + widget.selectedTimeZone + ? const Icon( + YaruIcons.checkmark, + size: BusyMaxSizes.iconSm, + ) + : null, + onTap: () => Navigator.of( + context, + ).pop(result.location.id), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], + ); + } + + void _search(String query) { + final generation = ++_searchGeneration; + final hasQuery = query.trim().isNotEmpty; + if (!hasQuery || BusyMaxTimeZoneCatalog.isLocationSearchReady) { + setState(() { + _query = query; + _isSearching = false; + _results = hasQuery + ? BusyMaxTimeZoneCatalog.searchPreparedLocations(query) + : const []; + }); + _resetResultsScroll(); + return; + } + + setState(() { + _query = query; + _isSearching = true; + }); + unawaited(_searchAfterLoad(query, generation)); + } + + Future _searchAfterLoad(String query, int generation) async { + final results = await BusyMaxTimeZoneCatalog.searchLocations(query); + if (!mounted || generation != _searchGeneration) { + return; + } + setState(() { + _results = results; + _isSearching = false; + }); + _resetResultsScroll(); + } + + void _resetResultsScroll() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _resultsController.hasClients) { + _resultsController.jumpTo(0); + } + }); + } +} diff --git a/lib/src/google_tasks/oauth/oauth_service.dart b/lib/src/google_tasks/oauth/oauth_service.dart index ea67bc4..b356707 100644 --- a/lib/src/google_tasks/oauth/oauth_service.dart +++ b/lib/src/google_tasks/oauth/oauth_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:crypto/crypto.dart'; @@ -22,14 +23,10 @@ abstract interface class OAuthGateway { Future refreshActiveToken(); - Future signOut(); - - Future signOutAccount(String accountId); - - Future revokeAndSignOut(); - Future revokeAndSignOutAccount(String accountId); + Future revokeAuthorization(String accountId); + Future clearLocalSession({String? accountId}); Future cancelSignIn(); @@ -42,17 +39,20 @@ class OAuthService implements OAuthGateway { required OAuthTokenStore tokenStore, required OAuthLoopbackFlow loopbackFlow, DateTime Function()? nowUtc, + Duration authorizationRevocationTimeout = const Duration(seconds: 10), }) : _config = config, _httpClient = httpClient, _tokenStore = tokenStore, _loopbackFlow = loopbackFlow, - _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()); + _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()), + _authorizationRevocationTimeout = authorizationRevocationTimeout; final BuildConfig _config; final http.Client _httpClient; final OAuthTokenStore _tokenStore; final OAuthLoopbackFlow _loopbackFlow; final DateTime Function() _nowUtc; + final Duration _authorizationRevocationTimeout; final RedactingLogger _logger = RedactingLogger(Logger('OAuthService')); @override @@ -148,18 +148,6 @@ class OAuthService implements OAuthGateway { @override Future cancelSignIn() => _loopbackFlow.cancel(); - @override - Future signOut() => _tokenStore.clearActiveAccount(); - - @override - Future signOutAccount(String accountId) async { - final active = await _tokenStore.readActiveAccountId(); - await _tokenStore.clearTokenSet(accountId); - if (active == accountId) { - await _tokenStore.clearActiveAccount(); - } - } - Future exchangeAuthorizationCode({ required String code, required String codeVerifier, @@ -292,34 +280,47 @@ class OAuthService implements OAuthGateway { } @override - Future revokeAndSignOut() async { - final accountId = await _tokenStore.readActiveAccountId(); - if (accountId == null) { - return; + Future revokeAndSignOutAccount(String accountId) async { + try { + await revokeAuthorization(accountId); + } finally { + await clearLocalSession(accountId: accountId); } - - await revokeAndSignOutAccount(accountId); } @override - Future revokeAndSignOutAccount(String accountId) async { - final active = await _tokenStore.readActiveAccountId(); + Future revokeAuthorization(String accountId) async { final tokenSet = await _tokenStore.readTokenSet(accountId); final token = tokenSet?.refreshToken ?? tokenSet?.accessToken; + if (token == null || token.isEmpty) { + throw const OAuthException( + 'OAuthRevocationUnavailable', + 'No local Google authorization is available to revoke.', + ); + } + + late final http.Response response; try { - if (token != null && token.isNotEmpty) { - await _httpClient.post( - Uri.parse( - _config.oauthRevocationEndpoint, - ).replace(queryParameters: {'token': token}), - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, - ); - } - } finally { - await _tokenStore.clearTokenSet(accountId); - if (active == accountId) { - await _tokenStore.clearActiveAccount(); - } + response = await _httpClient + .post( + Uri.parse(_config.oauthRevocationEndpoint), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: {'token': token}, + ) + .timeout(_authorizationRevocationTimeout); + } on TimeoutException { + throw const OAuthException( + 'OAuthRevocationTimedOut', + 'Google authorization revocation timed out.', + ); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw OAuthException( + 'OAuthRevocationFailed', + 'Google authorization revocation failed ' + '(HTTP ${response.statusCode}).', + ); } } diff --git a/lib/src/l10n/app_locale.dart b/lib/src/l10n/app_locale.dart new file mode 100644 index 0000000..7574781 --- /dev/null +++ b/lib/src/l10n/app_locale.dart @@ -0,0 +1,178 @@ +import 'package:flutter/widgets.dart'; + +/// A locale that can be selected explicitly in BusyMax. +/// +/// Language names are endonyms on purpose: users should be able to find their +/// language even when the rest of the application is currently unreadable. +class BusyMaxLocaleOption { + const BusyMaxLocaleOption({required this.locale, required this.endonym}); + + final Locale locale; + final String endonym; + + String get tag => locale.toLanguageTag(); +} + +const busyMaxLocaleOptions = [ + BusyMaxLocaleOption(locale: Locale('ar'), endonym: 'العربية'), + BusyMaxLocaleOption(locale: Locale('de'), endonym: 'Deutsch'), + BusyMaxLocaleOption(locale: Locale('en'), endonym: 'English'), + BusyMaxLocaleOption(locale: Locale('es'), endonym: 'Español'), + BusyMaxLocaleOption(locale: Locale('et'), endonym: 'Eesti'), + BusyMaxLocaleOption(locale: Locale('fa'), endonym: 'فارسی'), + BusyMaxLocaleOption(locale: Locale('fi'), endonym: 'Suomi'), + BusyMaxLocaleOption(locale: Locale('fr'), endonym: 'Français'), + BusyMaxLocaleOption(locale: Locale('hi'), endonym: 'हिन्दी'), + BusyMaxLocaleOption(locale: Locale('it'), endonym: 'Italiano'), + BusyMaxLocaleOption(locale: Locale('ja'), endonym: '日本語'), + BusyMaxLocaleOption(locale: Locale('ko'), endonym: '한국어'), + BusyMaxLocaleOption(locale: Locale('pt'), endonym: 'Português'), + BusyMaxLocaleOption(locale: Locale('ru'), endonym: 'Русский'), + BusyMaxLocaleOption(locale: Locale('vi'), endonym: 'Tiếng Việt'), + BusyMaxLocaleOption( + locale: Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'), + endonym: '简体中文', + ), + BusyMaxLocaleOption( + locale: Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), + endonym: '繁體中文', + ), +]; + +const _traditionalChineseRegions = {'HK', 'MO', 'TW'}; +const _simplifiedChineseRegions = {'CN', 'MY', 'SG'}; + +Locale? busyMaxLocaleFromTag(String? tag) { + final normalized = normalizeBusyMaxLocaleTag(tag); + if (normalized == null) { + return null; + } + for (final option in busyMaxLocaleOptions) { + if (option.tag == normalized) { + return option.locale; + } + } + return null; +} + +String? normalizeBusyMaxLocaleTag(String? tag) { + final trimmed = tag?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + + final parsed = _parseLocaleTag(trimmed); + if (parsed == null) { + return null; + } + final normalized = _normalizeChineseLocale(parsed); + for (final option in busyMaxLocaleOptions) { + if (option.locale == normalized) { + return option.tag; + } + } + + // Stored regional variants such as en-CA use the app's base translation. + for (final option in busyMaxLocaleOptions) { + if (option.locale.languageCode == normalized.languageCode && + option.locale.scriptCode == null && + option.locale.countryCode == null) { + return option.tag; + } + } + return null; +} + +String busyMaxLocaleEndonym(String tag) { + final normalized = normalizeBusyMaxLocaleTag(tag); + for (final option in busyMaxLocaleOptions) { + if (option.tag == normalized) { + return option.endonym; + } + } + return tag; +} + +/// Resolves the complete platform language preference list. +/// +/// Flutter's generated locale list is alphabetical, which would otherwise +/// make Arabic the fallback for an unsupported system language. Reordering the +/// supported list keeps English as the deliberate fallback while retaining +/// Flutter's exact/script/region/language preference algorithm. +Locale resolveBusyMaxLocales( + List? requestedLocales, + Iterable supportedLocales, +) { + final supported = supportedLocales.toList(growable: false); + if (supported.isEmpty) { + return const Locale('en'); + } + + final english = supported.cast().firstWhere( + (locale) => locale?.languageCode == 'en', + orElse: () => null, + ); + final orderedSupported = [ + if (english != null) english, + for (final locale in supported) + if (locale != english) locale, + ]; + final normalizedRequested = requestedLocales + ?.map(_normalizeChineseLocale) + .toList(growable: false); + return basicLocaleListResolution(normalizedRequested, orderedSupported); +} + +Locale resolveBusyMaxLocale( + Locale? requestedLocale, + Iterable supportedLocales, +) { + return resolveBusyMaxLocales( + requestedLocale == null ? null : [requestedLocale], + supportedLocales, + ); +} + +Locale _normalizeChineseLocale(Locale locale) { + if (locale.languageCode.toLowerCase() != 'zh' || locale.scriptCode != null) { + return locale; + } + final region = locale.countryCode?.toUpperCase(); + if (_traditionalChineseRegions.contains(region)) { + return Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'); + } + if (_simplifiedChineseRegions.contains(region)) { + return Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'); + } + return locale; +} + +Locale? _parseLocaleTag(String tag) { + final parts = tag.replaceAll('_', '-').split('-'); + if (parts.isEmpty || !RegExp(r'^[A-Za-z]{2,3}$').hasMatch(parts.first)) { + return null; + } + + final languageCode = parts.first.toLowerCase(); + String? scriptCode; + String? countryCode; + for (final part in parts.skip(1)) { + if (scriptCode == null && RegExp(r'^[A-Za-z]{4}$').hasMatch(part)) { + scriptCode = + '${part.substring(0, 1).toUpperCase()}' + '${part.substring(1).toLowerCase()}'; + continue; + } + if (countryCode == null && + RegExp(r'^(?:[A-Za-z]{2}|[0-9]{3})$').hasMatch(part)) { + countryCode = part.toUpperCase(); + continue; + } + return null; + } + return Locale.fromSubtags( + languageCode: languageCode, + scriptCode: scriptCode, + countryCode: countryCode, + ); +} diff --git a/lib/src/l10n/locale_resolution.dart b/lib/src/l10n/locale_resolution.dart new file mode 100644 index 0000000..8daf0c7 --- /dev/null +++ b/lib/src/l10n/locale_resolution.dart @@ -0,0 +1 @@ +export 'app_locale.dart' show resolveBusyMaxLocale, resolveBusyMaxLocales; diff --git a/lib/src/l10n/localized_formatters.dart b/lib/src/l10n/localized_formatters.dart new file mode 100644 index 0000000..cc40a36 --- /dev/null +++ b/lib/src/l10n/localized_formatters.dart @@ -0,0 +1,37 @@ +import 'package:intl/intl.dart'; + +import '../schedule/schedule_range.dart'; + +// `intl` exposes CLDR's middle-of-sentence month names, but does not apply +// CLDR contextTransforms. These supported locales request titlecase-firstword +// for wide standalone month names used as UI headings. +const _titleCasedStandaloneMonthLanguages = {'es', 'fr', 'it', 'ru'}; + +/// Formats a month used by itself as a calendar heading. +/// +/// `LLLL` selects the standalone grammatical form. The casing transform is +/// separate because standalone grammar and standalone UI capitalization are +/// distinct concepts in CLDR. +String localizedMonthHeading(String locale, DateTime month) { + final label = DateFormat.LLLL(locale).format(month); + final language = Intl.canonicalizedLocale(locale).split('_').first; + if (label.isEmpty || + !_titleCasedStandaloneMonthLanguages.contains(language)) { + return label; + } + return '${label[0].toUpperCase()}${label.substring(1)}'; +} + +/// Formats a closed-open schedule range without assuming a month/day/year +/// ordering. Each endpoint is formatted as a complete localized date so the +/// result remains unambiguous in every supported locale. +String localizedScheduleRangeLabel(String locale, ScheduleRange range) { + final end = range.end.subtract(const Duration(days: 1)); + final dateFormat = DateFormat.yMMMd(locale); + return localizedRangeLabel( + dateFormat.format(range.start), + dateFormat.format(end), + ); +} + +String localizedRangeLabel(String start, String end) => '$start – $end'; diff --git a/lib/src/platform/busymax_tray_service.dart b/lib/src/platform/busymax_tray_service.dart index 37e53d3..6bc4df4 100644 --- a/lib/src/platform/busymax_tray_service.dart +++ b/lib/src/platform/busymax_tray_service.dart @@ -43,15 +43,12 @@ class BusyMaxTrayService { required LinuxWindowService windowService, required BusyMaxTrayLabels labels, required Future Function() onOpenAgenda, - Future Function()? onBeforeQuit, }) : _windowService = windowService, _labels = labels, - _onOpenAgenda = onOpenAgenda, - _onBeforeQuit = onBeforeQuit; + _onOpenAgenda = onOpenAgenda; final LinuxWindowService _windowService; final Future Function() _onOpenAgenda; - final Future Function()? _onBeforeQuit; BusyMaxTrayLabels _labels; final RedactingLogger _logger = RedactingLogger(Logger('BusyMaxTrayService')); @@ -200,7 +197,6 @@ class BusyMaxTrayService { Future _quit() async { _logger.fine('Tray quit requested: action=quitApp'); - await _onBeforeQuit?.call(); unawaited(_stopAfterQuitRequest()); await _windowService.quitApp(); } diff --git a/lib/src/platform/busymax_window_args.dart b/lib/src/platform/busymax_window_args.dart deleted file mode 100644 index a594408..0000000 --- a/lib/src/platform/busymax_window_args.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'dart:convert'; - -enum BusyMaxWindowKind { main, compactAgenda } - -class BusyMaxWindowArgs { - const BusyMaxWindowArgs({ - required this.kind, - required this.version, - this.requestedPositionX, - this.requestedPositionY, - }); - - final BusyMaxWindowKind kind; - final int version; - final double? requestedPositionX; - final double? requestedPositionY; - - static const currentVersion = 1; - - static const main = BusyMaxWindowArgs( - kind: BusyMaxWindowKind.main, - version: currentVersion, - ); - - static const compactAgenda = BusyMaxWindowArgs( - kind: BusyMaxWindowKind.compactAgenda, - version: currentVersion, - ); - - static BusyMaxWindowArgs compactAgendaAt({ - required double x, - required double y, - }) { - return BusyMaxWindowArgs( - kind: BusyMaxWindowKind.compactAgenda, - version: currentVersion, - requestedPositionX: x, - requestedPositionY: y, - ); - } - - String encode() { - return jsonEncode({ - 'app': 'BusyMax', - 'version': version, - 'kind': kind.name, - if (requestedPositionX != null && requestedPositionY != null) - 'position': {'x': requestedPositionX, 'y': requestedPositionY}, - }); - } - - static BusyMaxWindowArgs parse(String raw) { - if (raw.trim().isEmpty) { - return main; - } - - try { - final decoded = jsonDecode(raw); - if (decoded is! Map) { - return main; - } - if (decoded['app'] != 'BusyMax') { - return main; - } - final kind = decoded['kind']?.toString(); - if (kind == BusyMaxWindowKind.compactAgenda.name) { - final position = decoded['position']; - final x = position is Map ? position['x'] : null; - final y = position is Map ? position['y'] : null; - final parsedX = x is num ? x.toDouble() : null; - final parsedY = y is num ? y.toDouble() : null; - if (parsedX != null && - parsedY != null && - parsedX.isFinite && - parsedY.isFinite) { - return compactAgendaAt(x: parsedX, y: parsedY); - } - return compactAgenda; - } - return main; - } on Object { - return main; - } - } -} diff --git a/lib/src/platform/compact_agenda_window_service.dart b/lib/src/platform/compact_agenda_window_service.dart deleted file mode 100644 index 250c3f0..0000000 --- a/lib/src/platform/compact_agenda_window_service.dart +++ /dev/null @@ -1,286 +0,0 @@ -import 'dart:io'; - -import 'package:desktop_multi_window/desktop_multi_window.dart'; -import 'package:flutter/widgets.dart'; -import 'package:logging/logging.dart'; -import 'package:screen_retriever/screen_retriever.dart'; - -import 'busymax_window_args.dart'; - -const _compactAgendaWindowWidth = 420.0; -const _compactAgendaWindowHeight = 680.0; -const _compactAgendaWindowShadowMargin = 32.0; -const _compactAgendaPanelScreenGap = 6.0; -const _compactAgendaWindowFrameWidth = - _compactAgendaWindowWidth + _compactAgendaWindowShadowMargin * 2; -const _compactAgendaWindowFrameHeight = - _compactAgendaWindowHeight + _compactAgendaWindowShadowMargin * 2; - -@visibleForTesting -Offset compactAgendaTopRightWorkAreaPositionForTest(Rect workarea) { - return _topRightWorkAreaPlacement(workarea).finalPosition; -} - -class CompactAgendaWindowService { - const CompactAgendaWindowService(); - - static final Logger _logger = Logger('CompactAgendaWindowService'); - - Future toggle() async { - final position = await _preferredCompactAgendaPosition(); - _logPlacementRequest('toggle', position); - final controller = await _findCompactAgendaWindow(); - if (controller == null) { - await _createCompactAgendaWindow(position); - return; - } - await _invokeCompactMethod( - controller, - 'busymax.compactAgenda.toggle', - position, - ); - } - - Future show() async { - final position = await _preferredCompactAgendaPosition(); - _logPlacementRequest('show', position); - final controller = await _findCompactAgendaWindow(); - if (controller == null) { - await _createCompactAgendaWindow(position); - return; - } - await _invokeCompactMethod( - controller, - 'busymax.compactAgenda.show', - position, - ); - } - - Future hide() async { - final controller = await _findCompactAgendaWindow(); - if (controller == null) { - return; - } - await _invokeOrIgnore(controller, 'busymax.compactAgenda.hide'); - } - - Future closeIfOpen() async { - final controller = await _findCompactAgendaWindow(); - if (controller == null) { - return; - } - await _invokeOrIgnore(controller, 'busymax.compactAgenda.destroy'); - } - - Future _findCompactAgendaWindow() async { - final List controllers; - try { - controllers = await WindowController.getAll(); - } on Object { - return null; - } - for (final controller in controllers) { - final args = BusyMaxWindowArgs.parse(controller.arguments); - if (args.kind == BusyMaxWindowKind.compactAgenda) { - return controller; - } - } - return null; - } - - Future _createCompactAgendaWindow(Offset position) async { - _logger.fine( - 'Compact agenda create requested: final_x=${position.dx.round()} ' - 'final_y=${position.dy.round()} ${_sessionDescription()}', - ); - await WindowController.create( - WindowConfiguration( - arguments: BusyMaxWindowArgs.compactAgendaAt( - x: position.dx, - y: position.dy, - ).encode(), - hiddenAtLaunch: true, - ), - ); - } - - Future _invokeCompactMethod( - WindowController controller, - String method, - Offset position, - ) async { - const attempts = 12; - const retryDelay = Duration(milliseconds: 80); - for (var attempt = 0; attempt < attempts; attempt += 1) { - try { - await controller.invokeMethod( - method, - _positionMethodArguments(position), - ); - _logger.fine( - 'Compact agenda native position invocation succeeded: method=$method ' - 'final_x=${position.dx.round()} final_y=${position.dy.round()} ' - '${_sessionDescription()}', - ); - return; - } on Object catch (error) { - if (attempt == attempts - 1) { - _logger.warning( - 'Compact agenda native position invocation failed: method=$method ' - 'final_x=${position.dx.round()} final_y=${position.dy.round()} ' - 'error=$error ${_sessionDescription()}', - ); - return; - } - await Future.delayed(retryDelay); - } - } - } - - Future _invokeOrIgnore( - WindowController controller, - String method, - ) async { - try { - await controller.invokeMethod(method); - } on Object { - // Window may already be closing; nothing useful to do. - } - } - - Future _preferredCompactAgendaPosition() async { - try { - final primaryDisplay = await screenRetriever.getPrimaryDisplay(); - final displays = await screenRetriever.getAllDisplays(); - final cursor = await screenRetriever.getCursorScreenPoint(); - final display = displays.firstWhere((display) { - final frame = _visibleFrame(display); - return frame.contains(cursor); - }, orElse: () => primaryDisplay); - final placement = _topRightWorkAreaPlacement(_visibleFrame(display)); - _logger.fine( - 'Compact agenda monitor placement resolved: cursor_x=${cursor.dx.round()} ' - 'cursor_y=${cursor.dy.round()} workarea=${_displayGeometry(display)} ' - 'raw_x=${placement.rawPosition.dx.round()} ' - 'raw_y=${placement.rawPosition.dy.round()} ' - 'final_x=${placement.finalPosition.dx.round()} ' - 'final_y=${placement.finalPosition.dy.round()} ' - 'window_width=${_compactAgendaWindowFrameWidth.round()} ' - 'window_height=${_compactAgendaWindowFrameHeight.round()} ' - '${_sessionDescription()}', - ); - return placement.finalPosition; - } on Object catch (error) { - try { - final display = await screenRetriever.getPrimaryDisplay(); - final placement = _topRightWorkAreaPlacement(_visibleFrame(display)); - _logger.warning( - 'Compact agenda cursor placement fallback used: ' - 'workarea=${_displayGeometry(display)} ' - 'raw_x=${placement.rawPosition.dx.round()} ' - 'raw_y=${placement.rawPosition.dy.round()} ' - 'final_x=${placement.finalPosition.dx.round()} ' - 'final_y=${placement.finalPosition.dy.round()} ' - 'window_width=${_compactAgendaWindowFrameWidth.round()} ' - 'window_height=${_compactAgendaWindowFrameHeight.round()} ' - 'error=$error ${_sessionDescription()}', - ); - return placement.finalPosition; - } on Object catch (fallbackError) { - _logger.warning( - 'Compact agenda placement fallback failed: error=$fallbackError ' - '${_sessionDescription()}', - ); - return Offset.zero; - } - } - } - - Map _positionMethodArguments(Offset position) { - return { - 'position': {'x': position.dx, 'y': position.dy}, - }; - } - - Rect _visibleFrame(Display display) { - final visiblePosition = display.visiblePosition ?? Offset.zero; - final visibleSize = display.visibleSize ?? display.size; - return Rect.fromLTWH( - visiblePosition.dx, - visiblePosition.dy, - visibleSize.width, - visibleSize.height, - ); - } - - void _logPlacementRequest(String action, Offset position) { - _logger.fine( - 'Compact agenda placement requested: action=$action ' - 'final_x=${position.dx.round()} final_y=${position.dy.round()} ' - 'window_width=${_compactAgendaWindowFrameWidth.round()} ' - 'window_height=${_compactAgendaWindowFrameHeight.round()} ' - '${_sessionDescription()}', - ); - } - - String _displayGeometry(Display display) { - final frame = _visibleFrame(display); - return '${frame.left.round()},${frame.top.round()},' - '${frame.width.round()}x${frame.height.round()}'; - } - - static String _sessionDescription() { - final session = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - final backend = Platform.environment['GDK_BACKEND'] ?? ''; - return 'session=$session gdk_backend=$backend'; - } -} - -_CompactAgendaPlacement _topRightWorkAreaPlacement(Rect workarea) { - final rawPosition = Offset( - workarea.right - - _compactAgendaWindowFrameWidth - - _compactAgendaPanelScreenGap, - workarea.top + _compactAgendaPanelScreenGap, - ); - return _CompactAgendaPlacement( - rawPosition: rawPosition, - finalPosition: _clampWindowPositionToWorkArea(rawPosition, workarea), - ); -} - -Offset _clampWindowPositionToWorkArea(Offset position, Rect workarea) { - return Offset( - _clampToVisibleFrame( - position.dx, - workarea.left + _compactAgendaPanelScreenGap, - workarea.right - - _compactAgendaWindowFrameWidth - - _compactAgendaPanelScreenGap, - ), - _clampToVisibleFrame( - position.dy, - workarea.top + _compactAgendaPanelScreenGap, - workarea.bottom - - _compactAgendaWindowFrameHeight - - _compactAgendaPanelScreenGap, - ), - ); -} - -double _clampToVisibleFrame(double value, double min, double max) { - if (max < min) { - return min; - } - return value.clamp(min, max).toDouble(); -} - -class _CompactAgendaPlacement { - const _CompactAgendaPlacement({ - required this.rawPosition, - required this.finalPosition, - }); - - final Offset rawPosition; - final Offset finalPosition; -} diff --git a/lib/src/platform/gtk_font_service.dart b/lib/src/platform/gtk_font_service.dart index de3d63d..79c3533 100644 --- a/lib/src/platform/gtk_font_service.dart +++ b/lib/src/platform/gtk_font_service.dart @@ -47,6 +47,8 @@ class GtkFontService { return _parseFontSettings(raw); } on MissingPluginException { return null; + } on PlatformException { + return null; } } @@ -78,6 +80,22 @@ class GtkThemeService { final MethodChannel _methodChannel; final EventChannel _themeColorsEventsChannel; + Future setPreferDark(bool? preferDark) async { + if (preferDark == null) { + return; + } + try { + await _methodChannel.invokeMethod( + 'setGtkThemePreference', + preferDark, + ); + } on MissingPluginException { + // Non-Linux and lightweight test hosts do not expose GTK settings. + } on PlatformException { + // A theme preference is advisory; fall back to the current GTK palette. + } + } + Future getGtkThemeColors() async { try { final raw = await _methodChannel.invokeMapMethod( @@ -89,6 +107,8 @@ class GtkThemeService { return _parseThemeColors(raw); } on MissingPluginException { return null; + } on PlatformException { + return null; } } @@ -106,8 +126,15 @@ class GtkThemeService { } } +final initialGtkFontSettingsProvider = Provider( + (ref) => null, +); + final gtkFontSettingsProvider = StreamProvider((ref) { - return const GtkFontService().watchGtkFont(); + return _seededGtkSettingsStream( + ref.watch(initialGtkFontSettingsProvider), + const GtkFontService().watchGtkFont(), + ); }); @immutable @@ -127,13 +154,16 @@ class GtkThemeColors { this.controlHover, this.controlActive, this.accent, + this.accentForeground, this.activeToggle, this.foreground, this.mutedForeground, this.disabledForeground, this.disabledControl, this.border, - this.subtleBorder, + this.divider, + this.cardShade, + this.floatingBorder, this.sidebarBorder, this.shade, }); @@ -152,13 +182,16 @@ class GtkThemeColors { final Color? controlHover; final Color? controlActive; final Color? accent; + final Color? accentForeground; final Color? activeToggle; final Color? foreground; final Color? mutedForeground; final Color? disabledForeground; final Color? disabledControl; final Color? border; - final Color? subtleBorder; + final Color? divider; + final Color? cardShade; + final Color? floatingBorder; final Color? sidebarBorder; final Color? shade; @@ -180,13 +213,16 @@ class GtkThemeColors { other.controlHover == controlHover && other.controlActive == controlActive && other.accent == accent && + other.accentForeground == accentForeground && other.activeToggle == activeToggle && other.foreground == foreground && other.mutedForeground == mutedForeground && other.disabledForeground == disabledForeground && other.disabledControl == disabledControl && other.border == border && - other.subtleBorder == subtleBorder && + other.divider == divider && + other.cardShade == cardShade && + other.floatingBorder == floatingBorder && other.sidebarBorder == sidebarBorder && other.shade == shade; } @@ -207,27 +243,48 @@ class GtkThemeColors { controlHover, controlActive, accent, + accentForeground, activeToggle, foreground, mutedForeground, disabledForeground, disabledControl, border, - subtleBorder, + divider, + cardShade, + floatingBorder, sidebarBorder, shade, ]); } +final initialGtkThemeColorsProvider = Provider((ref) => null); + final gtkThemeColorsProvider = StreamProvider((ref) { - return const GtkThemeService().watchGtkThemeColors(); + return _seededGtkSettingsStream( + ref.watch(initialGtkThemeColorsProvider), + const GtkThemeService().watchGtkThemeColors(), + ); }); +Stream _seededGtkSettingsStream(T? initial, Stream updates) async* { + var previous = initial; + yield initial; + await for (final update in updates) { + if (update == previous) { + continue; + } + previous = update; + yield update; + } +} + GtkFontSettings? _parseFontSettings(Object? value) { if (value is! Map) { return null; } - final family = (value['family'] as String?)?.trim(); + final rawFamily = value['family']; + final family = rawFamily is String ? rawFamily.trim() : null; if (family == null || family.isEmpty) { return null; } @@ -243,8 +300,11 @@ GtkThemeColors? _parseThemeColors(Object? value) { if (value is! Map) { return null; } - final window = _parseColor(value['window'] as String?); - final brightness = switch ((value['brightness'] as String?)?.trim()) { + final window = _parseColor(value['window']); + final rawBrightness = value['brightness']; + final brightness = switch (rawBrightness is String + ? rawBrightness.trim() + : null) { 'dark' => Brightness.dark, 'light' => Brightness.light, _ => @@ -260,33 +320,39 @@ GtkThemeColors? _parseThemeColors(Object? value) { return GtkThemeColors( brightness: brightness, window: window, - view: _parseColor(value['view'] as String?), - sidebar: _parseColor(value['sidebar'] as String?), - secondarySidebar: _parseColor(value['secondarySidebar'] as String?), - headerbar: _parseColor(value['headerbar'] as String?), - headerbarFlat: _parseColor(value['headerbarFlat'] as String?), - card: _parseColor(value['card'] as String?), - dialog: _parseColor(value['dialog'] as String?), - popover: _parseColor(value['popover'] as String?), - control: _parseColor(value['control'] as String?), - controlHover: _parseColor(value['controlHover'] as String?), - controlActive: _parseColor(value['controlActive'] as String?), - accent: _parseColor(value['accent'] as String?), - activeToggle: _parseColor(value['activeToggle'] as String?), - foreground: _parseColor(value['foreground'] as String?), - mutedForeground: _parseColor(value['mutedForeground'] as String?), - disabledForeground: _parseColor(value['disabledForeground'] as String?), - disabledControl: _parseColor(value['disabledControl'] as String?), - border: _parseColor(value['border'] as String?), - subtleBorder: _parseColor(value['subtleBorder'] as String?), - sidebarBorder: _parseColor(value['sidebarBorder'] as String?), - shade: _parseColor(value['shade'] as String?), + view: _parseColor(value['view']), + sidebar: _parseColor(value['sidebar']), + secondarySidebar: _parseColor(value['secondarySidebar']), + headerbar: _parseColor(value['headerbar']), + headerbarFlat: _parseColor(value['headerbarFlat']), + card: _parseColor(value['card']), + dialog: _parseColor(value['dialog']), + popover: _parseColor(value['popover']), + control: _parseColor(value['control']), + controlHover: _parseColor(value['controlHover']), + controlActive: _parseColor(value['controlActive']), + accent: _parseColor(value['accent']), + accentForeground: _parseColor(value['accentForeground']), + activeToggle: _parseColor(value['activeToggle']), + foreground: _parseColor(value['foreground']), + mutedForeground: _parseColor(value['mutedForeground']), + disabledForeground: _parseColor(value['disabledForeground']), + disabledControl: _parseColor(value['disabledControl']), + border: _parseColor(value['border']), + divider: _parseColor(value['divider']), + cardShade: _parseColor(value['cardShade']), + floatingBorder: _parseColor(value['floatingBorder']), + sidebarBorder: _parseColor(value['sidebarBorder']), + shade: _parseColor(value['shade']), ); } -Color? _parseColor(String? value) { - final raw = value?.trim(); - if (raw == null || raw.isEmpty || !raw.startsWith('#')) { +Color? _parseColor(Object? value) { + if (value is! String) { + return null; + } + final raw = value.trim(); + if (raw.isEmpty || !raw.startsWith('#')) { return null; } final hex = raw.substring(1); diff --git a/lib/src/platform/linux_header_bar_configuration_synchronizer.dart b/lib/src/platform/linux_header_bar_configuration_synchronizer.dart new file mode 100644 index 0000000..04e11d0 --- /dev/null +++ b/lib/src/platform/linux_header_bar_configuration_synchronizer.dart @@ -0,0 +1,158 @@ +import 'dart:async'; + +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import 'linux_header_bar_service.dart'; + +@immutable +final class BusyMaxHeaderBarConfiguration { + const BusyMaxHeaderBarConfiguration({ + required this.labels, + required this.sidebarWidth, + required this.textDirection, + required this.theme, + }); + + final BusyMaxHeaderBarLabels labels; + final double sidebarWidth; + final TextDirection textDirection; + final BusyMaxHeaderBarTheme theme; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is BusyMaxHeaderBarConfiguration && + labels == other.labels && + sidebarWidth == other.sidebarWidth && + textDirection == other.textDirection && + theme == other.theme; + } + + @override + int get hashCode => Object.hash(labels, sidebarWidth, textDirection, theme); +} + +typedef BusyMaxHeaderBarConfigurationApplier = + Future Function(BusyMaxHeaderBarConfiguration configuration); +typedef BusyMaxAfterFrameScheduler = void Function(VoidCallback callback); +typedef BusyMaxHeaderBarConfigurationErrorReporter = + void Function(Object error, StackTrace stackTrace); + +/// Coalesces build-time native chrome updates and applies them serially. +/// +/// A route or theme change can rebuild the application several times in one +/// frame. Only the latest configuration from that frame reaches GTK, while an +/// update requested during an in-flight platform call is queued behind it. +final class BusyMaxHeaderBarConfigurationSynchronizer { + BusyMaxHeaderBarConfigurationSynchronizer(LinuxHeaderBarService service) + : this._( + apply: (configuration) async { + await service.initialize(); + await service.setLocalizedLabels(configuration.labels); + await service.setSidebarWidth(configuration.sidebarWidth); + await service.setTextDirection(configuration.textDirection); + await service.setTheme(configuration.theme); + }, + scheduleAfterFrame: _afterCurrentFrame, + reportError: _reportApplyFailure, + ); + + @visibleForTesting + BusyMaxHeaderBarConfigurationSynchronizer.forTesting({ + required BusyMaxHeaderBarConfigurationApplier apply, + required BusyMaxAfterFrameScheduler scheduleAfterFrame, + BusyMaxHeaderBarConfigurationErrorReporter? reportError, + }) : this._( + apply: apply, + scheduleAfterFrame: scheduleAfterFrame, + reportError: reportError ?? _reportApplyFailure, + ); + + BusyMaxHeaderBarConfigurationSynchronizer._({ + required BusyMaxHeaderBarConfigurationApplier apply, + required BusyMaxAfterFrameScheduler scheduleAfterFrame, + required BusyMaxHeaderBarConfigurationErrorReporter reportError, + }) : _apply = apply, + _scheduleAfterFrame = scheduleAfterFrame, + _reportError = reportError; + + final BusyMaxHeaderBarConfigurationApplier _apply; + final BusyMaxAfterFrameScheduler _scheduleAfterFrame; + final BusyMaxHeaderBarConfigurationErrorReporter _reportError; + + BusyMaxHeaderBarConfiguration? _requested; + Future _tail = Future.value(); + var _revision = 0; + var _disposed = false; + + void schedule(BusyMaxHeaderBarConfiguration configuration) { + if (_disposed || _requested == configuration) { + return; + } + _requested = configuration; + final revision = ++_revision; + _scheduleAfterFrame(() { + if (_disposed || revision != _revision) { + return; + } + _tail = _tail.then((_) => _applySafely(configuration, revision)); + }); + } + + @visibleForTesting + Future get settled => _tail; + + void dispose() { + _disposed = true; + _revision += 1; + } + + Future _applyIfCurrent( + BusyMaxHeaderBarConfiguration configuration, + int revision, + ) async { + if (_disposed || revision != _revision) { + return; + } + await _apply(configuration); + } + + Future _applySafely( + BusyMaxHeaderBarConfiguration configuration, + int revision, + ) async { + try { + await _applyIfCurrent(configuration, revision); + } catch (error, stackTrace) { + // A platform-channel failure must not poison the serial queue. Clear the + // deduplication token only when this remains the newest request so an + // equivalent configuration can be retried on a later rebuild. + if (!_disposed && revision == _revision && _requested == configuration) { + _requested = null; + } + try { + _reportError(error, stackTrace); + } catch (reportingError, reportingStackTrace) { + _reportApplyFailure(reportingError, reportingStackTrace); + } + } + } + + static void _reportApplyFailure(Object error, StackTrace stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'BusyMax Linux header bar', + context: ErrorDescription( + 'while applying native header-bar configuration', + ), + ), + ); + } + + static void _afterCurrentFrame(VoidCallback callback) { + SchedulerBinding.instance.addPostFrameCallback((_) => callback()); + } +} diff --git a/lib/src/platform/linux_header_bar_provider.dart b/lib/src/platform/linux_header_bar_provider.dart new file mode 100644 index 0000000..52e9b44 --- /dev/null +++ b/lib/src/platform/linux_header_bar_provider.dart @@ -0,0 +1,14 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'linux_header_bar_service.dart'; + +/// Application-scoped owner of the native Linux header-bar bridge. +/// +/// Keeping the provider beside the platform service lets shared presentation +/// infrastructure resolve the bridge without depending on the application's +/// composition root. +final linuxHeaderBarServiceProvider = Provider((ref) { + final service = LinuxHeaderBarService(); + ref.onDispose(service.dispose); + return service; +}); diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index ba17d9f..cd909cb 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -1,11 +1,13 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import '../schedule/schedule_view_mode.dart'; +const int busyMaxOnboardingContentMaxWidth = 480; + enum BusyMaxHeaderBarAction { back, continueSetup, @@ -19,13 +21,46 @@ enum BusyMaxHeaderBarAction { viewModeYear, viewModeAgenda, search, - create, + createEvent, + createTask, refresh, settings, keyboardShortcuts, + reportIssue, aboutBusyMax, } +sealed class BusyMaxHeaderBarSearchEvent { + const BusyMaxHeaderBarSearchEvent(); +} + +@immutable +final class BusyMaxHeaderBarSearchQueryChanged + extends BusyMaxHeaderBarSearchEvent { + const BusyMaxHeaderBarSearchQueryChanged(this.query); + + final String query; +} + +@immutable +final class BusyMaxHeaderBarSearchFocusChanged + extends BusyMaxHeaderBarSearchEvent { + const BusyMaxHeaderBarSearchFocusChanged(this.focused); + + final bool focused; +} + +@immutable +final class BusyMaxHeaderBarSearchCleared extends BusyMaxHeaderBarSearchEvent { + const BusyMaxHeaderBarSearchCleared(); +} + +@immutable +final class BusyMaxHeaderBarSearchEscapePressed + extends BusyMaxHeaderBarSearchEvent { + const BusyMaxHeaderBarSearchEscapePressed(); +} + @immutable class BusyMaxHeaderBarLabels { const BusyMaxHeaderBarLabels({ @@ -37,15 +72,33 @@ class BusyMaxHeaderBarLabels { required this.agenda, required this.search, required this.create, + required this.createEvent, + required this.createTask, required this.refresh, required this.menu, required this.previous, required this.next, - required this.sidebar, + required this.showSidebarPanel, + required this.hideSidebarPanel, required this.back, required this.settings, required this.keyboardShortcuts, + required this.reportIssue, required this.aboutBusyMax, + this.todayShortcut = '', + this.dayShortcut = '', + this.weekShortcut = '', + this.monthShortcut = '', + this.yearShortcut = '', + this.agendaShortcut = '', + this.searchShortcut = '', + this.sidebarShortcut = '', + this.createEventShortcut = '', + this.createTaskShortcut = '', + this.previousShortcut = '', + this.nextShortcut = '', + this.settingsShortcut = '', + this.keyboardShortcutsShortcut = '', }); final String today; @@ -56,15 +109,33 @@ class BusyMaxHeaderBarLabels { final String agenda; final String search; final String create; + final String createEvent; + final String createTask; final String refresh; final String menu; final String previous; final String next; - final String sidebar; + final String showSidebarPanel; + final String hideSidebarPanel; final String back; final String settings; final String keyboardShortcuts; + final String reportIssue; final String aboutBusyMax; + final String todayShortcut; + final String dayShortcut; + final String weekShortcut; + final String monthShortcut; + final String yearShortcut; + final String agendaShortcut; + final String searchShortcut; + final String sidebarShortcut; + final String createEventShortcut; + final String createTaskShortcut; + final String previousShortcut; + final String nextShortcut; + final String settingsShortcut; + final String keyboardShortcutsShortcut; Map toJson() { return { @@ -76,15 +147,33 @@ class BusyMaxHeaderBarLabels { 'agenda': agenda, 'search': search, 'create': create, + 'createEvent': createEvent, + 'createTask': createTask, 'refresh': refresh, 'menu': menu, 'previous': previous, 'next': next, - 'sidebar': sidebar, + 'showSidebarPanel': showSidebarPanel, + 'hideSidebarPanel': hideSidebarPanel, 'back': back, 'settings': settings, 'keyboardShortcuts': keyboardShortcuts, + 'reportIssue': reportIssue, 'aboutBusyMax': aboutBusyMax, + 'todayShortcut': todayShortcut, + 'dayShortcut': dayShortcut, + 'weekShortcut': weekShortcut, + 'monthShortcut': monthShortcut, + 'yearShortcut': yearShortcut, + 'agendaShortcut': agendaShortcut, + 'searchShortcut': searchShortcut, + 'sidebarShortcut': sidebarShortcut, + 'createEventShortcut': createEventShortcut, + 'createTaskShortcut': createTaskShortcut, + 'previousShortcut': previousShortcut, + 'nextShortcut': nextShortcut, + 'settingsShortcut': settingsShortcut, + 'keyboardShortcutsShortcut': keyboardShortcutsShortcut, }; } @@ -100,19 +189,37 @@ class BusyMaxHeaderBarLabels { agenda == other.agenda && search == other.search && create == other.create && + createEvent == other.createEvent && + createTask == other.createTask && refresh == other.refresh && menu == other.menu && previous == other.previous && next == other.next && - sidebar == other.sidebar && + showSidebarPanel == other.showSidebarPanel && + hideSidebarPanel == other.hideSidebarPanel && back == other.back && settings == other.settings && keyboardShortcuts == other.keyboardShortcuts && - aboutBusyMax == other.aboutBusyMax; + reportIssue == other.reportIssue && + aboutBusyMax == other.aboutBusyMax && + todayShortcut == other.todayShortcut && + dayShortcut == other.dayShortcut && + weekShortcut == other.weekShortcut && + monthShortcut == other.monthShortcut && + yearShortcut == other.yearShortcut && + agendaShortcut == other.agendaShortcut && + searchShortcut == other.searchShortcut && + sidebarShortcut == other.sidebarShortcut && + createEventShortcut == other.createEventShortcut && + createTaskShortcut == other.createTaskShortcut && + previousShortcut == other.previousShortcut && + nextShortcut == other.nextShortcut && + settingsShortcut == other.settingsShortcut && + keyboardShortcutsShortcut == other.keyboardShortcutsShortcut; } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ today, day, week, @@ -121,15 +228,93 @@ class BusyMaxHeaderBarLabels { agenda, search, create, + createEvent, + createTask, refresh, menu, previous, next, - sidebar, + showSidebarPanel, + hideSidebarPanel, back, settings, keyboardShortcuts, + reportIssue, aboutBusyMax, + todayShortcut, + dayShortcut, + weekShortcut, + monthShortcut, + yearShortcut, + agendaShortcut, + searchShortcut, + sidebarShortcut, + createEventShortcut, + createTaskShortcut, + previousShortcut, + nextShortcut, + settingsShortcut, + keyboardShortcutsShortcut, + ]); +} + +@immutable +class BusyMaxHeaderBarTooltipTheme { + const BusyMaxHeaderBarTooltipTheme({ + required this.backgroundColor, + required this.foregroundColor, + required this.borderColor, + required this.borderRadius, + required this.fontSize, + required this.horizontalPadding, + required this.verticalPadding, + required this.minimumHeight, + }); + + final Color backgroundColor; + final Color foregroundColor; + final Color borderColor; + final double borderRadius; + final double fontSize; + final double horizontalPadding; + final double verticalPadding; + final double minimumHeight; + + Map toJson() => { + 'backgroundColor': busyMaxCssColor(backgroundColor), + 'foregroundColor': busyMaxCssColor(foregroundColor), + 'borderColor': busyMaxCssColor(borderColor), + 'borderRadius': borderRadius, + 'fontSize': fontSize, + 'horizontalPadding': horizontalPadding, + 'verticalPadding': verticalPadding, + 'minimumHeight': minimumHeight, + }; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is BusyMaxHeaderBarTooltipTheme && + backgroundColor == other.backgroundColor && + foregroundColor == other.foregroundColor && + borderColor == other.borderColor && + borderRadius == other.borderRadius && + fontSize == other.fontSize && + horizontalPadding == other.horizontalPadding && + verticalPadding == other.verticalPadding && + minimumHeight == other.minimumHeight; + } + + @override + int get hashCode => Object.hash( + backgroundColor, + foregroundColor, + borderColor, + borderRadius, + fontSize, + horizontalPadding, + verticalPadding, + minimumHeight, ); } @@ -137,58 +322,43 @@ class BusyMaxHeaderBarLabels { class BusyMaxHeaderBarTheme { const BusyMaxHeaderBarTheme({ required this.preferDark, + required this.highContrast, required this.windowBackgroundColor, required this.backgroundColor, required this.sidebarBackgroundColor, required this.foregroundColor, - required this.mutedForegroundColor, - required this.disabledForegroundColor, - required this.controlColor, - required this.controlHoverColor, - required this.controlActiveColor, - required this.accentColor, - required this.accentForegroundColor, - required this.popoverBackgroundColor, - required this.borderColor, - required this.shadeColor, + required this.sidebarBorderColor, + required this.dialogBackgroundColor, + required this.dialogOutlineColor, required this.modalBarrierColor, + required this.tooltip, }); final bool preferDark; + final bool highContrast; final Color windowBackgroundColor; final Color backgroundColor; final Color sidebarBackgroundColor; final Color foregroundColor; - final Color mutedForegroundColor; - final Color disabledForegroundColor; - final Color controlColor; - final Color controlHoverColor; - final Color controlActiveColor; - final Color accentColor; - final Color accentForegroundColor; - final Color popoverBackgroundColor; - final Color borderColor; - final Color shadeColor; + final Color sidebarBorderColor; + final Color dialogBackgroundColor; + final Color dialogOutlineColor; final Color modalBarrierColor; + final BusyMaxHeaderBarTooltipTheme tooltip; Map toJson() { return { 'preferDark': preferDark, + 'highContrast': highContrast, 'windowBackgroundColor': busyMaxCssColor(windowBackgroundColor), 'backgroundColor': busyMaxCssColor(backgroundColor), 'sidebarBackgroundColor': busyMaxCssColor(sidebarBackgroundColor), 'foregroundColor': busyMaxCssColor(foregroundColor), - 'mutedForegroundColor': busyMaxCssColor(mutedForegroundColor), - 'disabledForegroundColor': busyMaxCssColor(disabledForegroundColor), - 'controlColor': busyMaxCssColor(controlColor), - 'controlHoverColor': busyMaxCssColor(controlHoverColor), - 'controlActiveColor': busyMaxCssColor(controlActiveColor), - 'accentColor': busyMaxCssColor(accentColor), - 'accentForegroundColor': busyMaxCssColor(accentForegroundColor), - 'popoverBackgroundColor': busyMaxCssColor(popoverBackgroundColor), - 'borderColor': busyMaxCssColor(borderColor), - 'shadeColor': busyMaxCssColor(shadeColor), + 'sidebarBorderColor': busyMaxCssColor(sidebarBorderColor), + 'dialogBackgroundColor': busyMaxCssColor(dialogBackgroundColor), + 'dialogOutlineColor': busyMaxCssColor(dialogOutlineColor), 'modalBarrierColor': busyMaxCssColor(modalBarrierColor), + 'tooltip': tooltip.toJson(), }; } @@ -197,41 +367,158 @@ class BusyMaxHeaderBarTheme { return identical(this, other) || other is BusyMaxHeaderBarTheme && other.preferDark == preferDark && + other.highContrast == highContrast && other.windowBackgroundColor == windowBackgroundColor && other.backgroundColor == backgroundColor && other.sidebarBackgroundColor == sidebarBackgroundColor && other.foregroundColor == foregroundColor && - other.mutedForegroundColor == mutedForegroundColor && - other.disabledForegroundColor == disabledForegroundColor && - other.controlColor == controlColor && - other.controlHoverColor == controlHoverColor && - other.controlActiveColor == controlActiveColor && - other.accentColor == accentColor && - other.accentForegroundColor == accentForegroundColor && - other.popoverBackgroundColor == popoverBackgroundColor && - other.borderColor == borderColor && - other.shadeColor == shadeColor && - other.modalBarrierColor == modalBarrierColor; + other.sidebarBorderColor == sidebarBorderColor && + other.dialogBackgroundColor == dialogBackgroundColor && + other.dialogOutlineColor == dialogOutlineColor && + other.modalBarrierColor == modalBarrierColor && + other.tooltip == tooltip; } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ preferDark, + highContrast, windowBackgroundColor, backgroundColor, sidebarBackgroundColor, foregroundColor, - mutedForegroundColor, - disabledForegroundColor, - controlColor, - controlHoverColor, - controlActiveColor, - accentColor, - accentForegroundColor, - popoverBackgroundColor, - borderColor, - shadeColor, + sidebarBorderColor, + dialogBackgroundColor, + dialogOutlineColor, modalBarrierColor, + tooltip, + ]); +} + +/// The complete, screen-owned presentation state of the native header bar. +/// +/// Configuration that is shared across screens, such as localized labels, +/// theme colors, and sidebar width, is intentionally managed separately. +@immutable +class BusyMaxHeaderBarState { + const BusyMaxHeaderBarState({ + required this.title, + required this.viewMode, + required this.canRefresh, + required this.canCreateEvent, + required this.canCreateTask, + required this.searchActive, + required this.searchQuery, + required this.canShowSidebar, + required this.sidebarVisible, + required this.navigationVisible, + required this.scheduleControlsVisible, + required this.backVisible, + }); + + static const int schemaVersion = 3; + + final String title; + final ScheduleViewMode viewMode; + final bool canRefresh; + final bool canCreateEvent; + final bool canCreateTask; + final bool searchActive; + final String searchQuery; + + bool get canCreate => canCreateEvent || canCreateTask; + + /// Whether the current layout can present a sidebar. + /// + /// This is distinct from [sidebarVisible], which represents the user's + /// current expanded/collapsed choice when a sidebar can be presented. + final bool canShowSidebar; + final bool sidebarVisible; + final bool navigationVisible; + final bool scheduleControlsVisible; + final bool backVisible; + + Map toJson() { + return { + 'schemaVersion': schemaVersion, + 'title': title, + 'viewMode': viewMode.name, + 'canRefresh': canRefresh, + 'canCreateEvent': canCreateEvent, + 'canCreateTask': canCreateTask, + 'searchActive': searchActive, + 'searchQuery': searchQuery, + 'canShowSidebar': canShowSidebar, + 'sidebarVisible': sidebarVisible, + 'navigationVisible': navigationVisible, + 'scheduleControlsVisible': scheduleControlsVisible, + 'backVisible': backVisible, + }; + } + + BusyMaxHeaderBarState copyWith({ + String? title, + ScheduleViewMode? viewMode, + bool? canRefresh, + bool? canCreateEvent, + bool? canCreateTask, + bool? searchActive, + String? searchQuery, + bool? canShowSidebar, + bool? sidebarVisible, + bool? navigationVisible, + bool? scheduleControlsVisible, + bool? backVisible, + }) { + return BusyMaxHeaderBarState( + title: title ?? this.title, + viewMode: viewMode ?? this.viewMode, + canRefresh: canRefresh ?? this.canRefresh, + canCreateEvent: canCreateEvent ?? this.canCreateEvent, + canCreateTask: canCreateTask ?? this.canCreateTask, + searchActive: searchActive ?? this.searchActive, + searchQuery: searchQuery ?? this.searchQuery, + canShowSidebar: canShowSidebar ?? this.canShowSidebar, + sidebarVisible: sidebarVisible ?? this.sidebarVisible, + navigationVisible: navigationVisible ?? this.navigationVisible, + scheduleControlsVisible: + scheduleControlsVisible ?? this.scheduleControlsVisible, + backVisible: backVisible ?? this.backVisible, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is BusyMaxHeaderBarState && + title == other.title && + viewMode == other.viewMode && + canRefresh == other.canRefresh && + canCreateEvent == other.canCreateEvent && + canCreateTask == other.canCreateTask && + searchActive == other.searchActive && + searchQuery == other.searchQuery && + canShowSidebar == other.canShowSidebar && + sidebarVisible == other.sidebarVisible && + navigationVisible == other.navigationVisible && + scheduleControlsVisible == other.scheduleControlsVisible && + backVisible == other.backVisible; + } + + @override + int get hashCode => Object.hash( + title, + viewMode, + canRefresh, + canCreateEvent, + canCreateTask, + searchActive, + searchQuery, + canShowSidebar, + sidebarVisible, + navigationVisible, + scheduleControlsVisible, + backVisible, ); } @@ -246,88 +533,82 @@ class LinuxHeaderBarService { final MethodChannel _channel; final bool _isLinux; - final _actions = StreamController.broadcast(); + final _sessions = []; - bool _initialized = false; + Future? _initialization; bool _available = false; - String? _titleRange; - ScheduleViewMode? _viewMode; - bool? _canRefresh; - bool? _canCreate; - bool? _searchActive; - bool? _sidebarVisible; - bool? _navigationVisible; - bool? _scheduleControlsVisible; - bool? _backVisible; + bool _disposed = false; _BusyMaxOnboardingControlsState? _onboardingControls; - bool? _modalBarrierVisible; + ({bool visible, int shadeDepth})? _modalBarrierState; double? _sidebarWidth; + TextDirection? _textDirection; BusyMaxHeaderBarLabels? _labels; BusyMaxHeaderBarTheme? _theme; + BusyMaxHeaderBarState? _state; bool get isAvailable => _available; - Stream get actions => _actions.stream; + /// Returns a route-owned session for native header state and actions. + /// + /// Claiming a new session immediately supersedes the previous one. This is + /// important during route transitions, when the outgoing screen remains + /// mounted and can still receive asynchronous rebuilds. + LinuxHeaderBarSession claimSession() { + if (_disposed) { + throw StateError('Cannot claim a session from a disposed service.'); + } + final session = LinuxHeaderBarSession._(this); + _sessions.add(session); + return session; + } + + /// Initializes the native bridge once and shares the in-flight result. + Future initialize() { + return _initialization ??= _initialize(); + } - Future initialize() async { - if (_initialized) { + Future _initialize() async { + if (_disposed) { return; } - _initialized = true; _channel.setMethodCallHandler(handleNativeMethodCall); if (!_isLinux) { _available = false; return; } try { - _available = await _channel.invokeMethod('initialize') ?? false; + final available = + await _channel.invokeMethod('initialize') ?? false; + if (!_disposed) { + _available = available; + } } on MissingPluginException { - _available = false; - } - } - - Future setTitleRange(String value) async { - if (!_available) { - return; - } - if (_titleRange == value) { - return; - } - _titleRange = value; - await _invokeIfAvailable('setTitleRange', value); - } - - Future setViewMode(ScheduleViewMode mode) async { - if (!_available) { - return; - } - if (_viewMode == mode) { - return; - } - _viewMode = mode; - await _invokeIfAvailable('setViewMode', mode.name); - } - - Future setCanRefresh(bool value) async { - if (!_available) { - return; - } - if (_canRefresh == value) { - return; + if (!_disposed) { + _available = false; + } + } on PlatformException { + if (!_disposed) { + _available = false; + } } - _canRefresh = value; - await _invokeIfAvailable('setCanRefresh', value); } - Future setCanCreate(bool value) async { + /// Applies all screen-owned header state in one native transaction. + /// + /// Equal state is not sent twice. Session ownership is checked before this + /// method is called so inactive routes cannot mutate the native-state cache. + Future _applyState( + BusyMaxHeaderBarState state, { + bool force = false, + }) async { if (!_available) { return; } - if (_canCreate == value) { + if (!force && _state == state) { return; } - _canCreate = value; - await _invokeIfAvailable('setCanCreate', value); + _state = state; + await _invokeIfAvailable('setState', state.toJson()); } Future setLocalizedLabels(BusyMaxHeaderBarLabels labels) async { @@ -352,67 +633,21 @@ class LinuxHeaderBarService { await _invokeIfAvailable('setSidebarWidth', value); } - Future setSearchActive(bool value) async { - if (!_available) { - return; - } - if (_searchActive == value) { - return; - } - _searchActive = value; - await _invokeIfAvailable('setSearchActive', value); - } - - Future setSidebarVisible(bool value) async { - if (!_available) { - return; - } - if (_sidebarVisible == value) { - return; - } - _sidebarVisible = value; - await _invokeIfAvailable('setSidebarVisible', value); - } - - Future setNavigationVisible(bool value) async { - if (!_available) { - return; - } - if (_navigationVisible == value) { - return; - } - _navigationVisible = value; - await _invokeIfAvailable('setNavigationVisible', value); - } - - Future setScheduleControlsVisible(bool value) async { - if (!_available) { - return; - } - if (_scheduleControlsVisible == value) { - return; - } - _scheduleControlsVisible = value; - await _invokeIfAvailable('setScheduleControlsVisible', value); - } - - Future setBackVisible(bool value) async { - if (!_available) { + Future setTextDirection(TextDirection value) async { + if (!_available || _textDirection == value) { return; } - if (_backVisible == value) { - return; - } - _backVisible = value; - await _invokeIfAvailable('setBackVisible', value); + _textDirection = value; + await _invokeIfAvailable('setTextDirection', value.name); } - Future setOnboardingControls({ + Future _setOnboardingControls({ required bool visible, required bool canGoBack, required bool canContinue, required String backLabel, required String continueLabel, + required int contentWidth, bool force = false, }) async { if (!_available) { @@ -424,6 +659,7 @@ class LinuxHeaderBarService { canContinue: canContinue, backLabel: backLabel, continueLabel: continueLabel, + contentWidth: contentWidth, ); if (!force && _onboardingControls == state) { return; @@ -432,15 +668,51 @@ class LinuxHeaderBarService { await _invokeIfAvailable('setOnboardingControls', state.toJson()); } - Future setModalBarrierVisible(bool value) async { + LinuxHeaderBarSession? get _activeSession { + return _sessions.isEmpty ? null : _sessions.last; + } + + bool _isCurrentSession(LinuxHeaderBarSession session) { + return identical(_activeSession, session); + } + + void _releaseSession(LinuxHeaderBarSession session) { + final wasActive = _isCurrentSession(session); + _sessions.remove(session); + if (wasActive) { + final activeSession = _activeSession; + if (activeSession != null) { + unawaited(activeSession._restore()); + } + } + } + + Future setModalBarrierState({ + required bool visible, + required int shadeDepth, + }) async { if (!_available) { return; } - if (_modalBarrierVisible == value) { + final effectiveShadeDepth = visible ? (shadeDepth < 0 ? 0 : shadeDepth) : 0; + final state = (visible: visible, shadeDepth: effectiveShadeDepth); + if (_modalBarrierState == state) { return; } - _modalBarrierVisible = value; - await _invokeIfAvailable('setModalBarrierVisible', value); + _modalBarrierState = state; + await _invokeIfAvailable('setModalBarrierState', { + 'visible': state.visible, + 'shadeDepth': state.shadeDepth, + }); + } + + Future setModalBarrierVisible(bool value) { + return setModalBarrierState(visible: value, shadeDepth: value ? 1 : 0); + } + + Future setModalBarrierDepth(int value) { + final depth = value < 0 ? 0 : value; + return setModalBarrierState(visible: depth > 0, shadeDepth: depth); } Future setTheme(BusyMaxHeaderBarTheme theme) async { @@ -456,22 +728,69 @@ class LinuxHeaderBarService { @visibleForTesting Future handleNativeMethodCall(MethodCall call) async { + final searchEvent = _searchEventForCall(call); + if (searchEvent != null) { + _activeSession?._dispatchSearchEvent(searchEvent); + return null; + } final action = _actionForMethod(call.method); - if (action != null && !_actions.isClosed) { - _actions.add(action); + if (action != null) { + _activeSession?._dispatch(action); } return null; } Future _invokeIfAvailable(String method, Object? arguments) async { - if (!_available) { + if (_disposed || !_available) { return; } try { await _channel.invokeMethod(method, arguments); } on MissingPluginException { _available = false; + } on PlatformException { + _available = false; + } + } + + Future _showCreateMenu(LinuxHeaderBarSession session) async { + if (_disposed || !_available || !_isCurrentSession(session)) { + return false; + } + try { + return await _channel.invokeMethod('showCreateMenu') ?? false; + } on MissingPluginException { + _available = false; + } on PlatformException { + _available = false; + } + return false; + } + + Future _focusSearch(LinuxHeaderBarSession session) async { + if (_disposed || !_available || !_isCurrentSession(session)) { + return false; + } + try { + return await _channel.invokeMethod('focusSearch') ?? false; + } on MissingPluginException { + _available = false; + } on PlatformException { + _available = false; } + return false; + } + + BusyMaxHeaderBarSearchEvent? _searchEventForCall(MethodCall call) { + return switch ((call.method, call.arguments)) { + ('searchQueryChanged', final String query) => + BusyMaxHeaderBarSearchQueryChanged(query), + ('searchFocusChanged', final bool focused) => + BusyMaxHeaderBarSearchFocusChanged(focused), + ('searchCleared', _) => const BusyMaxHeaderBarSearchCleared(), + ('searchEscapePressed', _) => const BusyMaxHeaderBarSearchEscapePressed(), + _ => null, + }; } BusyMaxHeaderBarAction? _actionForMethod(String method) { @@ -488,18 +807,194 @@ class LinuxHeaderBarService { 'viewModeYear' => BusyMaxHeaderBarAction.viewModeYear, 'viewModeAgenda' => BusyMaxHeaderBarAction.viewModeAgenda, 'search' => BusyMaxHeaderBarAction.search, - 'create' => BusyMaxHeaderBarAction.create, + 'createEvent' => BusyMaxHeaderBarAction.createEvent, + 'createTask' => BusyMaxHeaderBarAction.createTask, 'refresh' => BusyMaxHeaderBarAction.refresh, 'settings' => BusyMaxHeaderBarAction.settings, 'keyboardShortcuts' => BusyMaxHeaderBarAction.keyboardShortcuts, + 'reportIssue' => BusyMaxHeaderBarAction.reportIssue, 'aboutBusyMax' => BusyMaxHeaderBarAction.aboutBusyMax, _ => null, }; } void dispose() { + if (_disposed) { + return; + } + _disposed = true; + _available = false; + for (final session in _sessions.toList()) { + session._disposeFromService(); + } + _sessions.clear(); _channel.setMethodCallHandler(null); + } +} + +/// Exclusive route-level access to native header state and actions. +/// +/// Global concerns such as theme, labels, and modal barriers remain on +/// [LinuxHeaderBarService]. Route presentation state goes through this session +/// so that a transitioning-out screen cannot overwrite its successor. +class LinuxHeaderBarSession { + LinuxHeaderBarSession._(this._service); + + final LinuxHeaderBarService _service; + final _actions = StreamController.broadcast(); + final _searchEvents = + StreamController.broadcast(); + bool _disposed = false; + BusyMaxHeaderBarState? _state; + int _stateRevision = 0; + _BusyMaxOnboardingControlsState? _onboardingControls; + int _onboardingRevision = 0; + + bool get isCurrent => !_disposed && _service._isCurrentSession(this); + + bool get isAvailable => !_disposed && _service.isAvailable; + + Stream get actions => _actions.stream; + + Stream get searchEvents => _searchEvents.stream; + + Future initialize() => _service.initialize(); + + Future updateState( + BusyMaxHeaderBarState state, { + bool force = false, + }) async { + if (_disposed) { + return; + } + _state = state; + final revision = ++_stateRevision; + await initialize(); + if (!isCurrent || revision != _stateRevision) { + return; + } + await _service._applyState(state, force: force); + } + + /// Opens the route-owned native Create popover when it is available. + /// + /// The current-session check prevents an outgoing route from opening UI in + /// a header that has already been claimed by its successor. + Future showCreateMenu() async { + if (_disposed) { + return false; + } + await initialize(); + if (!isCurrent) { + return false; + } + return _service._showCreateMenu(this); + } + + /// Focuses the route-owned native search entry when it is active. + Future focusSearch() async { + if (_disposed) { + return false; + } + await initialize(); + if (!isCurrent) { + return false; + } + return _service._focusSearch(this); + } + + Future setOnboardingControls({ + required bool visible, + required bool canGoBack, + required bool canContinue, + required String backLabel, + required String continueLabel, + int contentWidth = busyMaxOnboardingContentMaxWidth, + bool force = false, + }) async { + if (_disposed) { + return; + } + final state = _BusyMaxOnboardingControlsState( + visible: visible, + canGoBack: canGoBack, + canContinue: canContinue, + backLabel: backLabel, + continueLabel: continueLabel, + contentWidth: contentWidth, + ); + _onboardingControls = state; + final revision = ++_onboardingRevision; + await initialize(); + if (!isCurrent || revision != _onboardingRevision) { + return; + } + await _service._setOnboardingControls( + visible: state.visible, + canGoBack: state.canGoBack, + canContinue: state.canContinue, + backLabel: state.backLabel, + continueLabel: state.continueLabel, + contentWidth: state.contentWidth, + force: force, + ); + } + + Future _restore() async { + await initialize(); + if (!isCurrent) { + return; + } + final state = _state; + if (state != null) { + await _service._applyState(state, force: true); + } + if (!isCurrent) { + return; + } + final onboardingControls = _onboardingControls; + if (onboardingControls != null) { + await _service._setOnboardingControls( + visible: onboardingControls.visible, + canGoBack: onboardingControls.canGoBack, + canContinue: onboardingControls.canContinue, + backLabel: onboardingControls.backLabel, + continueLabel: onboardingControls.continueLabel, + contentWidth: onboardingControls.contentWidth, + force: true, + ); + } + } + + void _dispatch(BusyMaxHeaderBarAction action) { + if (isCurrent && !_actions.isClosed) { + _actions.add(action); + } + } + + void _dispatchSearchEvent(BusyMaxHeaderBarSearchEvent event) { + if (isCurrent && !_searchEvents.isClosed) { + _searchEvents.add(event); + } + } + + void dispose() { + if (_disposed) { + return; + } + _disposed = true; + _service._releaseSession(this); unawaited(_actions.close()); + unawaited(_searchEvents.close()); + } + + void _disposeFromService() { + if (_disposed) { + return; + } + _disposed = true; + unawaited(_actions.close()); + unawaited(_searchEvents.close()); } } @@ -511,6 +1006,7 @@ class _BusyMaxOnboardingControlsState { required this.canContinue, required this.backLabel, required this.continueLabel, + required this.contentWidth, }); final bool visible; @@ -518,6 +1014,7 @@ class _BusyMaxOnboardingControlsState { final bool canContinue; final String backLabel; final String continueLabel; + final int contentWidth; Map toJson() { return { @@ -526,6 +1023,7 @@ class _BusyMaxOnboardingControlsState { 'canContinue': canContinue, 'backLabel': backLabel, 'continueLabel': continueLabel, + 'contentWidth': contentWidth, }; } @@ -537,15 +1035,21 @@ class _BusyMaxOnboardingControlsState { canGoBack == other.canGoBack && canContinue == other.canContinue && backLabel == other.backLabel && - continueLabel == other.continueLabel; + continueLabel == other.continueLabel && + contentWidth == other.contentWidth; } @override - int get hashCode => - Object.hash(visible, canGoBack, canContinue, backLabel, continueLabel); + int get hashCode => Object.hash( + visible, + canGoBack, + canContinue, + backLabel, + continueLabel, + contentWidth, + ); } -@visibleForTesting String busyMaxCssColor(Color color) { final rgb = color.toARGB32() & 0x00ffffff; if (color.a >= 1) { diff --git a/lib/src/platform/main_window_command_bridge.dart b/lib/src/platform/main_window_command_bridge.dart deleted file mode 100644 index 1182d6f..0000000 --- a/lib/src/platform/main_window_command_bridge.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../app/app_bootstrap.dart'; -import '../app/app_router.dart'; -import '../features/schedule/application/compact_agenda_data.dart'; -import '../features/schedule/application/compact_agenda_snapshot.dart'; -import '../features/sync/sync_auth_error.dart'; -import '../schedule/schedule_commands.dart'; -import 'main_window_command_client.dart'; - -Future> loadFreshCompactAgendaSnapshot( - WidgetRef ref, - Object? rawArgs, -) async { - final query = decodeCompactAgendaQuery(rawArgs); - final data = await ref.refresh( - compactAgendaDataForQueryProvider(query).future, - ); - return encodeCompactAgendaData(data); -} - -class MainWindowCommandBridge extends ConsumerStatefulWidget { - const MainWindowCommandBridge({super.key, required this.child}); - - final Widget child; - - @override - ConsumerState createState() => - _MainWindowCommandBridgeState(); -} - -class _MainWindowCommandBridgeState - extends ConsumerState { - var _sequence = 0; - - @override - void initState() { - super.initState(); - unawaited(busyMaxMainWindowChannel.setMethodCallHandler(_handleMethodCall)); - } - - @override - void dispose() { - unawaited(busyMaxMainWindowChannel.setMethodCallHandler(null)); - super.dispose(); - } - - Future _handleMethodCall(MethodCall call) async { - switch (call.method) { - case 'busymax.main.open': - await ref.read(linuxWindowServiceProvider).showWindow(); - return true; - case 'busymax.main.openScheduleItem': - return _openScheduleItem(call.arguments); - case 'busymax.main.newTask': - return _newTask(); - case 'busymax.main.refreshAll': - await ref.read(allAccountsSyncRunnerProvider)(); - return true; - case 'busymax.main.compactAgendaSnapshot': - return _compactAgendaSnapshot(call.arguments); - case 'busymax.main.requestTaskSync': - return _requestTaskSync(call.arguments); - case 'busymax.main.requestCalendarSync': - return _requestCalendarSync(call.arguments); - } - - throw MissingPluginException('Not implemented: ${call.method}'); - } - - Future _openScheduleItem(Object? rawArgs) async { - if (rawArgs is! Map) { - return false; - } - final args = rawArgs.cast(); - final kind = args['kind']?.toString(); - final accountId = args['accountId']?.toString(); - final sourceId = args['sourceId']?.toString(); - final itemId = args['itemId']?.toString(); - final rawDate = args['date']?.toString(); - final date = rawDate == null ? null : DateTime.tryParse(rawDate); - - if (kind == null || - accountId == null || - sourceId == null || - itemId == null) { - return false; - } - - final commandKind = switch (kind) { - 'task' => ScheduleWorkspaceCommandKind.openTask, - 'calendarEvent' => ScheduleWorkspaceCommandKind.openCalendarEvent, - _ => null, - }; - if (commandKind == null) { - return false; - } - - await ref.read(linuxWindowServiceProvider).showWindow(); - ref - .read(scheduleWorkspaceCommandProvider.notifier) - .state = ScheduleWorkspaceCommand( - commandKind, - ++_sequence, - date: date, - accountId: accountId, - sourceId: sourceId, - itemId: itemId, - ); - ref.read(appRouterProvider).go('/schedule'); - return true; - } - - Future _newTask() async { - await ref.read(linuxWindowServiceProvider).showWindow(); - ref - .read(scheduleWorkspaceCommandProvider.notifier) - .state = ScheduleWorkspaceCommand( - ScheduleWorkspaceCommandKind.newTask, - ++_sequence, - ); - ref.read(appRouterProvider).go('/schedule'); - return true; - } - - Future> _compactAgendaSnapshot(Object? rawArgs) async { - return loadFreshCompactAgendaSnapshot(ref, rawArgs); - } - - Future _requestTaskSync(Object? rawArgs) async { - if (rawArgs is! Map) { - return false; - } - final accountId = rawArgs.cast()['accountId']?.toString(); - if (accountId == null || accountId.isEmpty) { - return false; - } - ref - .read(pendingMutationSyncRequesterForAccountProvider(accountId)) - .request(); - unawaited(ref.read(notificationSchedulerProvider).checkNow()); - return true; - } - - Future _requestCalendarSync(Object? rawArgs) async { - if (rawArgs is! Map) { - return false; - } - final accountId = rawArgs.cast()['accountId']?.toString(); - if (accountId == null || accountId.isEmpty) { - return false; - } - unawaited(_syncCalendarAccount(accountId)); - unawaited(ref.read(notificationSchedulerProvider).checkNow()); - return true; - } - - Future _syncCalendarAccount(String accountId) async { - try { - await ref - .read(calendarSyncEngineForAccountFactoryProvider)(accountId) - .incrementalSync(); - } on Object catch (error) { - if (isMissingOAuthTokenError(error)) { - try { - await ref - .read(authRepositoryProvider) - .markReconnectRequired(accountId); - } on Object { - // Preserve the original sync failure handling below. - } - } - // The local pending operation remains queued and a later refresh/sync can - // retry it. - } - } - - @override - Widget build(BuildContext context) { - return widget.child; - } -} diff --git a/lib/src/platform/main_window_command_client.dart b/lib/src/platform/main_window_command_client.dart deleted file mode 100644 index 073f307..0000000 --- a/lib/src/platform/main_window_command_client.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:desktop_multi_window/desktop_multi_window.dart'; - -import '../features/schedule/application/compact_agenda_data.dart'; -import '../features/schedule/application/compact_agenda_snapshot.dart'; -import '../schedule/schedule_item.dart'; -import '../schedule/schedule_projection.dart'; - -const busyMaxMainWindowChannelName = 'io.busystack.busymax/main-window'; - -const busyMaxMainWindowChannel = WindowMethodChannel( - busyMaxMainWindowChannelName, - mode: ChannelMode.unidirectional, -); - -class MainWindowCommandClient { - const MainWindowCommandClient(); - - Future openMain() async { - await busyMaxMainWindowChannel.invokeMethod('busymax.main.open'); - } - - Future openScheduleItem(ScheduleItem item) async { - final date = item.start == null - ? ScheduleProjection.day(DateTime.now()) - : ScheduleProjection.day(item.start!); - await busyMaxMainWindowChannel - .invokeMethod('busymax.main.openScheduleItem', { - 'kind': item is TaskScheduleItem ? 'task' : 'calendarEvent', - 'accountId': item.accountId, - 'sourceId': item.sourceId, - 'itemId': item.id, - 'date': date.toIso8601String(), - }); - } - - Future newTask() async { - await busyMaxMainWindowChannel.invokeMethod('busymax.main.newTask'); - } - - Future refreshAll() async { - await busyMaxMainWindowChannel.invokeMethod( - 'busymax.main.refreshAll', - ); - } - - Future compactAgendaSnapshot( - CompactAgendaQuery query, - ) async { - final response = await busyMaxMainWindowChannel.invokeMethod( - 'busymax.main.compactAgendaSnapshot', - encodeCompactAgendaQuery(query), - ); - return decodeCompactAgendaData(response); - } - - Future requestTaskSync(String accountId) async { - await busyMaxMainWindowChannel.invokeMethod( - 'busymax.main.requestTaskSync', - {'accountId': accountId}, - ); - } - - Future requestCalendarSync(String accountId) async { - await busyMaxMainWindowChannel.invokeMethod( - 'busymax.main.requestCalendarSync', - {'accountId': accountId}, - ); - } -} diff --git a/lib/src/platform/native_dialog_service.dart b/lib/src/platform/native_dialog_service.dart new file mode 100644 index 0000000..f4c34f9 --- /dev/null +++ b/lib/src/platform/native_dialog_service.dart @@ -0,0 +1,148 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'linux_header_bar_service.dart'; + +@visibleForTesting +const nativeDialogChannelName = 'busymax/native_dialogs'; + +@immutable +class NativeTimeZoneOption { + const NativeTimeZoneOption({ + required this.id, + required this.region, + required this.name, + required this.englishName, + required this.title, + required this.subtitle, + required this.searchText, + }); + + final String id; + final String region; + final String name; + final String englishName; + final String title; + final String subtitle; + final String searchText; + + Map toMessage() { + return { + 'id': id, + 'region': region, + 'name': name, + 'englishName': englishName, + 'title': title, + 'subtitle': subtitle, + 'searchText': searchText, + }; + } +} + +@immutable +class NativeTimeZoneSelectionResult { + const NativeTimeZoneSelectionResult({ + required this.available, + this.selectedTimeZone, + }); + + const NativeTimeZoneSelectionResult.unavailable() + : available = false, + selectedTimeZone = null; + + final bool available; + final String? selectedTimeZone; +} + +/// Semantic presentation values shared with Flutter's grouped-list component. +/// +/// The Linux host keeps its native GTK input and Handy rows, while these +/// values prevent the native result groups from developing a separate visual +/// language from BusyMaxGroupedList. +@immutable +class NativeGroupedListStyle { + const NativeGroupedListStyle({ + required this.surfaceColor, + required this.dividerColor, + required this.sectionHeaderColor, + required this.primaryTextColor, + required this.secondaryTextColor, + required this.hoverColor, + required this.shadowColor, + required this.outlineColor, + required this.highContrast, + required this.radius, + required this.sectionTopSpacing, + required this.sectionHorizontalPadding, + required this.titleBottomSpacing, + }); + + final Color surfaceColor; + final Color dividerColor; + final Color sectionHeaderColor; + final Color primaryTextColor; + final Color secondaryTextColor; + final Color hoverColor; + final Color shadowColor; + final Color outlineColor; + final bool highContrast; + final int radius; + final int sectionTopSpacing; + final int sectionHorizontalPadding; + final int titleBottomSpacing; + + Map toMessage() { + return { + 'surfaceColor': busyMaxCssColor(surfaceColor), + 'dividerColor': busyMaxCssColor(dividerColor), + 'sectionHeaderColor': busyMaxCssColor(sectionHeaderColor), + 'primaryTextColor': busyMaxCssColor(primaryTextColor), + 'secondaryTextColor': busyMaxCssColor(secondaryTextColor), + 'hoverColor': busyMaxCssColor(hoverColor), + 'shadowColor': busyMaxCssColor(shadowColor), + 'outlineColor': busyMaxCssColor(outlineColor), + 'highContrast': highContrast, + 'radius': radius, + 'sectionTopSpacing': sectionTopSpacing, + 'sectionHorizontalPadding': sectionHorizontalPadding, + 'titleBottomSpacing': titleBottomSpacing, + }; + } +} + +/// Presents desktop dialogs owned by the host toolkit. +class NativeDialogService { + const NativeDialogService({ + MethodChannel channel = const MethodChannel(nativeDialogChannelName), + }) : _channel = channel; + + final MethodChannel _channel; + + Future selectTimeZone({ + required String title, + required String searchPlaceholder, + required String noResultsLabel, + required String selectedTimeZone, + required List options, + required NativeGroupedListStyle groupedListStyle, + }) async { + try { + final selected = await _channel.invokeMethod('selectTimeZone', { + 'title': title, + 'searchPlaceholder': searchPlaceholder, + 'noResultsLabel': noResultsLabel, + 'selectedTimeZone': selectedTimeZone, + 'options': options.map((option) => option.toMessage()).toList(), + 'groupedListStyle': groupedListStyle.toMessage(), + }); + return NativeTimeZoneSelectionResult( + available: true, + selectedTimeZone: selected, + ); + } on MissingPluginException { + return const NativeTimeZoneSelectionResult.unavailable(); + } on PlatformException { + return const NativeTimeZoneSelectionResult.unavailable(); + } + } +} diff --git a/lib/src/platform/native_menu_service.dart b/lib/src/platform/native_menu_service.dart new file mode 100644 index 0000000..e365e1b --- /dev/null +++ b/lib/src/platform/native_menu_service.dart @@ -0,0 +1,129 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +@visibleForTesting +const nativeMenuChannelName = 'busymax/native_menus'; + +/// Identifies one native menu presentation. +/// +/// The host compares this identity when dismissing a menu, so a retiring +/// widget can never close a newer caller's popover. +@immutable +final class NativeMenuSession { + NativeMenuSession() : id = _nextId++; + + static int _nextId = 1; + + final int id; +} + +/// One command exposed by a host-toolkit menu. +/// +/// Entries intentionally contain presentation state only. The selected index +/// is mapped back to the caller's domain value after the native menu closes. +@immutable +final class NativeMenuEntry { + const NativeMenuEntry({ + required this.label, + this.iconName, + this.enabled = true, + this.selected = false, + this.shortcut, + }); + + final String label; + final String? iconName; + final bool enabled; + final bool selected; + final String? shortcut; + + Map _toPlatformMap() { + return { + 'label': label, + if (iconName != null && iconName!.isNotEmpty) 'icon': iconName!, + 'enabled': enabled, + 'selected': selected, + if (shortcut != null && shortcut!.isNotEmpty) 'shortcut': shortcut!, + }; + } +} + +/// Result of asking the host toolkit to present a native menu. +/// +/// [available] distinguishes a dismissed native menu from a host that does not +/// implement the bridge. When available, a null [selectedIndex] means that the +/// user dismissed the menu without choosing an entry. +@immutable +final class NativeMenuResult { + const NativeMenuResult.available({this.selectedIndex}) : available = true; + + const NativeMenuResult.unavailable() + : available = false, + selectedIndex = null; + + final bool available; + final int? selectedIndex; +} + +/// Presents an anchored menu owned by the host desktop toolkit. +/// +/// Hosts that do not implement the channel are reported as unavailable so the +/// caller can use its themed Flutter fallback. +class NativeMenuService { + const NativeMenuService({ + MethodChannel channel = const MethodChannel(nativeMenuChannelName), + }) : _channel = channel; + + final MethodChannel _channel; + + Future show({ + required NativeMenuSession session, + required Rect anchor, + required List entries, + bool focusFirst = false, + bool preferAbove = false, + }) async { + try { + final selectedIndex = await _channel.invokeMethod('show', { + 'sessionId': session.id, + 'anchor': { + 'x': anchor.left, + 'y': anchor.top, + 'width': anchor.width, + 'height': anchor.height, + }, + 'entries': [for (final entry in entries) entry._toPlatformMap()], + 'focusFirst': focusFirst, + 'preferredPosition': preferAbove ? 'top' : 'bottom', + }); + return NativeMenuResult.available(selectedIndex: selectedIndex); + } on MissingPluginException { + return const NativeMenuResult.unavailable(); + } on PlatformException catch (error) { + if (error.code == 'unavailable') { + return const NativeMenuResult.unavailable(); + } + rethrow; + } + } + + /// Dismisses [session] if it is still owned by the native host. + /// + /// Returns false when no menu was dismissed or the host does not implement + /// the native menu bridge. + Future dismiss(NativeMenuSession session) async { + try { + return await _channel.invokeMethod('dismiss', { + 'sessionId': session.id, + }) ?? + false; + } on MissingPluginException { + return false; + } on PlatformException catch (error) { + if (error.code == 'unavailable') { + return false; + } + rethrow; + } + } +} diff --git a/lib/src/schedule/schedule_commands.dart b/lib/src/schedule/schedule_commands.dart index 3e583aa..79423ba 100644 --- a/lib/src/schedule/schedule_commands.dart +++ b/lib/src/schedule/schedule_commands.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; enum ScheduleWorkspaceCommandKind { today, + agenda, newEvent, newTask, openDate, diff --git a/lib/src/schedule/schedule_filters.dart b/lib/src/schedule/schedule_filters.dart index dd698bf..415399b 100644 --- a/lib/src/schedule/schedule_filters.dart +++ b/lib/src/schedule/schedule_filters.dart @@ -1,8 +1,28 @@ +class ScheduleTaskListKey { + const ScheduleTaskListKey({ + required this.accountId, + required this.taskListId, + }); + + final String accountId; + final String taskListId; + + @override + bool operator ==(Object other) { + return other is ScheduleTaskListKey && + other.accountId == accountId && + other.taskListId == taskListId; + } + + @override + int get hashCode => Object.hash(accountId, taskListId); +} + class ScheduleFilters { const ScheduleFilters({ this.accountIds = const {}, this.sourceIds = const {}, - this.taskListIds = const {}, + this.taskListKeys = const {}, this.sourceFilterActive = false, this.taskListFilterActive = false, this.includeCalendarEvents = true, @@ -14,7 +34,7 @@ class ScheduleFilters { final Set accountIds; final Set sourceIds; - final Set taskListIds; + final Set taskListKeys; final bool sourceFilterActive; final bool taskListFilterActive; final bool includeCalendarEvents; diff --git a/lib/src/schedule/schedule_item.dart b/lib/src/schedule/schedule_item.dart index 2d3ff48..ca39923 100644 --- a/lib/src/schedule/schedule_item.dart +++ b/lib/src/schedule/schedule_item.dart @@ -2,6 +2,25 @@ import '../task_providers/task_provider.dart'; enum ScheduleItemKind { calendarEvent, task, localReminder } +class ScheduleItemCapabilities { + const ScheduleItemCapabilities({ + required this.canEdit, + required this.canDelete, + }); + + static const editable = ScheduleItemCapabilities( + canEdit: true, + canDelete: true, + ); + static const readOnly = ScheduleItemCapabilities( + canEdit: false, + canDelete: false, + ); + + final bool canEdit; + final bool canDelete; +} + sealed class ScheduleItem { String get id; String get accountId; @@ -16,6 +35,7 @@ sealed class ScheduleItem { bool get allDay; List get categories; ScheduleItemKind get kind; + ScheduleItemCapabilities get capabilities; } class CalendarScheduleItem implements ScheduleItem { @@ -46,6 +66,7 @@ class CalendarScheduleItem implements ScheduleItem { this.sourceName, this.accountDisplayName, this.accountEmail, + this.capabilities = ScheduleItemCapabilities.editable, }); @override @@ -90,6 +111,8 @@ class CalendarScheduleItem implements ScheduleItem { final String? accountDisplayName; @override final String? accountEmail; + @override + final ScheduleItemCapabilities capabilities; @override ScheduleItemKind get kind => ScheduleItemKind.calendarEvent; @@ -112,6 +135,7 @@ class TaskScheduleItem implements ScheduleItem { this.sourceName, this.accountDisplayName, this.accountEmail, + this.capabilities = ScheduleItemCapabilities.editable, }); @override @@ -141,6 +165,8 @@ class TaskScheduleItem implements ScheduleItem { final String? accountDisplayName; @override final String? accountEmail; + @override + final ScheduleItemCapabilities capabilities; @override ScheduleItemKind get kind => ScheduleItemKind.task; diff --git a/lib/src/schedule/schedule_projection.dart b/lib/src/schedule/schedule_projection.dart index de26253..6837b00 100644 --- a/lib/src/schedule/schedule_projection.dart +++ b/lib/src/schedule/schedule_projection.dart @@ -90,6 +90,10 @@ class ScheduleProjection { } static Color colorForItem(ScheduleItem item, Brightness brightness) { + if (item is CalendarScheduleItem) { + return _colorFromHex(item.colorHex) ?? + deterministicSourceColor(item.sourceId, brightness); + } if (item is TaskScheduleItem && item.completed) { return brightness == Brightness.dark ? const Color(0xff949494) @@ -130,6 +134,18 @@ class ScheduleProjection { } } +Color? _colorFromHex(String? value) { + if (value == null) { + return null; + } + final normalized = value.trim().replaceFirst('#', ''); + if (normalized.length != 6) { + return null; + } + final parsed = int.tryParse(normalized, radix: 16); + return parsed == null ? null : Color(0xff000000 | parsed); +} + String? _cleanLabel(String? label) { final value = label?.trim(); if (value == null || value.isEmpty) { diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index aeac0a1..82b0ae4 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -18,6 +18,70 @@ class ScheduleRepository { final AppDatabase _database; + Future findTaskTarget({ + required String accountId, + required String taskListId, + required String taskId, + }) async { + return watchTaskTarget( + accountId: accountId, + taskListId: taskListId, + taskId: taskId, + ).first; + } + + /// Watches one live, visible task identified by its full database key. + /// + /// A stream is used for deep links so a route can remain pending while an + /// initial sync inserts the requested task, without polling or retry loops. + Stream watchTaskTarget({ + required String accountId, + required String taskListId, + required String taskId, + }) { + final query = + _database.select(_database.tasks).join([ + innerJoin( + _database.taskLists, + _database.taskLists.accountId.equalsExp( + _database.tasks.accountId, + ) & + _database.taskLists.id.equalsExp(_database.tasks.taskListId), + ), + innerJoin( + _database.accounts, + _database.accounts.id.equalsExp(_database.tasks.accountId), + ), + ]) + ..where(_database.tasks.accountId.equals(accountId)) + ..where(_database.tasks.taskListId.equals(taskListId)) + ..where(_database.tasks.id.equals(taskId)) + ..where(_database.tasks.pendingDelete.equals(false)) + ..where(_database.tasks.serverMissing.equals(false)) + ..where( + _database.tasks.deleted.isNull() | + _database.tasks.deleted.equals(false), + ) + ..where( + _database.tasks.hidden.isNull() | + _database.tasks.hidden.equals(false), + ) + ..where(_database.taskLists.pendingDelete.equals(false)) + ..where(_database.taskLists.serverMissing.equals(false)) + ..where(_database.accounts.authState.equals('signed_in')); + return query.watchSingleOrNull().map((row) { + if (row == null) { + return null; + } + final task = row.readTable(_database.tasks); + return ScheduleTaskTarget( + accountId: task.accountId, + taskListId: task.taskListId, + taskId: task.id, + ); + }); + } + Future> listItems({ required ScheduleRange range, ScheduleFilters filters = const ScheduleFilters(), @@ -209,6 +273,9 @@ class ScheduleRepository { sourceName: source?.summary, accountDisplayName: accountDisplayNames[event.accountId], accountEmail: accountEmails[event.accountId], + capabilities: source != null && !source.readOnly && !source.isDeleted + ? ScheduleItemCapabilities.editable + : ScheduleItemCapabilities.readOnly, ), ); } @@ -224,7 +291,7 @@ class ScheduleRepository { Map accountDisplayNames, Map accountEmails, ) async { - if (filters.taskListFilterActive && filters.taskListIds.isEmpty) { + if (filters.taskListFilterActive && filters.taskListKeys.isEmpty) { return const []; } final query = @@ -253,7 +320,7 @@ class ScheduleRepository { _database.taskLists.serverMissing.equals(false), ); if (filters.taskListFilterActive) { - query.where(_database.tasks.taskListId.isIn(filters.taskListIds)); + query.where(_taskListFilter(filters.taskListKeys)); } if (!filters.showCompletedTasks) { query.where(_taskIncomplete()); @@ -294,7 +361,7 @@ class ScheduleRepository { required bool Function(TaskScheduleItem item) itemFilter, }) async { if (!filters.includeTasks || - (filters.taskListFilterActive && filters.taskListIds.isEmpty)) { + (filters.taskListFilterActive && filters.taskListKeys.isEmpty)) { return const ScheduleTaskBucketPage(items: [], hasMore: false); } @@ -332,7 +399,7 @@ class ScheduleRepository { ..where(databaseFilter) ..limit(effectiveLimit + 1); if (filters.taskListFilterActive) { - query.where(_database.tasks.taskListId.isIn(filters.taskListIds)); + query.where(_taskListFilter(filters.taskListKeys)); } if (!filters.showCompletedTasks) { query.where(_taskIncomplete()); @@ -415,6 +482,17 @@ class ScheduleRepository { _database.tasks.status.equals('completed').not(); } + Expression _taskListFilter(Set taskListKeys) { + Expression matches = const Constant(false); + for (final key in taskListKeys) { + matches = + matches | + (_database.tasks.accountId.equals(key.accountId) & + _database.tasks.taskListId.equals(key.taskListId)); + } + return matches; + } + Expression _taskNoDate() { return _database.tasks.dueUtc.isNull() & _database.tasks.microsoftStartDateTime.isNull() & @@ -437,6 +515,29 @@ class ScheduleRepository { } } +class ScheduleTaskTarget { + const ScheduleTaskTarget({ + required this.accountId, + required this.taskListId, + required this.taskId, + }); + + final String accountId; + final String taskListId; + final String taskId; + + @override + bool operator ==(Object other) { + return other is ScheduleTaskTarget && + other.accountId == accountId && + other.taskListId == taskListId && + other.taskId == taskId; + } + + @override + int get hashCode => Object.hash(accountId, taskListId, taskId); +} + class ScheduleTaskBucketPage { const ScheduleTaskBucketPage({required this.items, required this.hasMore}); diff --git a/lib/src/schedule/schedule_source_visibility.dart b/lib/src/schedule/schedule_source_visibility.dart index f3d0c1f..da6792d 100644 --- a/lib/src/schedule/schedule_source_visibility.dart +++ b/lib/src/schedule/schedule_source_visibility.dart @@ -1,11 +1,12 @@ import '../app/app_settings.dart'; import '../features/calendar/data/calendar_repository.dart'; import '../features/task_lists/data/task_lists_repository.dart'; +import 'schedule_filters.dart'; class ScheduleSourceVisibility { const ScheduleSourceVisibility({ required this.visibleCalendarSourceIds, - required this.visibleTaskListIds, + required this.visibleTaskListKeys, required this.hasCalendarSources, required this.hasTaskLists, }); @@ -20,11 +21,11 @@ class ScheduleSourceVisibility { for (final source in calendarSources) if (source.selected && !source.hidden && !source.isDeleted) source.id, }, - visibleTaskListIds: { + visibleTaskListKeys: { for (final list in taskLists) if (!list.pendingDelete && settings.isTaskListVisibleInSchedule(list.accountId, list.id)) - list.id, + ScheduleTaskListKey(accountId: list.accountId, taskListId: list.id), }, hasCalendarSources: calendarSources.any((source) => !source.isDeleted), hasTaskLists: taskLists.any((list) => !list.pendingDelete), @@ -32,7 +33,7 @@ class ScheduleSourceVisibility { } final Set visibleCalendarSourceIds; - final Set visibleTaskListIds; + final Set visibleTaskListKeys; final bool hasCalendarSources; final bool hasTaskLists; } diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 1e0ed75..3756fe6 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -53,6 +53,7 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) # System-level dependencies. find_package(PkgConfig REQUIRED) pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(HANDY REQUIRED IMPORTED_TARGET libhandy-1) # Application build; see runner/CMakeLists.txt. add_subdirectory("runner") diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index cf299bd..a246036 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,7 +6,6 @@ #include "generated_plugin_registrant.h" -#include #include #include #include @@ -17,9 +16,6 @@ #include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) desktop_multi_window_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopMultiWindowPlugin"); - desktop_multi_window_plugin_register_with_registrar(desktop_multi_window_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 99551c6..a8c1e40 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - desktop_multi_window file_selector_linux flutter_secure_storage_linux gtk diff --git a/linux/io.busystack.busymax.desktop b/linux/io.busystack.busymax.desktop index 01f89b8..7499fc6 100644 --- a/linux/io.busystack.busymax.desktop +++ b/linux/io.busystack.busymax.desktop @@ -2,6 +2,40 @@ Type=Application Name=BusyMax Comment=Calendar and task manager +Name[ar]=BusyMax +Comment[ar]=مدير التقويم والمهام +Name[de]=BusyMax +Comment[de]=Kalender- und Aufgabenverwaltung +Name[es]=BusyMax +Comment[es]=Gestor de calendarios y tareas +Name[et]=BusyMax +Comment[et]=Kalendri- ja ülesannete haldur +Name[fa]=BusyMax +Comment[fa]=مدیر تقویم و کارها +Name[fi]=BusyMax +Comment[fi]=Kalenteri- ja tehtäväsovellus +Name[fr]=BusyMax +Comment[fr]=Gestionnaire de calendriers et de tâches +Name[hi]=BusyMax +Comment[hi]=कैलेंडर और कार्य प्रबंधक +Name[it]=BusyMax +Comment[it]=Gestore di calendari e attività +Name[ja]=BusyMax +Comment[ja]=カレンダー・タスク管理アプリ +Name[ko]=BusyMax +Comment[ko]=캘린더와 할 일 관리 +Name[pt]=BusyMax +Comment[pt]=Gestor de calendário e tarefas +Name[ru]=BusyMax +Comment[ru]=Календарь и планировщик задач +Name[vi]=BusyMax +Comment[vi]=Trình quản lý lịch và công việc +Name[zh]=BusyMax +Comment[zh]=日历与任务管理工具 +Name[zh_Hans]=BusyMax +Comment[zh_Hans]=日历与任务管理工具 +Name[zh_Hant]=BusyMax +Comment[zh_Hant]=行事曆與待辦事項管理工具 Exec=busymax Icon=io.busystack.busymax Terminal=false diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml index 43dfa21..87ec999 100644 --- a/linux/io.busystack.busymax.metainfo.xml +++ b/linux/io.busystack.busymax.metainfo.xml @@ -4,9 +4,60 @@ CC0-1.0 Apache-2.0 BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax Calendar and task manager + مدير التقويم والمهام + Kalender- und Aufgabenverwaltung + Gestor de calendarios y tareas + Kalendri- ja ülesannete haldur + مدیر تقویم و کارها + Kalenteri- ja tehtäväsovellus + Gestionnaire de calendriers et de tâches + कैलेंडर और कार्य प्रबंधक + Gestore di calendari e attività + カレンダー・タスク管理アプリ + 캘린더와 할 일 관리 + Gestor de calendário e tarefas + Календарь и планировщик задач + Trình quản lý lịch và công việc + 日历与任务管理工具 + 日历与任务管理工具 + 行事曆與待辦事項管理工具

BusyMax is a Linux desktop calendar and task manager for planning events, tasks, reminders, and daily schedules in one native-feeling workspace.

+

BusyMax هو مدير تقويم ومهام لسطح مكتب Linux يتيح تخطيط الأحداث والمهام والتذكيرات والجداول اليومية في مساحة عمل واحدة.

+

BusyMax ist eine Kalender- und Aufgabenverwaltung für den Linux-Desktop, mit der sich Termine, Aufgaben, Erinnerungen und Tagespläne in einem Arbeitsbereich planen lassen.

+

BusyMax es un gestor de calendarios y tareas para el escritorio Linux que permite planificar eventos, tareas, recordatorios y agendas diarias en un único espacio de trabajo.

+

BusyMax on Linuxi töölaua kalendri- ja ülesannete haldur sündmuste, ülesannete, meeldetuletuste ja päevakavade planeerimiseks ühes tööruumis.

+

BusyMax مدیر تقویم و کارها برای میزکار لینوکس است که رویدادها، کارها، یادآورها و برنامه‌های روزانه را در یک فضای کاری گرد هم می‌آورد.

+

BusyMax on Linux-työpöydän kalenteri- ja tehtäväsovellus, jolla voi suunnitella tapahtumia, tehtäviä, muistutuksia ja päiväohjelmia yhdessä työtilassa.

+

BusyMax est un gestionnaire de calendriers et de tâches pour le bureau Linux qui réunit événements, tâches, rappels et emplois du temps quotidiens dans un même espace de travail.

+

BusyMax Linux डेस्कटॉप के लिए एक कैलेंडर और कार्य प्रबंधक है, जिसमें घटनाओं, कार्यों, रिमाइंडर और दैनिक कार्यक्रमों की योजना एक ही कार्यक्षेत्र में बनाई जा सकती है।

+

BusyMax è un gestore di calendari e attività per desktop Linux che riunisce eventi, attività, promemoria e programmi giornalieri in un unico spazio di lavoro.

+

BusyMax は、予定、タスク、リマインダー、毎日のスケジュールを一つのワークスペースで管理できる Linux デスクトップ向けのカレンダー・タスク管理アプリです。

+

BusyMax는 일정, 할 일, 미리 알림, 일일 계획을 하나의 작업 공간에서 관리할 수 있는 Linux 데스크톱용 캘린더 및 할 일 관리 앱입니다.

+

BusyMax é um gestor de calendário e tarefas para o ambiente de trabalho Linux que reúne eventos, tarefas, lembretes e agendas diárias num único espaço de trabalho.

+

BusyMax — календарь и планировщик задач для Linux. В одном приложении можно управлять событиями, задачами, напоминаниями и распорядком дня.

+

BusyMax là trình quản lý lịch và công việc cho máy tính Linux, giúp lập kế hoạch sự kiện, công việc, lời nhắc và lịch trình hằng ngày trong một không gian làm việc.

+

BusyMax 是一款面向 Linux 桌面的日历与任务管理工具,可在一个工作区中规划日程、任务、提醒和每日安排。

+

BusyMax 是一款面向 Linux 桌面的日历与任务管理工具,可在一个工作区中规划日程、任务、提醒和每日安排。

+

BusyMax 是一款適用於 Linux 桌面的行事曆與待辦事項管理工具,可在同一個工作區中規劃活動、待辦事項、提醒與每日行程。

It supports Google Calendar, Google Tasks, Microsoft Calendar, and Microsoft To Do.

This beta release is intended for early Ubuntu App Center and Snap Store testing.

@@ -35,7 +86,7 @@ - +

Beta maintenance release.

diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt index e97dabc..ccbb420 100644 --- a/linux/runner/CMakeLists.txt +++ b/linux/runner/CMakeLists.txt @@ -22,5 +22,6 @@ add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") # Add dependency libraries. Add any application-specific dependencies here. target_link_libraries(${BINARY_NAME} PRIVATE flutter) target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::HANDY) target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 4ffac61..6281a43 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,22 +1,20 @@ #include "my_application.h" #include -#include #include +#include #include +#include #include #include #include -#ifdef GDK_WINDOWING_X11 -#include -#endif - #include "flutter/generated_plugin_registrant.h" -#include "desktop_multi_window/desktop_multi_window_plugin.h" constexpr char kApplicationDisplayName[] = "BusyMax"; constexpr char kNativeDateTimePickerChannel[] = "busymax/native_date_time_picker"; +constexpr char kNativeDialogChannel[] = "busymax/native_dialogs"; +constexpr char kNativeMenuChannel[] = "busymax/native_menus"; constexpr char kWindowChannel[] = "io.busystack.busymax/window"; constexpr char kHeaderBarChannel[] = "io.busystack.busymax/headerbar"; constexpr char kGtkSettingsChannel[] = "io.busystack.busymax/gtk_settings"; @@ -24,45 +22,78 @@ constexpr char kGtkFontSettingsEventChannel[] = "io.busystack.busymax/gtk_font_settings"; constexpr char kGtkThemeColorsEventChannel[] = "io.busystack.busymax/gtk_theme_colors"; -constexpr char kCompactAgendaWindowChannel[] = - "io.busystack.busymax/compact_agenda_window"; +constexpr gint64 kHeaderBarStateSchemaVersion = 3; constexpr gint kHeaderButtonHeight = 34; -constexpr gint kHeaderButtonRadius = 8; -constexpr gint kHeaderButtonHorizontalPadding = 8; constexpr gint kHeaderButtonSpacing = 6; -constexpr gint kHeaderWindowControlsBalanceWidth = - kHeaderButtonHeight * 3 + kHeaderButtonSpacing * 2; +constexpr gint kHeaderCenterMaximumWidthChars = 48; constexpr gint kHeaderOnboardingContentWidth = 480; constexpr gint kHeaderOnboardingSideWidth = 120; -constexpr gint kHeaderMenuPadding = kHeaderButtonSpacing; -constexpr gint kHeaderPopoverRowSpacing = 4; +// Current libadwaita applies filter: opacity(0.5) to headerbar window-handle +// content in the backdrop state. GTK 3 cannot apply that filter here without +// also disturbing BusyMax's independently resolved header surfaces, so carry +// the same native metric through the semantic foreground instead. +constexpr gdouble kHeaderBackdropForegroundOpacity = 0.50; +constexpr gdouble kHeaderDisabledForegroundOpacity = 0.38; +constexpr gdouble kHeaderDisabledBackdropForegroundOpacity = + kHeaderDisabledForegroundOpacity * kHeaderBackdropForegroundOpacity; constexpr gint kHeaderSidebarContentInset = kHeaderButtonSpacing; constexpr gint kHeaderMainContentStartInset = kHeaderSidebarContentInset; -constexpr gint kHeaderTooltipVerticalPadding = 5; -constexpr gint kHeaderTooltipHorizontalPadding = 8; -constexpr gint kHeaderWindowRadius = 8; -constexpr gint kCompactAgendaPanelWidth = 420; -constexpr gint kCompactAgendaPanelHeight = 680; -constexpr gint kCompactAgendaWindowShadowMargin = 32; -constexpr gint kCompactAgendaWindowWidth = - kCompactAgendaPanelWidth + kCompactAgendaWindowShadowMargin * 2; -constexpr gint kCompactAgendaWindowHeight = - kCompactAgendaPanelHeight + kCompactAgendaWindowShadowMargin * 2; -constexpr gint kCompactAgendaWindowMinWidth = - 360 + kCompactAgendaWindowShadowMargin * 2; -constexpr gint kCompactAgendaWindowMinHeight = - 520 + kCompactAgendaWindowShadowMargin * 2; -constexpr gint kCompactAgendaWindowMaxWidth = - 480 + kCompactAgendaWindowShadowMargin * 2; -constexpr gint kCompactAgendaWindowMaxHeight = - 840 + kCompactAgendaWindowShadowMargin * 2; -constexpr char kDefaultHeaderBarBackgroundColor[] = "#1D1D20"; -constexpr char kDefaultHeaderBarSidebarBackgroundColor[] = "#2E2E32"; +constexpr gint kMainWindowDefaultWidth = 1280; +constexpr gint kMainWindowDefaultHeight = 720; +constexpr char kDefaultWindowBackgroundColor[] = "#2C2C2C"; +constexpr char kDefaultHeaderBarBackgroundColor[] = "#272727"; +constexpr char kDefaultHeaderBarSidebarBackgroundColor[] = "#393939"; +constexpr char kDefaultHeaderBarSidebarBorderColor[] = + "rgba(16,16,16,0.35)"; +constexpr char kDefaultDialogOutlineColor[] = "rgba(255,255,255,0.07)"; +constexpr char kDefaultModalBarrierColor[] = "rgba(0,0,0,0.25)"; +constexpr char kDefaultTooltipBackground[] = "rgba(0,0,0,0.8)"; +constexpr char kDefaultTooltipForeground[] = "#FFFFFF"; +constexpr char kDefaultTooltipBorder[] = "rgba(255,255,255,0.1)"; +constexpr gdouble kDefaultTooltipRadius = 8.0; +constexpr gdouble kDefaultTooltipFontSize = 14.0; +constexpr gdouble kDefaultTooltipHorizontalPadding = 10.0; +constexpr gdouble kDefaultTooltipVerticalPadding = 6.0; +constexpr gdouble kDefaultTooltipMinimumHeight = 30.0; +constexpr gdouble kTooltipBorderWidth = 1.0; +// GtkTooltipWindow applies a private GtkContainer border-width of 6 px around +// its content. Compensate for it so native header hints have the same visible +// border-to-text padding as Flutter tooltips. +constexpr gdouble kGtkTooltipContainerInset = 6.0; +constexpr char kHeaderControlStyleClass[] = "busymax-header-control"; +constexpr char kHeaderOnboardingTextButtonStyleClass[] = + "busymax-onboarding-text-button"; +constexpr char kMenuAccelAttribute[] = "accel"; +constexpr char kLtrIsolateStart[] = "\xE2\x81\xA6"; +constexpr char kBidiIsolateEnd[] = "\xE2\x81\xA9"; +constexpr char kHeaderSearchEntryStyleClass[] = + "busymax-header-search-entry"; +constexpr char kHeaderModalOpenStyleClass[] = "busymax-modal-open"; +constexpr char kHeaderModalBarrierStyleClass[] = "busymax-modal-barrier"; +constexpr char kHeaderApplicationActiveStyleClass[] = + "busymax-focus-active"; +constexpr char kHeaderApplicationBackdropStyleClass[] = + "busymax-focus-backdrop"; +constexpr char kNativeDialogStyleClass[] = "busymax-native-dialog"; +// Mirrors Yaru's shared window/dialog radius used by the Flutter fallback. +constexpr gint kNativeDialogCornerRadius = 14; +constexpr char kNativeTimeZoneDialogStyleClass[] = + "busymax-time-zone-dialog"; +constexpr char kNativeTimeZoneResultsStyleClass[] = + "busymax-time-zone-results"; +constexpr char kNativeTimeZoneGroupStyleClass[] = + "busymax-time-zone-group"; +constexpr char kNativeTimeZoneRowStyleClass[] = "busymax-time-zone-row"; +constexpr gint kNativeTimeZoneDialogWidth = 520; +constexpr gint kNativeTimeZoneDialogContentHeight = 420; +constexpr size_t kNativeTimeZoneResultLimit = 250; struct _MyApplication { GtkApplication parent_instance; char** dart_entrypoint_arguments; FlMethodChannel* native_date_time_picker_channel; + FlMethodChannel* native_dialog_channel; + FlMethodChannel* native_menu_channel; FlMethodChannel* window_channel; FlMethodChannel* header_bar_channel; FlMethodChannel* gtk_settings_channel; @@ -77,28 +108,37 @@ struct _MyApplication { gchar* header_bar_window_background_color; gchar* header_bar_background_color; gchar* header_bar_sidebar_background_color; + gchar* header_bar_sidebar_border_color; gchar* header_bar_foreground_color; - gchar* header_bar_muted_foreground_color; - gchar* header_bar_disabled_foreground_color; - gchar* header_bar_control_color; - gchar* header_bar_control_hover_color; - gchar* header_bar_control_active_color; - gchar* header_bar_accent_color; - gchar* header_bar_accent_foreground_color; - gchar* header_bar_popover_background_color; - gchar* header_bar_border_color; - gchar* header_bar_shade_color; + gchar* header_bar_dialog_background_color; + gchar* header_bar_dialog_outline_color; gchar* header_bar_modal_barrier_color; + gchar* header_bar_tooltip_background_color; + gchar* header_bar_tooltip_foreground_color; + gchar* header_bar_tooltip_border_color; + gdouble header_bar_tooltip_radius; + gdouble header_bar_tooltip_font_size; + gdouble header_bar_tooltip_horizontal_padding; + gdouble header_bar_tooltip_vertical_padding; + gdouble header_bar_tooltip_minimum_height; + gboolean header_bar_high_contrast; gint header_bar_sidebar_width; + gboolean header_bar_can_show_sidebar; gboolean header_bar_sidebar_visible; gboolean header_bar_modal_barrier_visible; + gint header_bar_modal_barrier_shade_depth; + gboolean header_bar_theme_received; GtkWindow* main_window; + GtkWindow* header_focus_transient_window; GtkWidget* flutter_view; + GtkWidget* titlebar_handle; + GtkWidget* titlebar_overlay; + GtkWidget* titlebar_modal_barrier; GtkWidget* titlebar_box; GtkHeaderBar* header_bar; GtkWidget* header_start_box; - GtkWidget* header_title_balance_spacer; GtkWidget* header_title_box; + GtkWidget* header_title_stack; GtkWidget* onboarding_back_slot; GtkWidget* onboarding_back_button; GtkWidget* onboarding_continue_slot; @@ -107,39 +147,122 @@ struct _MyApplication { GtkWidget* header_brand_label; GtkWidget* settings_menu_button; GtkWidget* settings_menu; - GtkWidget* settings_item; - GtkWidget* keyboard_shortcuts_item; - GtkWidget* about_item; GtkWidget* header_view_box; GtkWidget* header_title_label; + GtkWidget* search_entry; GtkWidget* back_button; GtkWidget* sidebar_collapsed_toggle_button; GtkWidget* today_button; GtkWidget* previous_button; GtkWidget* next_button; GtkWidget* view_mode_button; - GtkWidget* view_mode_label; + GtkWidget* view_mode_icon; GtkWidget* view_mode_menu; - GtkWidget* view_mode_day_item; - GtkWidget* view_mode_week_item; - GtkWidget* view_mode_month_item; - GtkWidget* view_mode_year_item; - GtkWidget* view_mode_agenda_item; GtkWidget* search_button; GtkWidget* create_button; + GtkWidget* create_menu; GtkWidget* refresh_button; + GSimpleActionGroup* header_menu_action_group; + GSimpleAction* header_view_mode_menu_action; + GSimpleAction* header_create_event_action; + GSimpleAction* header_create_task_action; gchar* header_view_mode; + gchar* header_title_text; + gchar* header_day_label; + gchar* header_week_label; + gchar* header_month_label; + gchar* header_year_label; + gchar* header_agenda_label; + gchar* header_show_sidebar_panel_label; + gchar* header_hide_sidebar_panel_label; + gchar* header_create_event_label; + gchar* header_create_task_label; + gchar* header_settings_label; + gchar* header_keyboard_shortcuts_label; + gchar* header_report_issue_label; + gchar* header_about_label; + gchar* header_day_shortcut; + gchar* header_week_shortcut; + gchar* header_month_shortcut; + gchar* header_year_shortcut; + gchar* header_agenda_shortcut; + gchar* header_sidebar_shortcut; + gchar* header_create_event_shortcut; + gchar* header_create_task_shortcut; + gchar* header_settings_shortcut; + gchar* header_keyboard_shortcuts_shortcut; + gchar* header_search_query; gboolean hide_on_close; gboolean suppress_header_bar_actions; + gboolean header_search_active; gboolean header_schedule_controls_visible; gboolean header_navigation_visible; gboolean header_back_visible; gboolean header_onboarding_controls_visible; - gboolean main_window_transparent_backing; + gint header_onboarding_content_width; }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) +static void schedule_header_bar_focus_state_refresh(MyApplication* self); +static void update_header_control_visibility(MyApplication* self); + +static gchar* gtk_accelerator_from_shortcut_label(const gchar* shortcut) { + if (shortcut == nullptr || shortcut[0] == '\0') { + return nullptr; + } + + guint key = 0; + GdkModifierType modifiers = static_cast(0); + gtk_accelerator_parse(shortcut, &key, &modifiers); + if (key != 0) { + return g_strdup(shortcut); + } + + gchar** parts = g_strsplit(shortcut, "+", -1); + const gsize part_count = g_strv_length(parts); + GString* accelerator = g_string_new(nullptr); + gboolean valid = part_count > 0; + for (gsize index = 0; valid && index + 1 < part_count; index++) { + const gchar* part = g_strstrip(parts[index]); + if (g_strcmp0(part, "Ctrl") == 0 || + g_strcmp0(part, "Control") == 0) { + g_string_append(accelerator, ""); + } else if (g_strcmp0(part, "Alt") == 0) { + g_string_append(accelerator, ""); + } else if (g_strcmp0(part, "Shift") == 0) { + g_string_append(accelerator, ""); + } else if (g_strcmp0(part, "Super") == 0) { + g_string_append(accelerator, ""); + } else if (g_strcmp0(part, "Meta") == 0) { + g_string_append(accelerator, ""); + } else { + valid = FALSE; + } + } + if (valid) { + const gchar* key_label = g_strstrip(parts[part_count - 1]); + g_string_append(accelerator, + g_strcmp0(key_label, "Esc") == 0 ? "Escape" : key_label); + key = 0; + modifiers = static_cast(0); + gtk_accelerator_parse(accelerator->str, &key, &modifiers); + valid = key != 0; + } + + g_strfreev(parts); + return g_string_free(accelerator, !valid); +} + +static void set_menu_item_accelerator(GMenuItem* item, + const gchar* shortcut) { + g_autofree gchar* accelerator = + gtk_accelerator_from_shortcut_label(shortcut); + if (accelerator != nullptr) { + g_menu_item_set_attribute(item, kMenuAccelAttribute, "s", accelerator); + } +} + static GdkPixbuf* load_application_icon_at_size(gint size) { g_autofree gchar* executable_path = g_file_read_link("/proc/self/exe", nullptr); @@ -166,150 +289,17 @@ static GdkPixbuf* load_application_icon() { return load_application_icon_at_size(256); } -static gboolean gtk_theme_exists_in_data_dir(const gchar* data_dir, - const gchar* theme_name) { - if (data_dir == nullptr || theme_name == nullptr || theme_name[0] == '\0') { - return FALSE; - } - g_autofree gchar* css_path = - g_build_filename(data_dir, "themes", theme_name, "gtk-3.0", "gtk.css", - nullptr); - return g_file_test(css_path, G_FILE_TEST_IS_REGULAR); -} - -static gboolean gtk_theme_exists(const gchar* theme_name) { - if (gtk_theme_exists_in_data_dir(g_get_user_data_dir(), theme_name)) { - return TRUE; - } - const gchar* const* data_dirs = g_get_system_data_dirs(); - for (gint i = 0; data_dirs != nullptr && data_dirs[i] != nullptr; ++i) { - if (gtk_theme_exists_in_data_dir(data_dirs[i], theme_name)) { - return TRUE; - } - } - return FALSE; -} - -static gboolean icon_theme_exists_in_data_dir(const gchar* data_dir, - const gchar* theme_name) { - if (data_dir == nullptr || theme_name == nullptr || theme_name[0] == '\0') { - return FALSE; - } - g_autofree gchar* index_path = - g_build_filename(data_dir, "icons", theme_name, "index.theme", nullptr); - return g_file_test(index_path, G_FILE_TEST_IS_REGULAR); -} - -static gboolean icon_theme_exists(const gchar* theme_name) { - if (icon_theme_exists_in_data_dir(g_get_user_data_dir(), theme_name)) { - return TRUE; - } - const gchar* const* data_dirs = g_get_system_data_dirs(); - for (gint i = 0; data_dirs != nullptr && data_dirs[i] != nullptr; ++i) { - if (icon_theme_exists_in_data_dir(data_dirs[i], theme_name)) { - return TRUE; - } - } - return FALSE; -} - -static gboolean is_yaru_theme_name(const gchar* theme_name) { - return theme_name != nullptr && g_str_has_prefix(theme_name, "Yaru") && - (theme_name[4] == '\0' || theme_name[4] == '-'); -} - -static gchar* yaru_theme_name_for_preference( - const gchar* theme_name, - gboolean prefer_dark, - gboolean (*theme_exists)(const gchar*)) { - if (!is_yaru_theme_name(theme_name)) { - return nullptr; - } - const gboolean is_dark = g_str_has_suffix(theme_name, "-dark"); - if (prefer_dark == is_dark) { - return theme_exists(theme_name) ? g_strdup(theme_name) : nullptr; - } - if (prefer_dark) { - g_autofree gchar* dark_name = g_strdup_printf("%s-dark", theme_name); - return theme_exists(dark_name) ? g_strdup(dark_name) : nullptr; - } - - const gsize suffix_length = strlen("-dark"); - const gsize theme_length = strlen(theme_name); - if (theme_length <= suffix_length) { - return nullptr; - } - g_autofree gchar* light_name = - g_strndup(theme_name, theme_length - suffix_length); - return theme_exists(light_name) ? g_strdup(light_name) : nullptr; -} - -static gchar* available_gtk_theme_for_preference(const gchar* current_theme, - gboolean prefer_dark) { - g_autofree gchar* current_variant = - yaru_theme_name_for_preference(current_theme, prefer_dark, - gtk_theme_exists); - if (current_variant != nullptr) { - return g_strdup(current_variant); - } - - const gchar* primary = prefer_dark ? "Yaru-dark" : "Yaru"; - if (gtk_theme_exists(primary)) { - return g_strdup(primary); - } - const gchar* secondary = prefer_dark ? "Adwaita-dark" : "Adwaita"; - return gtk_theme_exists(secondary) ? g_strdup(secondary) : nullptr; -} - -static gchar* available_icon_theme_for_preference(const gchar* current_theme, - gboolean prefer_dark) { - g_autofree gchar* current_variant = - yaru_theme_name_for_preference(current_theme, prefer_dark, - icon_theme_exists); - if (current_variant != nullptr) { - return g_strdup(current_variant); - } - - const gchar* primary = prefer_dark ? "Yaru-dark" : "Yaru"; - if (icon_theme_exists(primary)) { - return g_strdup(primary); - } - return icon_theme_exists("Adwaita") ? g_strdup("Adwaita") : nullptr; -} - static void set_gtk_theme_preference(gboolean prefer_dark) { GtkSettings* settings = gtk_settings_get_default(); if (settings == nullptr) { return; } + // Express the app preference through GTK. Do not replace the user's GTK or + // icon theme: GTK remains responsible for resolving the installed theme's + // light/dark presentation and its native assets. g_object_set(settings, "gtk-application-prefer-dark-theme", prefer_dark, nullptr); - - g_autofree gchar* theme_name = nullptr; - g_object_get(settings, "gtk-theme-name", &theme_name, nullptr); - g_autofree gchar* theme_for_preference = - available_gtk_theme_for_preference(theme_name, prefer_dark); - if (theme_for_preference != nullptr) { - if (g_strcmp0(theme_name, theme_for_preference) != 0) { - g_object_set(settings, "gtk-theme-name", theme_for_preference, nullptr); - } - } - - g_autofree gchar* icon_theme_name = nullptr; - g_object_get(settings, "gtk-icon-theme-name", &icon_theme_name, nullptr); - g_autofree gchar* icon_theme_for_preference = - available_icon_theme_for_preference(icon_theme_name, prefer_dark); - if (icon_theme_for_preference != nullptr) { - if (g_strcmp0(icon_theme_name, icon_theme_for_preference) != 0) { - g_object_set(settings, "gtk-icon-theme-name", icon_theme_for_preference, - nullptr); - GtkIconTheme* icon_theme = gtk_icon_theme_get_default(); - if (icon_theme != nullptr) { - gtk_icon_theme_set_custom_theme(icon_theme, icon_theme_for_preference); - } - } - } } static const gchar* fl_lookup_string_arg(FlValue* args, const gchar* key) { @@ -350,6 +340,63 @@ static gboolean fl_lookup_optional_bool_arg(FlValue* args, return TRUE; } +static gboolean fl_lookup_int_arg(FlValue* args, + const gchar* key, + gint64* value_out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr || fl_value_get_type(value) != FL_VALUE_TYPE_INT) { + return FALSE; + } + *value_out = fl_value_get_int(value); + return TRUE; +} + +static gboolean fl_lookup_double_arg(FlValue* args, + const gchar* key, + gdouble* value_out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr) { + return FALSE; + } + if (fl_value_get_type(value) == FL_VALUE_TYPE_FLOAT) { + *value_out = fl_value_get_float(value); + return TRUE; + } + if (fl_value_get_type(value) == FL_VALUE_TYPE_INT) { + *value_out = static_cast(fl_value_get_int(value)); + return TRUE; + } + return FALSE; +} + +static void update_bounded_double_arg(FlValue* args, + const gchar* key, + gdouble minimum, + gdouble maximum, + gdouble* target) { + gdouble value = 0; + if (fl_lookup_double_arg(args, key, &value) && value >= minimum && + value <= maximum) { + *target = value; + } +} + +static FlValue* fl_lookup_map_arg(FlValue* args, const gchar* key) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return nullptr; + } + FlValue* value = fl_value_lookup_string(args, key); + return value != nullptr && fl_value_get_type(value) == FL_VALUE_TYPE_MAP + ? value + : nullptr; +} + static gboolean parse_date(const gchar* value, guint* year, guint* month, @@ -373,12 +420,39 @@ static gboolean parse_date(const gchar* value, return TRUE; } +static gboolean parse_time(const gchar* value, guint* hour, guint* minute) { + if (value == nullptr) { + return FALSE; + } + unsigned int parsed_hour = 0; + unsigned int parsed_minute = 0; + if (sscanf(value, "%u:%u", &parsed_hour, &parsed_minute) != 2) { + return FALSE; + } + if (parsed_hour > 23 || parsed_minute > 59) { + return FALSE; + } + *hour = parsed_hour; + *minute = parsed_minute; + return TRUE; +} + static void respond_string(FlMethodCall* method_call, const gchar* value) { g_autoptr(FlValue) result = value == nullptr ? fl_value_new_null() : fl_value_new_string(value); fl_method_call_respond_success(method_call, result, nullptr); } +static void style_native_dialog(GtkWidget* dialog) { + gtk_style_context_add_class(gtk_widget_get_style_context(dialog), + kNativeDialogStyleClass); + if (GTK_IS_DIALOG(dialog)) { + GtkStyleContext* content_context = + gtk_widget_get_style_context(gtk_dialog_get_content_area(GTK_DIALOG(dialog))); + gtk_style_context_add_class(content_context, "busymax-native-dialog-content"); + } +} + static void handle_pick_date(FlMethodCall* method_call, FlValue* args, GtkWindow* parent) { @@ -387,9 +461,17 @@ static void handle_pick_date(FlMethodCall* method_call, const gchar* cancel_label = fl_lookup_string_arg(args, "cancelLabel"); const gchar* ok_label = fl_lookup_string_arg(args, "okLabel"); GtkWidget* dialog = gtk_dialog_new_with_buttons( - title != nullptr ? title : "Date", parent, GTK_DIALOG_MODAL, - cancel_label != nullptr ? cancel_label : "_Cancel", GTK_RESPONSE_CANCEL, - ok_label != nullptr ? ok_label : "_OK", GTK_RESPONSE_OK, nullptr); + title != nullptr ? title : "Date", parent, + static_cast(GTK_DIALOG_MODAL | + GTK_DIALOG_DESTROY_WITH_PARENT | + GTK_DIALOG_USE_HEADER_BAR), + cancel_label != nullptr && cancel_label[0] != '\0' ? cancel_label : "Cancel", + GTK_RESPONSE_CANCEL, + ok_label != nullptr && ok_label[0] != '\0' ? ok_label : "OK", + GTK_RESPONSE_OK, + nullptr); + style_native_dialog(dialog); + gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_OK); gtk_window_set_resizable(GTK_WINDOW(dialog), FALSE); GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); @@ -420,6 +502,82 @@ static void handle_pick_date(FlMethodCall* method_call, gtk_widget_destroy(dialog); } +static void handle_pick_time(FlMethodCall* method_call, + FlValue* args, + GtkWindow* parent) { + const gchar* title = fl_lookup_string_arg(args, "title"); + const gchar* initial_time = fl_lookup_string_arg(args, "initialTime"); + const gchar* cancel_label = fl_lookup_string_arg(args, "cancelLabel"); + const gchar* ok_label = fl_lookup_string_arg(args, "okLabel"); + + GtkWidget* dialog = gtk_dialog_new_with_buttons( + title != nullptr ? title : "Time", parent, + static_cast(GTK_DIALOG_MODAL | + GTK_DIALOG_DESTROY_WITH_PARENT | + GTK_DIALOG_USE_HEADER_BAR), + cancel_label != nullptr && cancel_label[0] != '\0' ? cancel_label : "Cancel", + GTK_RESPONSE_CANCEL, + ok_label != nullptr && ok_label[0] != '\0' ? ok_label : "OK", + GTK_RESPONSE_OK, + nullptr); + style_native_dialog(dialog); + gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_OK); + gtk_window_set_resizable(GTK_WINDOW(dialog), FALSE); + + GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_container_set_border_width(GTK_CONTAINER(content), 12); + gtk_container_add(GTK_CONTAINER(content), row); + + GtkWidget* hour_input = gtk_spin_button_new_with_range(0.0, 23.0, 1.0); + GtkWidget* minute_input = gtk_spin_button_new_with_range(0.0, 59.0, 1.0); + gtk_spin_button_set_numeric(GTK_SPIN_BUTTON(hour_input), TRUE); + gtk_spin_button_set_numeric(GTK_SPIN_BUTTON(minute_input), TRUE); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(hour_input), 0.0); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(minute_input), 0.0); + gtk_widget_set_size_request(hour_input, 70, -1); + gtk_widget_set_size_request(minute_input, 70, -1); + + gtk_container_add(GTK_CONTAINER(row), gtk_label_new("Hour")); + gtk_container_add(GTK_CONTAINER(row), hour_input); + gtk_container_add(GTK_CONTAINER(row), gtk_label_new(":")); + gtk_container_add(GTK_CONTAINER(row), minute_input); + gtk_container_add(GTK_CONTAINER(row), gtk_label_new("Min")); + + guint hour = 0; + guint minute = 0; + if (parse_time(initial_time, &hour, &minute)) { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(hour_input), hour); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(minute_input), minute); + } else { + GDateTime* now = g_date_time_new_now_local(); + if (now != nullptr) { + hour = g_date_time_get_hour(now); + minute = g_date_time_get_minute(now); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(hour_input), hour); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(minute_input), minute); + g_date_time_unref(now); + } + } + + gtk_widget_show_all(dialog); + const gint response = gtk_dialog_run(GTK_DIALOG(dialog)); + + if (response == GTK_RESPONSE_OK) { + const gint selected_hour = gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(hour_input)); + const gint selected_minute = gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(minute_input)); + g_autofree gchar* result = g_strdup_printf( + "%02d:%02d", selected_hour, selected_minute); + respond_string(method_call, result); + } else { + respond_string(method_call, nullptr); + } + + gtk_widget_destroy(dialog); +} + static void native_date_time_picker_method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { @@ -428,6 +586,8 @@ static void native_date_time_picker_method_call_cb(FlMethodChannel* channel, FlValue* args = fl_method_call_get_args(method_call); if (strcmp(method, "pickDate") == 0) { handle_pick_date(method_call, args, parent); + } else if (strcmp(method, "pickTime") == 0) { + handle_pick_time(method_call, args, parent); } else { fl_method_call_respond_not_implemented(method_call, nullptr); } @@ -453,142 +613,1432 @@ static void register_native_date_time_picker(MyApplication* self, create_native_date_time_picker_channel(view, window); } -static void register_native_date_time_picker_for_subwindow(FlView* view, - GtkWindow* window) { - FlMethodChannel* channel = create_native_date_time_picker_channel(view, window); - g_object_set_data_full(G_OBJECT(window), "busymax-native-date-time-picker", - channel, g_object_unref); -} - static void respond_bool(FlMethodCall* method_call, gboolean value) { g_autoptr(FlValue) result = fl_value_new_bool(value); fl_method_call_respond_success(method_call, result, nullptr); } -static void respond_success(FlMethodCall* method_call) { - g_autoptr(FlValue) result = fl_value_new_null(); - fl_method_call_respond_success(method_call, result, nullptr); -} +struct NativeTimeZoneOption { + gchar* id; + gchar* region; + gchar* normalized_name; + gchar* normalized_english_name; + gchar* title; + gchar* subtitle; + gchar* search_text; + gint match_rank; + gint region_match_rank; +}; -static gboolean fl_method_bool_arg(FlValue* args) { - return args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_BOOL - ? fl_value_get_bool(args) - : FALSE; -} +struct NativeGroupedListStyle { + const gchar* surface_color; + const gchar* divider_color; + const gchar* section_header_color; + const gchar* primary_text_color; + const gchar* secondary_text_color; + const gchar* hover_color; + const gchar* shadow_color; + const gchar* outline_color; + gboolean high_contrast; + gint radius; + gint section_top_spacing; + gint section_horizontal_padding; + gint title_bottom_spacing; +}; -static gdouble fl_method_double_arg(FlValue* args, gdouble fallback) { - if (args == nullptr) { - return fallback; - } - switch (fl_value_get_type(args)) { - case FL_VALUE_TYPE_FLOAT: - return fl_value_get_float(args); - case FL_VALUE_TYPE_INT: - return static_cast(fl_value_get_int(args)); - default: - return fallback; +struct NativeTimeZoneDialogState { + MyApplication* application; + GtkWidget* window; + GtkWidget* results; + GPtrArray* options; + const gchar* selected_time_zone; + const gchar* no_results_label; + GMainLoop* loop; + gchar* result; +}; + +static void native_time_zone_option_free(gpointer data) { + auto* option = static_cast(data); + if (option == nullptr) { + return; } + g_free(option->id); + g_free(option->region); + g_free(option->normalized_name); + g_free(option->normalized_english_name); + g_free(option->title); + g_free(option->subtitle); + g_free(option->search_text); + g_free(option); } -static const gchar* fl_method_string_arg(FlValue* args) { - return args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(args) - : nullptr; +static void clear_native_time_zone_results(GtkWidget* results) { + GList* children = gtk_container_get_children(GTK_CONTAINER(results)); + for (GList* child = children; child != nullptr; child = child->next) { + gtk_widget_destroy(GTK_WIDGET(child->data)); + } + g_list_free(children); } -static void track_header_bar_pointer(MyApplication* self, - GtkHeaderBar* header_bar) { - self->header_bar = header_bar; - g_object_add_weak_pointer(G_OBJECT(header_bar), - reinterpret_cast(&self->header_bar)); +static gint native_time_zone_option_match_rank( + const NativeTimeZoneOption* option, + const gchar* normalized_query) { + if (g_strcmp0(option->normalized_name, normalized_query) == 0 || + g_strcmp0(option->normalized_english_name, normalized_query) == 0) { + return 0; + } + if (g_str_has_prefix(option->normalized_name, normalized_query) || + g_str_has_prefix(option->normalized_english_name, normalized_query)) { + return 1; + } + if (strstr(option->normalized_name, normalized_query) != nullptr || + strstr(option->normalized_english_name, normalized_query) != nullptr) { + return 2; + } + return strstr(option->search_text, normalized_query) != nullptr ? 3 : -1; } -static void track_widget_pointer(GtkWidget** target, GtkWidget* widget) { - *target = widget; - g_object_add_weak_pointer(G_OBJECT(widget), - reinterpret_cast(target)); +static gint compare_native_time_zone_options(gconstpointer first, + gconstpointer second, + gpointer) { + const auto* first_option = + *static_cast(first); + const auto* second_option = + *static_cast(second); + if (first_option->region_match_rank != second_option->region_match_rank) { + return first_option->region_match_rank - + second_option->region_match_rank; + } + const gint region_order = + g_strcmp0(first_option->region, second_option->region); + if (region_order != 0) { + return region_order; + } + if (first_option->match_rank != second_option->match_rank) { + return first_option->match_rank - second_option->match_rank; + } + const gint name_order = + g_strcmp0(first_option->normalized_name, + second_option->normalized_name); + return name_order != 0 + ? name_order + : g_strcmp0(first_option->id, second_option->id); } -static gboolean has_header_bar(MyApplication* self) { - return self->header_bar != nullptr && GTK_IS_HEADER_BAR(self->header_bar); +static void native_time_zone_row_activated_cb(HdyActionRow* row, + gpointer user_data) { + auto* state = static_cast(user_data); + const gchar* id = static_cast( + g_object_get_data(G_OBJECT(row), "busymax-time-zone-id")); + if (id == nullptr) { + return; + } + g_free(state->result); + state->result = g_strdup(id); + if (g_main_loop_is_running(state->loop)) { + g_main_loop_quit(state->loop); + } } -static gboolean is_css_hex_color(const gchar* value) { - if (value == nullptr || strlen(value) != 7 || value[0] != '#') { - return FALSE; - } - for (int i = 1; i < 7; i++) { - if (!g_ascii_isxdigit(value[i])) { - return FALSE; - } +static gboolean native_time_zone_window_delete_event_cb( + GtkWidget*, + GdkEvent*, + gpointer user_data) { + auto* state = static_cast(user_data); + if (g_main_loop_is_running(state->loop)) { + g_main_loop_quit(state->loop); } return TRUE; } -static gboolean is_css_rgba_color(const gchar* value) { - if (value == nullptr || !g_str_has_prefix(value, "rgba(")) { +static gboolean native_time_zone_window_key_press_event_cb( + GtkWidget*, + GdkEventKey* event, + gpointer user_data) { + if (event->keyval != GDK_KEY_Escape) { return FALSE; } - gint red = -1; - gint green = -1; - gint blue = -1; - gdouble alpha = -1; - gchar extra = 0; - if (sscanf(value, "rgba(%d,%d,%d,%lf)%c", &red, &green, &blue, &alpha, - &extra) != 4) { - return FALSE; + auto* state = static_cast(user_data); + if (g_main_loop_is_running(state->loop)) { + g_main_loop_quit(state->loop); } - return red >= 0 && red <= 255 && green >= 0 && green <= 255 && blue >= 0 && - blue <= 255 && alpha >= 0 && alpha <= 1; + return TRUE; } -static gboolean is_css_color_token(const gchar* value) { - return is_css_hex_color(value) || is_css_rgba_color(value); +static void native_time_zone_window_destroy_cb(GtkWidget*, + gpointer user_data) { + auto* state = static_cast(user_data); + state->window = nullptr; + if (state->application != nullptr) { + schedule_header_bar_focus_state_refresh(state->application); + } + if (g_main_loop_is_running(state->loop)) { + g_main_loop_quit(state->loop); + } } -static const gchar* css_color_or(const gchar* value, const gchar* fallback) { - return is_css_color_token(value) ? value : fallback; +static void header_focus_window_is_active_notify_cb( + GtkWindow*, + GParamSpec*, + gpointer user_data) { + schedule_header_bar_focus_state_refresh(MY_APPLICATION(user_data)); } -static void set_flutter_view_background_color(MyApplication* self, - const gchar* color) { - if (self->flutter_view == nullptr || !FL_IS_VIEW(self->flutter_view) || - !is_css_color_token(color)) { - return; +static gboolean native_time_zone_present_after_parent_activation_cb( + gpointer user_data) { + GtkWindow* window = GTK_WINDOW(user_data); + GtkWindow* parent = gtk_window_get_transient_for(window); + if (parent == nullptr || !gtk_window_is_active(parent) || + !gtk_widget_get_visible(GTK_WIDGET(window)) || + gtk_window_is_active(window)) { + return G_SOURCE_REMOVE; } - GdkRGBA background_color; - if (!gdk_rgba_parse(&background_color, color)) { - return; - } - fl_view_set_background_color(FL_VIEW(self->flutter_view), &background_color); + gtk_window_present_with_time(window, GDK_CURRENT_TIME); + return G_SOURCE_REMOVE; } -static void set_flutter_view_background_transparent(MyApplication* self) { - if (self->flutter_view == nullptr || !FL_IS_VIEW(self->flutter_view)) { +static void native_time_zone_parent_is_active_notify_cb( + GtkWindow* parent, + GParamSpec*, + gpointer user_data) { + GtkWindow* window = GTK_WINDOW(user_data); + if (!gtk_window_is_active(parent) || + !gtk_widget_get_visible(GTK_WIDGET(window))) { return; } - GdkRGBA transparent = {0, 0, 0, 0}; - fl_view_set_background_color(FL_VIEW(self->flutter_view), &transparent); + // GTK can briefly activate the transient parent while focus is leaving the + // application. Wait until that transition settles before deciding whether + // the modal needs focus again, otherwise the dialog steals focus on + // alternating activation cycles. + g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, + native_time_zone_present_after_parent_activation_cb, + g_object_ref(window), g_object_unref); } -static void set_main_flutter_view_background(MyApplication* self) { - if (self->main_window_transparent_backing) { - set_flutter_view_background_transparent(self); +static void rebuild_native_time_zone_results( + NativeTimeZoneDialogState* state, + const gchar* query) { + clear_native_time_zone_results(state->results); + + g_autofree gchar* query_copy = g_strdup(query != nullptr ? query : ""); + const gchar* stripped_query = g_strstrip(query_copy); + if (stripped_query[0] == '\0') { + gtk_widget_show_all(state->results); return; } + g_autofree gchar* normalized_query = + g_utf8_casefold(stripped_query, -1); + + GPtrArray* matches = g_ptr_array_new(); + GHashTable* region_match_ranks = + g_hash_table_new(g_str_hash, g_str_equal); + for (size_t index = 0; index < state->options->len; index++) { + auto* option = static_cast( + g_ptr_array_index(state->options, index)); + option->match_rank = + native_time_zone_option_match_rank(option, normalized_query); + if (option->match_rank < 0) { + continue; + } + g_ptr_array_add(matches, option); + const gpointer stored_rank = + g_hash_table_lookup(region_match_ranks, option->region); + if (stored_rank == nullptr || + option->match_rank < GPOINTER_TO_INT(stored_rank) - 1) { + g_hash_table_insert(region_match_ranks, option->region, + GINT_TO_POINTER(option->match_rank + 1)); + } + } + for (size_t index = 0; index < matches->len; index++) { + auto* option = static_cast( + g_ptr_array_index(matches, index)); + option->region_match_rank = + GPOINTER_TO_INT( + g_hash_table_lookup(region_match_ranks, option->region)) - + 1; + } + g_ptr_array_sort_with_data(matches, compare_native_time_zone_options, + nullptr); + g_hash_table_unref(region_match_ranks); + + GtkWidget* current_group = nullptr; + const gchar* current_region = nullptr; + size_t result_count = 0; + for (size_t index = 0; + index < matches->len && + result_count < kNativeTimeZoneResultLimit; + index++) { + auto* option = static_cast( + g_ptr_array_index(matches, index)); + + if (current_region == nullptr || + g_strcmp0(current_region, option->region) != 0) { + current_region = option->region; + current_group = hdy_preferences_group_new(); + gtk_style_context_add_class( + gtk_widget_get_style_context(current_group), + kNativeTimeZoneGroupStyleClass); + gtk_widget_set_hexpand(current_group, TRUE); + gtk_widget_set_halign(current_group, GTK_ALIGN_FILL); + hdy_preferences_group_set_title( + HDY_PREFERENCES_GROUP(current_group), current_region); + gtk_box_pack_start(GTK_BOX(state->results), current_group, FALSE, FALSE, + 0); + } - set_flutter_view_background_color( - self, css_color_or(self->header_bar_window_background_color, - self->header_bar_background_color)); -} + GtkWidget* row = hdy_action_row_new(); + gtk_style_context_add_class(gtk_widget_get_style_context(row), + kNativeTimeZoneRowStyleClass); + gtk_widget_set_hexpand(row, TRUE); + gtk_widget_set_halign(row, GTK_ALIGN_FILL); + hdy_preferences_row_set_title(HDY_PREFERENCES_ROW(row), option->title); + hdy_action_row_set_subtitle(HDY_ACTION_ROW(row), option->subtitle); + hdy_action_row_set_title_lines(HDY_ACTION_ROW(row), 1); + hdy_action_row_set_subtitle_lines(HDY_ACTION_ROW(row), 1); + hdy_action_row_set_icon_name(HDY_ACTION_ROW(row), + "mark-location-symbolic"); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE); + g_object_set_data_full(G_OBJECT(row), "busymax-time-zone-id", + g_strdup(option->id), g_free); + g_signal_connect(row, "activated", + G_CALLBACK(native_time_zone_row_activated_cb), state); + + if (g_strcmp0(option->id, state->selected_time_zone) == 0) { + GtkWidget* selected_icon = gtk_image_new_from_icon_name( + "emblem-ok-symbolic", GTK_ICON_SIZE_BUTTON); + gtk_container_add(GTK_CONTAINER(row), selected_icon); + } + gtk_container_add(GTK_CONTAINER(current_group), row); + result_count++; + } + g_ptr_array_unref(matches); -static gint header_sidebar_effective_width(MyApplication* self) { - if (!self->header_bar_sidebar_visible) { - return 0; + if (result_count == 0) { + GtkWidget* no_results = gtk_label_new(state->no_results_label); + gtk_widget_set_margin_top(no_results, 72); + gtk_style_context_add_class(gtk_widget_get_style_context(no_results), + GTK_STYLE_CLASS_DIM_LABEL); + gtk_box_pack_start(GTK_BOX(state->results), no_results, FALSE, FALSE, 0); } - return self->header_bar_sidebar_width; + gtk_widget_show_all(state->results); +} + +static void native_time_zone_search_changed_cb(GtkSearchEntry* search, + gpointer user_data) { + auto* state = static_cast(user_data); + rebuild_native_time_zone_results( + state, gtk_entry_get_text(GTK_ENTRY(search))); +} + +static GPtrArray* parse_native_time_zone_options(FlValue* args) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return nullptr; + } + FlValue* entries = fl_value_lookup_string(args, "options"); + if (entries == nullptr || + fl_value_get_type(entries) != FL_VALUE_TYPE_LIST) { + return nullptr; + } + + GPtrArray* options = + g_ptr_array_new_with_free_func(native_time_zone_option_free); + for (size_t index = 0; index < fl_value_get_length(entries); index++) { + FlValue* entry = fl_value_get_list_value(entries, index); + const gchar* id = fl_lookup_string_arg(entry, "id"); + const gchar* region = fl_lookup_string_arg(entry, "region"); + const gchar* name = fl_lookup_string_arg(entry, "name"); + const gchar* english_name = + fl_lookup_string_arg(entry, "englishName"); + const gchar* title = fl_lookup_string_arg(entry, "title"); + const gchar* subtitle = fl_lookup_string_arg(entry, "subtitle"); + const gchar* search_text = fl_lookup_string_arg(entry, "searchText"); + if (id == nullptr || region == nullptr || name == nullptr || + english_name == nullptr || title == nullptr || subtitle == nullptr || + search_text == nullptr) { + g_ptr_array_unref(options); + return nullptr; + } + + auto* option = g_new0(NativeTimeZoneOption, 1); + option->id = g_strdup(id); + option->region = g_strdup(region); + option->normalized_name = g_utf8_casefold(name, -1); + option->normalized_english_name = g_utf8_casefold(english_name, -1); + option->title = g_strdup(title); + option->subtitle = g_strdup(subtitle); + option->search_text = g_utf8_casefold(search_text, -1); + g_ptr_array_add(options, option); + } + return options; +} + +static gboolean is_native_grouped_list_color(const gchar* value) { + if (value == nullptr) { + return FALSE; + } + GdkRGBA parsed = {}; + return gdk_rgba_parse(&parsed, value); +} + +static gboolean parse_native_grouped_list_style( + FlValue* args, + NativeGroupedListStyle* style) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* value = fl_value_lookup_string(args, "groupedListStyle"); + if (value == nullptr || fl_value_get_type(value) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + + style->surface_color = fl_lookup_string_arg(value, "surfaceColor"); + style->divider_color = fl_lookup_string_arg(value, "dividerColor"); + style->section_header_color = + fl_lookup_string_arg(value, "sectionHeaderColor"); + style->primary_text_color = + fl_lookup_string_arg(value, "primaryTextColor"); + style->secondary_text_color = + fl_lookup_string_arg(value, "secondaryTextColor"); + style->hover_color = fl_lookup_string_arg(value, "hoverColor"); + style->shadow_color = fl_lookup_string_arg(value, "shadowColor"); + style->outline_color = fl_lookup_string_arg(value, "outlineColor"); + + gint64 radius = 0; + gint64 section_top_spacing = 0; + gint64 section_horizontal_padding = 0; + gint64 title_bottom_spacing = 0; + if (!fl_lookup_optional_bool_arg(value, "highContrast", + &style->high_contrast) || + !fl_lookup_int_arg(value, "radius", &radius) || + !fl_lookup_int_arg(value, "sectionTopSpacing", + §ion_top_spacing) || + !fl_lookup_int_arg(value, "sectionHorizontalPadding", + §ion_horizontal_padding) || + !fl_lookup_int_arg(value, "titleBottomSpacing", + &title_bottom_spacing)) { + return FALSE; + } + + const gchar* colors[] = { + style->surface_color, style->divider_color, + style->section_header_color, style->primary_text_color, + style->secondary_text_color, style->hover_color, + style->shadow_color, style->outline_color, + }; + for (const gchar* color : colors) { + if (!is_native_grouped_list_color(color)) { + return FALSE; + } + } + + if (radius < 0 || radius > 64 || + section_top_spacing < 0 || section_top_spacing > 128 || + section_horizontal_padding < 0 || + section_horizontal_padding > 128 || + title_bottom_spacing < 0 || title_bottom_spacing > 128) { + return FALSE; + } + style->radius = static_cast(radius); + style->section_top_spacing = static_cast(section_top_spacing); + style->section_horizontal_padding = + static_cast(section_horizontal_padding); + style->title_bottom_spacing = static_cast(title_bottom_spacing); + return TRUE; +} + +static GtkCssProvider* create_native_grouped_list_provider( + const NativeGroupedListStyle* style, + GError** error) { + g_autofree gchar* css = g_strdup_printf( + "window.%s .%s {" + "background-color: transparent;" + "background-image: none;" + "}" + "window.%s .%s {" + "margin-top: %dpx;" + "}" + "window.%s .%s > box > label.heading," + "window.%s .%s > box > label.h4 {" + "color: %s;" + "margin-bottom: %dpx;" + "}" + "window.%s .%s list {" + "background-color: %s;" + "background-image: none;" + "border: %dpx solid %s;" + "border-radius: %dpx;" + "box-shadow: 0 2px 6px 2px alpha(%s, 0.03)," + "0 1px 3px 1px alpha(%s, 0.07)," + "0 0 0 1px alpha(%s, 0.03);" + "}" + "window.%s row.%s," + "window.%s row.%s:backdrop {" + "background-color: transparent;" + "background-image: none;" + "border: none;" + "box-shadow: none;" + "color: %s;" + "}" + "window.%s row.%s:not(:last-child) {" + "border-bottom: 1px solid %s;" + "}" + "window.%s row.%s label.title {" + "color: %s;" + "}" + "window.%s row.%s label.subtitle," + "window.%s row.%s label.dim-label {" + "color: %s;" + "}" + "window.%s row.%s:hover:not(:disabled) {" + "background-color: %s;" + "background-image: none;" + "}", + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneResultsStyleClass, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneGroupStyleClass, + style->section_top_spacing, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneGroupStyleClass, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneGroupStyleClass, + style->section_header_color, style->title_bottom_spacing, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneGroupStyleClass, + style->surface_color, style->high_contrast ? 1 : 0, + style->outline_color, style->radius, style->shadow_color, + style->shadow_color, style->shadow_color, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + style->primary_text_color, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + style->divider_color, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + style->primary_text_color, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + style->secondary_text_color, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + style->hover_color); + GtkCssProvider* provider = gtk_css_provider_new(); + gtk_css_provider_load_from_data(provider, css, -1, error); + if (error != nullptr && *error != nullptr) { + g_object_unref(provider); + return nullptr; + } + return provider; +} + +static void handle_native_time_zone_selection(FlMethodCall* method_call, + FlValue* args, + GtkWindow* parent, + MyApplication* application) { + const gchar* title = fl_lookup_string_arg(args, "title"); + const gchar* search_placeholder = + fl_lookup_string_arg(args, "searchPlaceholder"); + const gchar* no_results_label = + fl_lookup_string_arg(args, "noResultsLabel"); + const gchar* selected_time_zone = + fl_lookup_string_arg(args, "selectedTimeZone"); + GPtrArray* options = parse_native_time_zone_options(args); + NativeGroupedListStyle grouped_list_style = {}; + if (title == nullptr || search_placeholder == nullptr || + no_results_label == nullptr || selected_time_zone == nullptr || + options == nullptr || options->len == 0 || + !parse_native_grouped_list_style(args, &grouped_list_style)) { + if (options != nullptr) { + g_ptr_array_unref(options); + } + fl_method_call_respond_error( + method_call, "invalid-arguments", + "The timezone dialog requires localized labels, a selected timezone, " + "a non-empty option list, and valid grouped-list presentation values.", + nullptr, nullptr); + return; + } + + g_autoptr(GError) css_error = nullptr; + g_autoptr(GtkCssProvider) grouped_list_provider = + create_native_grouped_list_provider(&grouped_list_style, &css_error); + if (grouped_list_provider == nullptr) { + g_ptr_array_unref(options); + fl_method_call_respond_error( + method_call, "invalid-arguments", + css_error != nullptr ? css_error->message + : "The grouped-list presentation is invalid.", + nullptr, nullptr); + return; + } + + GtkWidget* window = hdy_window_new(); + style_native_dialog(window); + GtkStyleContext* dialog_context = gtk_widget_get_style_context(window); + gtk_style_context_add_class(dialog_context, + kNativeTimeZoneDialogStyleClass); + gtk_window_set_title(GTK_WINDOW(window), title); + gtk_window_set_transient_for(GTK_WINDOW(window), parent); + gtk_window_set_modal(GTK_WINDOW(window), TRUE); + gtk_window_set_destroy_with_parent(GTK_WINDOW(window), TRUE); + gtk_window_set_type_hint(GTK_WINDOW(window), GDK_WINDOW_TYPE_HINT_DIALOG); + gtk_window_set_skip_taskbar_hint(GTK_WINDOW(window), TRUE); + gtk_window_set_skip_pager_hint(GTK_WINDOW(window), TRUE); + gtk_window_set_position(GTK_WINDOW(window), GTK_WIN_POS_CENTER_ON_PARENT); + gtk_window_set_resizable(GTK_WINDOW(window), FALSE); + gtk_window_set_default_size(GTK_WINDOW(window), + kNativeTimeZoneDialogWidth, -1); + GdkScreen* screen = gtk_widget_get_screen(window); + gtk_style_context_add_provider_for_screen( + screen, GTK_STYLE_PROVIDER(grouped_list_provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + 1); + + GtkWidget* window_root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_container_add(GTK_CONTAINER(window), window_root); + + GtkWidget* header_bar = hdy_header_bar_new(); + hdy_header_bar_set_title(HDY_HEADER_BAR(header_bar), title); + hdy_header_bar_set_has_subtitle(HDY_HEADER_BAR(header_bar), FALSE); + hdy_header_bar_set_show_close_button(HDY_HEADER_BAR(header_bar), TRUE); + hdy_header_bar_set_decoration_layout(HDY_HEADER_BAR(header_bar), ":close"); + hdy_header_bar_set_centering_policy(HDY_HEADER_BAR(header_bar), + HDY_CENTERING_POLICY_STRICT); + gtk_box_pack_start(GTK_BOX(window_root), header_bar, FALSE, FALSE, 0); + + GtkWidget* content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_style_context_add_class(gtk_widget_get_style_context(content), + "busymax-native-dialog-content"); + gtk_box_pack_start(GTK_BOX(window_root), content, TRUE, TRUE, 0); + + GtkWidget* root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); + gtk_widget_set_size_request(root, kNativeTimeZoneDialogWidth - 36, + kNativeTimeZoneDialogContentHeight); + gtk_container_set_border_width(GTK_CONTAINER(root), 18); + gtk_container_add(GTK_CONTAINER(content), root); + + GtkWidget* search = gtk_search_entry_new(); + gtk_entry_set_placeholder_text(GTK_ENTRY(search), search_placeholder); + gtk_box_pack_start(GTK_BOX(root), search, FALSE, FALSE, 0); + + GtkWidget* scrolled = gtk_scrolled_window_new(nullptr, nullptr); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_shadow_type(GTK_SCROLLED_WINDOW(scrolled), + GTK_SHADOW_NONE); + gtk_scrolled_window_set_propagate_natural_width( + GTK_SCROLLED_WINDOW(scrolled), FALSE); + gtk_scrolled_window_set_max_content_width( + GTK_SCROLLED_WINDOW(scrolled), kNativeTimeZoneDialogWidth - 36); + gtk_widget_set_hexpand(scrolled, TRUE); + gtk_widget_set_vexpand(scrolled, TRUE); + gtk_box_pack_start(GTK_BOX(root), scrolled, TRUE, TRUE, 0); + + GtkWidget* results = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_style_context_add_class(gtk_widget_get_style_context(results), + kNativeTimeZoneResultsStyleClass); + gtk_widget_set_margin_start( + results, grouped_list_style.section_horizontal_padding); + gtk_widget_set_margin_end( + results, grouped_list_style.section_horizontal_padding); + gtk_container_add(GTK_CONTAINER(scrolled), results); + + GMainLoop* loop = g_main_loop_new(nullptr, FALSE); + NativeTimeZoneDialogState state = { + application, + window, + results, + options, + selected_time_zone, + no_results_label, + loop, + nullptr, + }; + g_signal_connect(search, "search-changed", + G_CALLBACK(native_time_zone_search_changed_cb), &state); + g_signal_connect(window, "delete-event", + G_CALLBACK(native_time_zone_window_delete_event_cb), &state); + g_signal_connect(window, "key-press-event", + G_CALLBACK(native_time_zone_window_key_press_event_cb), + &state); + g_signal_connect(window, "destroy", + G_CALLBACK(native_time_zone_window_destroy_cb), &state); + if (application != nullptr && parent == application->main_window) { + application->header_focus_transient_window = GTK_WINDOW(window); + g_object_add_weak_pointer( + G_OBJECT(window), + reinterpret_cast( + &application->header_focus_transient_window)); + g_signal_connect( + window, "notify::is-active", + G_CALLBACK(header_focus_window_is_active_notify_cb), application); + } + g_signal_connect_object( + parent, "notify::is-active", + G_CALLBACK(native_time_zone_parent_is_active_notify_cb), window, + static_cast(0)); + + gtk_widget_show_all(window); + gtk_window_present_with_time(GTK_WINDOW(window), GDK_CURRENT_TIME); + gtk_widget_grab_focus(search); + g_main_loop_run(loop); + respond_string(method_call, state.result); + + if (state.window != nullptr) { + gtk_widget_destroy(state.window); + } + g_main_loop_unref(loop); + g_free(state.result); + g_ptr_array_unref(options); + gtk_style_context_remove_provider_for_screen( + screen, GTK_STYLE_PROVIDER(grouped_list_provider)); +} + +struct NativeDialogHandlerData { + GtkWindow* window; + MyApplication* application; +}; + +static void native_dialog_handler_data_free(gpointer user_data) { + auto* data = static_cast(user_data); + if (data->window != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(data->window), + reinterpret_cast(&data->window)); + } + if (data->application != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(data->application), + reinterpret_cast(&data->application)); + } + g_free(data); +} + +static void native_dialog_method_call_cb(FlMethodChannel* channel, + FlMethodCall* method_call, + gpointer user_data) { + auto* data = static_cast(user_data); + GtkWindow* parent = data->window; + if (parent == nullptr) { + fl_method_call_respond_not_implemented(method_call, nullptr); + return; + } + const gchar* method = fl_method_call_get_name(method_call); + if (strcmp(method, "selectTimeZone") == 0) { + handle_native_time_zone_selection( + method_call, fl_method_call_get_args(method_call), parent, + data->application); + } else { + fl_method_call_respond_not_implemented(method_call, nullptr); + } +} + +static FlMethodChannel* create_native_dialog_channel(FlView* view, + GtkWindow* window, + MyApplication* application) { + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + FlMethodChannel* channel = fl_method_channel_new( + fl_engine_get_binary_messenger(fl_view_get_engine(view)), + kNativeDialogChannel, FL_METHOD_CODEC(codec)); + auto* data = g_new0(NativeDialogHandlerData, 1); + data->window = window; + data->application = application; + g_object_add_weak_pointer(G_OBJECT(window), + reinterpret_cast(&data->window)); + if (application != nullptr) { + g_object_add_weak_pointer( + G_OBJECT(application), + reinterpret_cast(&data->application)); + } + fl_method_channel_set_method_call_handler( + channel, native_dialog_method_call_cb, data, + native_dialog_handler_data_free); + return channel; +} + +static void register_native_dialogs(MyApplication* self, + FlView* view, + GtkWindow* window) { + self->native_dialog_channel = + create_native_dialog_channel(view, window, self); +} + +constexpr char kNativeMenuActionNamespace[] = "busymax-native-menu"; +constexpr char kNativeMenuActionIndexKey[] = "busymax-native-menu-index"; + +struct NativeMenuHandlerData; + +struct NativeMenuSession { + NativeMenuHandlerData* owner; + gint64 id; + size_t entry_count; + GtkWidget* menu; + GMenu* model; + GSimpleActionGroup* action_group; + FlMethodCall* method_call; + gulong deactivate_signal_id; + guint cleanup_source_id; + gint pending_selected_index; +}; + +struct NativeMenuHandlerData { + GtkWidget* view; + NativeMenuSession* active; +}; + +static void native_menu_session_respond(NativeMenuSession* session, + gint selected_index) { + if (session->method_call == nullptr) { + return; + } + g_autoptr(FlValue) result = selected_index < 0 + ? fl_value_new_null() + : fl_value_new_int(selected_index); + fl_method_call_respond_success(session->method_call, result, nullptr); + g_clear_object(&session->method_call); +} + +static void native_menu_session_dispose(NativeMenuSession* session) { + if (session == nullptr) { + return; + } + + NativeMenuHandlerData* owner = session->owner; + if (owner != nullptr && owner->active == session) { + owner->active = nullptr; + } + + if (session->cleanup_source_id != 0) { + g_source_remove(session->cleanup_source_id); + session->cleanup_source_id = 0; + } + if (session->menu != nullptr) { + if (session->deactivate_signal_id != 0) { + g_signal_handler_disconnect(session->menu, + session->deactivate_signal_id); + session->deactivate_signal_id = 0; + } + if (gtk_widget_get_visible(session->menu)) { + gtk_menu_shell_deactivate(GTK_MENU_SHELL(session->menu)); + } + } + if (owner != nullptr && owner->view != nullptr) { + gtk_widget_insert_action_group(owner->view, + kNativeMenuActionNamespace, nullptr); + } + if (session->menu != nullptr && GTK_IS_MENU(session->menu) && + gtk_menu_get_attach_widget(GTK_MENU(session->menu)) != nullptr) { + gtk_menu_detach(GTK_MENU(session->menu)); + } + g_clear_object(&session->menu); + if (owner != nullptr && owner->view != nullptr) { + if (gtk_widget_get_realized(owner->view)) { + gtk_widget_grab_focus(owner->view); + } + } + g_clear_object(&session->model); + g_clear_object(&session->action_group); + // Resolve the Dart future only after the native session is fully retired. + // A resumed caller may immediately open another menu. + native_menu_session_respond(session, session->pending_selected_index); + g_free(session); +} + +static gboolean native_menu_cleanup_idle_cb(gpointer user_data) { + auto* session = static_cast(user_data); + session->cleanup_source_id = 0; + native_menu_session_dispose(session); + return G_SOURCE_REMOVE; +} + +static void native_menu_deactivate_cb(GtkMenuShell*, gpointer user_data) { + auto* session = static_cast(user_data); + if (session->cleanup_source_id == 0) { + // GtkMenu deactivates before it invokes the selected GAction. Resolve the + // Dart result after GTK has released its grab and the action has run. + session->cleanup_source_id = g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, native_menu_cleanup_idle_cb, session, nullptr); + } +} + +static void native_menu_action_activated_cb(GSimpleAction* action, + GVariant*, + gpointer user_data) { + auto* session = static_cast(user_data); + session->pending_selected_index = + GPOINTER_TO_INT( + g_object_get_data(G_OBJECT(action), kNativeMenuActionIndexKey)) - + 1; +} + +static void native_menu_selection_activated_cb(GSimpleAction* action, + GVariant* parameter, + gpointer user_data) { + if (parameter == nullptr || + !g_variant_is_of_type(parameter, G_VARIANT_TYPE_STRING)) { + return; + } + const gchar* target = g_variant_get_string(parameter, nullptr); + gchar* end = nullptr; + const guint64 parsed = g_ascii_strtoull(target, &end, 10); + auto* session = static_cast(user_data); + if (target[0] == '\0' || end == nullptr || *end != '\0' || + parsed > static_cast(G_MAXINT) || + parsed >= session->entry_count) { + return; + } + + g_simple_action_set_state(action, parameter); + session->pending_selected_index = static_cast(parsed); +} + +static gboolean native_menu_dismiss_active(NativeMenuHandlerData* data, + gint64 session_id) { + NativeMenuSession* session = data->active; + if (session == nullptr || session->id != session_id) { + return FALSE; + } + + if (session->menu != nullptr && gtk_widget_get_visible(session->menu)) { + gtk_menu_shell_deactivate(GTK_MENU_SHELL(session->menu)); + } else { + native_menu_session_dispose(session); + } + return TRUE; +} + +static gboolean fl_lookup_number_arg(FlValue* args, + const gchar* key, + gdouble* value_out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr) { + return FALSE; + } + switch (fl_value_get_type(value)) { + case FL_VALUE_TYPE_FLOAT: + *value_out = fl_value_get_float(value); + return std::isfinite(*value_out); + case FL_VALUE_TYPE_INT: + *value_out = static_cast(fl_value_get_int(value)); + return TRUE; + default: + return FALSE; + } +} + +static gboolean fl_lookup_optional_bool_arg(FlValue* args, + const gchar* key, + gboolean fallback, + gboolean* value_out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr) { + *value_out = fallback; + return TRUE; + } + if (fl_value_get_type(value) != FL_VALUE_TYPE_BOOL) { + return FALSE; + } + *value_out = fl_value_get_bool(value); + return TRUE; +} + +static gboolean fl_lookup_positive_int64_arg(FlValue* args, + const gchar* key, + gint64* value_out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr || fl_value_get_type(value) != FL_VALUE_TYPE_INT) { + return FALSE; + } + const gint64 parsed = fl_value_get_int(value); + if (parsed <= 0) { + return FALSE; + } + *value_out = parsed; + return TRUE; +} + +static void respond_native_menu_argument_error(FlMethodCall* method_call, + const gchar* message) { + fl_method_call_respond_error(method_call, "invalid-arguments", message, + nullptr, nullptr); +} + +static gboolean parse_native_menu_anchor(FlValue* args, + GdkRectangle* rectangle_out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* anchor = fl_value_lookup_string(args, "anchor"); + if (anchor == nullptr || fl_value_get_type(anchor) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + gdouble x = 0; + gdouble y = 0; + gdouble width = 0; + gdouble height = 0; + const gboolean has_x = fl_lookup_number_arg(anchor, "x", &x) || + fl_lookup_number_arg(anchor, "left", &x); + const gboolean has_y = fl_lookup_number_arg(anchor, "y", &y) || + fl_lookup_number_arg(anchor, "top", &y); + if (!has_x || !has_y || + !fl_lookup_number_arg(anchor, "width", &width) || + !fl_lookup_number_arg(anchor, "height", &height) || width < 0 || + height < 0 || !std::isfinite(x + width) || + !std::isfinite(y + height)) { + return FALSE; + } + + const gdouble left = std::floor(x); + const gdouble top = std::floor(y); + const gdouble right = std::ceil(x + width); + const gdouble bottom = std::ceil(y + height); + const gdouble pixel_width = std::max(1.0, right - left); + const gdouble pixel_height = std::max(1.0, bottom - top); + if (left < G_MININT || left > G_MAXINT || top < G_MININT || + top > G_MAXINT || right < G_MININT || right > G_MAXINT || + bottom < G_MININT || bottom > G_MAXINT || pixel_width > G_MAXINT || + pixel_height > G_MAXINT) { + return FALSE; + } + + rectangle_out->x = static_cast(left); + rectangle_out->y = static_cast(top); + rectangle_out->width = static_cast(pixel_width); + rectangle_out->height = static_cast(pixel_height); + return TRUE; +} + +static void show_native_menu(NativeMenuHandlerData* data, + FlMethodCall* method_call, + FlValue* args) { + if (data->view == nullptr || !gtk_widget_get_realized(data->view) || + gtk_widget_get_window(data->view) == nullptr) { + fl_method_call_respond_error(method_call, "unavailable", + "The native menu host is unavailable.", + nullptr, nullptr); + return; + } + + GdkRectangle anchor = {}; + gint64 session_id = 0; + if (!fl_lookup_positive_int64_arg(args, "sessionId", &session_id) || + !parse_native_menu_anchor(args, &anchor)) { + respond_native_menu_argument_error( + method_call, + "sessionId must be a positive integer and anchor must contain finite " + "x, y, width, and height."); + return; + } + + GtkWidget* toplevel = gtk_widget_get_toplevel(data->view); + GdkWindow* rect_window = + GTK_IS_WINDOW(toplevel) ? gtk_widget_get_window(toplevel) : nullptr; + GdkRectangle window_anchor = anchor; + if (rect_window == nullptr || + !gtk_widget_translate_coordinates( + data->view, toplevel, anchor.x, anchor.y, &window_anchor.x, + &window_anchor.y)) { + fl_method_call_respond_error( + method_call, "unavailable", + "GTK could not translate the menu anchor into window coordinates.", + nullptr, nullptr); + return; + } + + FlValue* entries = fl_value_lookup_string(args, "entries"); + GtkPositionType preferred_position = GTK_POS_BOTTOM; + const gchar* preferred_position_arg = + fl_lookup_string_arg(args, "preferredPosition"); + if (g_strcmp0(preferred_position_arg, "top") == 0) { + preferred_position = GTK_POS_TOP; + } else if (preferred_position_arg != nullptr && + g_strcmp0(preferred_position_arg, "bottom") != 0) { + respond_native_menu_argument_error( + method_call, "preferredPosition must be top or bottom."); + return; + } + gboolean focus_first = FALSE; + if (entries == nullptr || + fl_value_get_type(entries) != FL_VALUE_TYPE_LIST || + fl_value_get_length(entries) == 0 || + fl_value_get_length(entries) > static_cast(G_MAXINT) || + !fl_lookup_optional_bool_arg(args, "focusFirst", FALSE, + &focus_first)) { + respond_native_menu_argument_error( + method_call, + "entries must be a non-empty list and focusFirst must be boolean."); + return; + } + + size_t selected_entry_count = 0; + gboolean has_disabled_entry = FALSE; + for (size_t index = 0; index < fl_value_get_length(entries); index++) { + FlValue* entry = fl_value_get_list_value(entries, index); + const gchar* label = fl_lookup_string_arg(entry, "label"); + FlValue* icon = fl_value_lookup_string(entry, "icon"); + FlValue* shortcut = fl_value_lookup_string(entry, "shortcut"); + gboolean enabled = TRUE; + gboolean selected = FALSE; + if (label == nullptr || + (icon != nullptr && fl_value_get_type(icon) != FL_VALUE_TYPE_STRING) || + (shortcut != nullptr && + fl_value_get_type(shortcut) != FL_VALUE_TYPE_STRING) || + !fl_lookup_optional_bool_arg(entry, "enabled", TRUE, &enabled) || + !fl_lookup_optional_bool_arg(entry, "selected", FALSE, &selected)) { + respond_native_menu_argument_error( + method_call, + "each entry must contain a label, optional string icon and shortcut, " + "and optional boolean enabled and selected values."); + return; + } + if (selected) { + selected_entry_count++; + } + has_disabled_entry = has_disabled_entry || !enabled; + } + if (selected_entry_count > 1 || + (selected_entry_count == 1 && has_disabled_entry)) { + respond_native_menu_argument_error( + method_call, + "single-choice menus require exactly one selected entry and all " + "entries enabled."); + return; + } + + if (data->active != nullptr) { + native_menu_session_dispose(data->active); + } + + auto* session = g_new0(NativeMenuSession, 1); + session->owner = data; + session->id = session_id; + session->entry_count = fl_value_get_length(entries); + session->pending_selected_index = -1; + session->method_call = + FL_METHOD_CALL(g_object_ref(G_OBJECT(method_call))); + session->action_group = g_simple_action_group_new(); + session->model = g_menu_new(); + data->active = session; + + GSimpleAction* selection_action = nullptr; + g_autofree gchar* detailed_selection_action = nullptr; + if (selected_entry_count == 1) { + g_autofree gchar* selected_target = nullptr; + for (size_t index = 0; index < fl_value_get_length(entries); index++) { + FlValue* entry = fl_value_get_list_value(entries, index); + gboolean selected = FALSE; + fl_lookup_optional_bool_arg(entry, "selected", FALSE, &selected); + if (selected) { + selected_target = g_strdup_printf("%zu", index); + break; + } + } + selection_action = g_simple_action_new_stateful( + "select", G_VARIANT_TYPE_STRING, + g_variant_new_string(selected_target != nullptr ? selected_target + : "")); + g_signal_connect(selection_action, "activate", + G_CALLBACK(native_menu_selection_activated_cb), session); + g_action_map_add_action(G_ACTION_MAP(session->action_group), + G_ACTION(selection_action)); + detailed_selection_action = + g_strdup_printf("%s.select", kNativeMenuActionNamespace); + } + + for (size_t index = 0; index < fl_value_get_length(entries); index++) { + FlValue* entry = fl_value_get_list_value(entries, index); + const gchar* label = fl_lookup_string_arg(entry, "label"); + const gchar* icon_name = fl_lookup_string_arg(entry, "icon"); + const gchar* shortcut = fl_lookup_string_arg(entry, "shortcut"); + gboolean enabled = TRUE; + fl_lookup_optional_bool_arg(entry, "enabled", TRUE, &enabled); + + g_autoptr(GMenuItem) item = g_menu_item_new(label, nullptr); + if (selection_action != nullptr) { + g_autofree gchar* target = g_strdup_printf("%zu", index); + g_menu_item_set_action_and_target_value( + item, detailed_selection_action, g_variant_new_string(target)); + } else { + g_autofree gchar* action_name = g_strdup_printf("select-%zu", index); + GSimpleAction* action = g_simple_action_new(action_name, nullptr); + g_simple_action_set_enabled(action, enabled); + g_object_set_data(G_OBJECT(action), kNativeMenuActionIndexKey, + GINT_TO_POINTER(static_cast(index) + 1)); + g_signal_connect(action, "activate", + G_CALLBACK(native_menu_action_activated_cb), session); + g_action_map_add_action(G_ACTION_MAP(session->action_group), + G_ACTION(action)); + g_autofree gchar* detailed_action = + g_strdup_printf("%s.%s", kNativeMenuActionNamespace, action_name); + g_menu_item_set_detailed_action(item, detailed_action); + g_object_unref(action); + } + if (icon_name != nullptr && icon_name[0] != '\0') { + g_autoptr(GIcon) icon = g_themed_icon_new(icon_name); + g_menu_item_set_icon(item, icon); + } + if (shortcut != nullptr && shortcut[0] != '\0') { + set_menu_item_accelerator(item, shortcut); + } + g_menu_append_item(session->model, item); + } + if (selection_action != nullptr) { + g_object_unref(selection_action); + } + + // GTK 3 maps GtkPopover as a Wayland subsurface. Mutter can leave that + // surface's frame callback pending while Flutter's parent surface is idle, + // freezing GDK redraws after hover state changes. GtkMenu is GTK's native + // menu backend and maps as an independent xdg_popup instead. + gtk_widget_insert_action_group( + data->view, kNativeMenuActionNamespace, + G_ACTION_GROUP(session->action_group)); + session->menu = gtk_menu_new_from_model(G_MENU_MODEL(session->model)); + if (session->menu == nullptr || !GTK_IS_MENU(session->menu)) { + fl_method_call_respond_error(method_call, "unavailable", + "GTK could not create the native menu.", + nullptr, nullptr); + g_clear_object(&session->method_call); + native_menu_session_dispose(session); + return; + } + g_object_ref_sink(session->menu); + gtk_menu_attach_to_widget(GTK_MENU(session->menu), data->view, nullptr); + gtk_widget_show_all(session->menu); + session->deactivate_signal_id = g_signal_connect( + session->menu, "deactivate", G_CALLBACK(native_menu_deactivate_cb), + session); + + const gboolean open_above = preferred_position == GTK_POS_TOP; + g_object_set(session->menu, "anchor-hints", + GDK_ANCHOR_FLIP_Y | GDK_ANCHOR_SLIDE | GDK_ANCHOR_RESIZE, + nullptr); + if (!open_above) { + g_object_set(session->menu, "menu-type-hint", + GDK_WINDOW_TYPE_HINT_DROPDOWN_MENU, nullptr); + } + // Flutter reports view-local coordinates, while FlView is a no-window + // widget whose GdkWindow belongs to the toplevel. Anchor to the translated + // rectangle directly: moving a hidden proxy widget would only queue a later + // size allocation, so an immediate popup would still see its old position. + gtk_menu_popup_at_rect( + GTK_MENU(session->menu), rect_window, &window_anchor, + open_above ? GDK_GRAVITY_NORTH_WEST : GDK_GRAVITY_SOUTH_WEST, + open_above ? GDK_GRAVITY_SOUTH_WEST : GDK_GRAVITY_NORTH_WEST, nullptr); + if (focus_first) { + gtk_menu_shell_select_first(GTK_MENU_SHELL(session->menu), TRUE); + } else { + // A pointer-opened menu should not start with a keyboard-selected row. + gtk_menu_shell_deselect(GTK_MENU_SHELL(session->menu)); + } +} + +static void native_menu_handler_data_free(gpointer user_data) { + auto* data = static_cast(user_data); + if (data->active != nullptr) { + native_menu_session_dispose(data->active); + } + if (data->view != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(data->view), + reinterpret_cast(&data->view)); + } + g_free(data); +} + +static void native_menu_method_call_cb(FlMethodChannel*, + FlMethodCall* method_call, + gpointer user_data) { + auto* data = static_cast(user_data); + const gchar* method = fl_method_call_get_name(method_call); + if (strcmp(method, "show") == 0) { + show_native_menu(data, method_call, fl_method_call_get_args(method_call)); + } else if (strcmp(method, "dismiss") == 0) { + gint64 session_id = 0; + if (!fl_lookup_positive_int64_arg(fl_method_call_get_args(method_call), + "sessionId", &session_id)) { + respond_native_menu_argument_error( + method_call, "sessionId must be a positive integer."); + return; + } + respond_bool(method_call, + native_menu_dismiss_active(data, session_id)); + } else { + fl_method_call_respond_not_implemented(method_call, nullptr); + } +} + +static FlMethodChannel* create_native_menu_channel(FlView* view) { + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + FlMethodChannel* channel = fl_method_channel_new( + fl_engine_get_binary_messenger(fl_view_get_engine(view)), + kNativeMenuChannel, FL_METHOD_CODEC(codec)); + auto* data = g_new0(NativeMenuHandlerData, 1); + data->view = GTK_WIDGET(view); + g_object_add_weak_pointer(G_OBJECT(data->view), + reinterpret_cast(&data->view)); + fl_method_channel_set_method_call_handler( + channel, native_menu_method_call_cb, data, + native_menu_handler_data_free); + return channel; +} + +static void register_native_menus(MyApplication* self, FlView* view) { + self->native_menu_channel = create_native_menu_channel(view); +} + +static void respond_success(FlMethodCall* method_call) { + g_autoptr(FlValue) result = fl_value_new_null(); + fl_method_call_respond_success(method_call, result, nullptr); +} + +static gboolean fl_method_bool_arg(FlValue* args) { + return args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_BOOL + ? fl_value_get_bool(args) + : FALSE; +} + +static gint fl_method_int_arg(FlValue* args, gint fallback) { + return args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_INT + ? static_cast(fl_value_get_int(args)) + : fallback; +} + +static gdouble fl_method_double_arg(FlValue* args, gdouble fallback) { + if (args == nullptr) { + return fallback; + } + switch (fl_value_get_type(args)) { + case FL_VALUE_TYPE_FLOAT: + return fl_value_get_float(args); + case FL_VALUE_TYPE_INT: + return static_cast(fl_value_get_int(args)); + default: + return fallback; + } +} + +static const gchar* fl_method_string_arg(FlValue* args) { + return args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(args) + : nullptr; +} + +static void track_header_bar_pointer(MyApplication* self, + GtkHeaderBar* header_bar) { + self->header_bar = header_bar; + g_object_add_weak_pointer(G_OBJECT(header_bar), + reinterpret_cast(&self->header_bar)); +} + +static void track_widget_pointer(GtkWidget** target, GtkWidget* widget) { + *target = widget; + g_object_add_weak_pointer(G_OBJECT(widget), + reinterpret_cast(target)); +} + +static gboolean has_header_bar(MyApplication* self) { + return self->header_bar != nullptr && GTK_IS_HEADER_BAR(self->header_bar); +} + +static gboolean is_css_hex_color(const gchar* value) { + if (value == nullptr || strlen(value) != 7 || value[0] != '#') { + return FALSE; + } + for (int i = 1; i < 7; i++) { + if (!g_ascii_isxdigit(value[i])) { + return FALSE; + } + } + return TRUE; +} + +static gboolean is_css_rgba_color(const gchar* value) { + if (value == nullptr || !g_str_has_prefix(value, "rgba(")) { + return FALSE; + } + gint red = -1; + gint green = -1; + gint blue = -1; + gdouble alpha = -1; + gchar extra = 0; + if (sscanf(value, "rgba(%d,%d,%d,%lf)%c", &red, &green, &blue, &alpha, + &extra) != 4) { + return FALSE; + } + return red >= 0 && red <= 255 && green >= 0 && green <= 255 && blue >= 0 && + blue <= 255 && alpha >= 0 && alpha <= 1; +} + +static gboolean is_css_color_token(const gchar* value) { + return is_css_hex_color(value) || is_css_rgba_color(value); +} + +static const gchar* css_color_or(const gchar* value, const gchar* fallback) { + return is_css_color_token(value) ? value : fallback; +} + +static gchar* modal_barrier_color_for_depth(const gchar* color, gint depth) { + GdkRGBA barrier; + if (!gdk_rgba_parse(&barrier, color)) { + return g_strdup(color); + } + const gint effective_depth = std::max(0, depth); + barrier.alpha = + 1.0 - std::pow(1.0 - barrier.alpha, effective_depth); + return gdk_rgba_to_string(&barrier); +} + +static void set_flutter_view_background_color(MyApplication* self, + const gchar* color) { + if (self->flutter_view == nullptr || !FL_IS_VIEW(self->flutter_view) || + !is_css_color_token(color)) { + return; + } + + GdkRGBA background_color; + if (!gdk_rgba_parse(&background_color, color)) { + return; + } + fl_view_set_background_color(FL_VIEW(self->flutter_view), &background_color); +} + +static void set_main_flutter_view_background(MyApplication* self) { + set_flutter_view_background_color( + self, css_color_or(self->header_bar_window_background_color, + self->header_bar_background_color)); +} + +static gint header_sidebar_effective_width(MyApplication* self) { + if (!self->header_bar_can_show_sidebar || + !self->header_bar_sidebar_visible) { + return 0; + } + return self->header_bar_sidebar_width; +} + +static gboolean current_gtk_theme_uses_legacy_yaru_shadow() { + GtkSettings* settings = gtk_settings_get_default(); + if (settings == nullptr) { + return FALSE; + } + + g_autofree gchar* theme_name = nullptr; + g_object_get(settings, "gtk-theme-name", &theme_name, nullptr); + if (theme_name == nullptr) { + return FALSE; + } + + g_autofree gchar* normalized_theme = g_ascii_strdown(theme_name, -1); + const gboolean is_yaru = + g_strcmp0(normalized_theme, "yaru") == 0 || + g_str_has_prefix(normalized_theme, "yaru-"); + return is_yaru && strstr(normalized_theme, "highcontrast") == nullptr && + strstr(normalized_theme, "high-contrast") == nullptr; } static void refresh_header_bar_css(MyApplication* self) { @@ -600,294 +2050,438 @@ static void refresh_header_bar_css(MyApplication* self) { const gchar* background_color = self->header_bar_background_color; const gchar* window_background_color = css_color_or(self->header_bar_window_background_color, background_color); - const gchar* window_css_background_color = - self->main_window_transparent_backing ? "transparent" - : window_background_color; const gchar* sidebar_background_color = is_css_color_token(self->header_bar_sidebar_background_color) ? self->header_bar_sidebar_background_color : background_color; + const gchar* sidebar_border_color = css_color_or( + self->header_bar_sidebar_border_color, + kDefaultHeaderBarSidebarBorderColor); const gchar* foreground_color = css_color_or( self->header_bar_foreground_color, "rgba(255,255,255,0.86)"); - const gchar* foreground_disabled_color = - css_color_or(self->header_bar_disabled_foreground_color, - "rgba(255,255,255,0.38)"); - const gchar* muted_foreground_color = css_color_or( - self->header_bar_muted_foreground_color, "rgba(255,255,255,0.70)"); - const gchar* control_color = - css_color_or(self->header_bar_control_color, "rgba(255,255,255,0.10)"); - const gchar* control_hover_color = css_color_or( - self->header_bar_control_hover_color, "rgba(255,255,255,0.14)"); - const gchar* control_pressed_color = css_color_or( - self->header_bar_control_active_color, "rgba(255,255,255,0.18)"); - const gchar* accent_color = - css_color_or(self->header_bar_accent_color, control_pressed_color); - const gchar* accent_foreground_color = - css_color_or(self->header_bar_accent_foreground_color, foreground_color); - const gchar* popover_background_color = css_color_or( - self->header_bar_popover_background_color, background_color); - const gchar* border_color = - css_color_or(self->header_bar_border_color, "rgba(255,255,255,0.10)"); - const gchar* shade_color = - css_color_or(self->header_bar_shade_color, "rgba(0,0,0,0.28)"); - const gchar* modal_barrier_color = css_color_or( - self->header_bar_modal_barrier_color, "rgba(0,0,0,0.32)"); - const gint header_bar_left_radius = - header_sidebar_effective_width(self) > 0 ? 0 : kHeaderWindowRadius; - GtkWidget* header_bar = GTK_WIDGET(self->header_bar); - GtkStyleContext* context = gtk_widget_get_style_context(header_bar); - gtk_style_context_add_class(context, "busymax-flat-headerbar"); - - g_autofree gchar* css = g_strdup_printf( - "window#busymax-window," - "window#busymax-window:backdrop {" + const gchar* dialog_background_color = css_color_or( + self->header_bar_dialog_background_color, window_background_color); + g_autofree gchar* native_dialog_css = g_strdup_printf( + ".%s,.%s:backdrop {" "background-color: %s;" "background-image: none;" "}" - "window#busymax-window decoration," - "window#busymax-window decoration:backdrop {" - "background-color: transparent;" + ".%s headerbar," + ".%s headerbar:backdrop {" + "background-color: %s;" + "background-image: none;" + "box-shadow: none;" + "border-bottom-width: 0;" + "border-bottom-style: none;" + "border-bottom-color: transparent;" + "}" + ".%s .busymax-native-dialog-content," + ".%s .busymax-native-dialog-content:backdrop {" + "background-color: %s;" "background-image: none;" - "border: none;" - "box-shadow: 0 3px 18px 2px %s;" - "outline: none;" "border-radius: %dpx;" "}" - ".busymax-titlebar," - ".busymax-titlebar:backdrop," - "headerbar.busymax-flat-headerbar," - "headerbar.busymax-flat-headerbar:backdrop {" + ".%s.csd:not(.solid-csd):not(.maximized):not(.fullscreen) {" + // GTK 3 has no named modern dialog-outline role. Flutter supplies the + // shared semantic token so native confirmations and in-window dialogs + // retain the same restrained inside edge. + "box-shadow: inset 0 0 0 1px %s;" + "}", + kNativeDialogStyleClass, kNativeDialogStyleClass, + dialog_background_color, kNativeDialogStyleClass, + kNativeDialogStyleClass, dialog_background_color, + kNativeDialogStyleClass, kNativeDialogStyleClass, dialog_background_color, + kNativeDialogCornerRadius, kNativeDialogStyleClass, + css_color_or(self->header_bar_dialog_outline_color, + kDefaultDialogOutlineColor)); + g_autofree gchar* native_time_zone_dialog_css = g_strdup_printf( + "window.%s.%s.csd:not(.solid-csd):not(.maximized):not(.fullscreen)," + "window.%s.%s.csd:not(.solid-csd):not(.maximized):" + "not(.fullscreen):backdrop {" "background-color: %s;" "background-image: none;" "border: none;" + "border-radius: %dpx;" "box-shadow: none;" - "border-top-left-radius: %dpx;" - "border-top-right-radius: %dpx;" "}" - "headerbar.busymax-flat-headerbar," - "headerbar.busymax-flat-headerbar:backdrop {" - "border-top-left-radius: %dpx;" - "border-top-right-radius: %dpx;" - "padding-left: 0;" + "window.%s.%s.csd:not(.solid-csd):not(.maximized):not(.fullscreen) " + "> decoration," + "window.%s.%s.csd:not(.solid-csd):not(.maximized):" + "not(.fullscreen) > decoration:backdrop," + "window.%s.%s.csd:not(.solid-csd):not(.maximized):" + "not(.fullscreen) > decoration-overlay," + "window.%s.%s.csd:not(.solid-csd):not(.maximized):" + "not(.fullscreen) > decoration-overlay:backdrop {" + "border: none;" + "border-radius: %dpx;" + "box-shadow: none;" "}" - ".busymax-titlebar .busymax-header-brand {" + "window.%s.%s .busymax-native-dialog-content," + "window.%s.%s .busymax-native-dialog-content:backdrop {" "background-color: %s;" "background-image: none;" - "border: none;" + "border-radius: 0 0 %dpx %dpx;" + "}", + kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, + kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, + dialog_background_color, kNativeDialogCornerRadius, + kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, + kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, + kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, + kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, + kNativeDialogCornerRadius, kNativeDialogStyleClass, + kNativeTimeZoneDialogStyleClass, kNativeDialogStyleClass, + kNativeTimeZoneDialogStyleClass, dialog_background_color, + kNativeDialogCornerRadius, kNativeDialogCornerRadius); + // Modal blocking and visual shade depth are separate. A transparent nested + // Flutter barrier must continue blocking the native header without adding + // another black layer. + g_autofree gchar* modal_barrier_color = modal_barrier_color_for_depth( + css_color_or(self->header_bar_modal_barrier_color, + kDefaultModalBarrierColor), + self->header_bar_modal_barrier_shade_depth); + const gboolean use_legacy_yaru_compatibility = + !self->header_bar_high_contrast && + current_gtk_theme_uses_legacy_yaru_shadow(); + g_autofree gchar* native_search_geometry_css = + use_legacy_yaru_compatibility + ? g_strdup_printf( + "entry.search.%s {" + "border-radius: 9px;" + "}", + kHeaderSearchEntryStyleClass) + : g_strdup(""); + const gchar* tooltip_background = css_color_or( + self->header_bar_tooltip_background_color, kDefaultTooltipBackground); + const gchar* tooltip_foreground = css_color_or( + self->header_bar_tooltip_foreground_color, kDefaultTooltipForeground); + const gchar* tooltip_border = css_color_or( + self->header_bar_tooltip_border_color, kDefaultTooltipBorder); + const gdouble tooltip_label_horizontal_padding = std::max( + 0.0, self->header_bar_tooltip_horizontal_padding - + (kGtkTooltipContainerInset - kTooltipBorderWidth)); + const gdouble tooltip_label_vertical_padding = std::max( + 0.0, self->header_bar_tooltip_vertical_padding - + (kGtkTooltipContainerInset - kTooltipBorderWidth)); + const gdouble tooltip_label_minimum_height = std::max( + 0.0, self->header_bar_tooltip_minimum_height - + kGtkTooltipContainerInset * 2 - + tooltip_label_vertical_padding * 2); + g_autofree gchar* tooltip_css = g_strdup_printf( + "tooltip," + "tooltip.background," + "tooltip box," + "tooltip.background box {" + "margin: 0;" + "padding: 0;" + "min-width: 0;" + "min-height: 0;" + "}" + "tooltip.background {" + "background-color: %s;" + "background-image: none;" + "background-clip: padding-box;" + "border: %.2fpx solid %s;" + "border-radius: %.2fpx;" + "}" + "tooltip decoration," + "tooltip.csd decoration {" + "background-color: transparent;" + "border-radius: %.2fpx;" "box-shadow: none;" - "border-top-left-radius: %dpx;" - "border-top-right-radius: 0;" "}" - ".busymax-titlebar .busymax-header-brand label {" + "tooltip * {" + "background-color: transparent;" "color: %s;" "}" - ".busymax-titlebar .busymax-header-title {" + "tooltip label," + "tooltip.background label {" + "margin: 0;" + "padding: %.2fpx %.2fpx;" + "min-width: 0;" + "min-height: %.2fpx;" + "font-size: %.2fpx;" + "font-weight: 400;" + "}", + tooltip_background, kTooltipBorderWidth, tooltip_border, + self->header_bar_tooltip_radius, + self->header_bar_tooltip_radius, tooltip_foreground, + tooltip_label_vertical_padding, tooltip_label_horizontal_padding, + tooltip_label_minimum_height, self->header_bar_tooltip_font_size); + g_autofree gchar* header_focus_css = g_strdup_printf( + ".busymax-titlebar.%s .busymax-header-brand label," + ".busymax-titlebar.%s .busymax-header-title {" "color: %s;" "}" - ".busymax-titlebar.busymax-modal-barrier," - ".busymax-titlebar.busymax-modal-barrier:backdrop {" - "background-color: %s;" - "background-image: linear-gradient(%s, %s);" + ".busymax-titlebar.%s .busymax-header-brand label," + ".busymax-titlebar.%s .busymax-header-title {" + "color: alpha(%s, %.2f);" + "}" + ".busymax-titlebar.%s " + ".busymax-header-control:not(:disabled)," + ".busymax-titlebar.%s " + "headerbar button.titlebutton:not(:disabled) {" + "color: %s;" + "-gtk-icon-effect: none;" + "}" + ".busymax-titlebar.%s " + ".busymax-header-control:not(:disabled)," + ".busymax-titlebar.%s " + "headerbar button.titlebutton:not(:disabled) {" + "color: alpha(%s, %.2f);" + "-gtk-icon-effect: none;" "}" - ".busymax-titlebar.busymax-modal-barrier .busymax-header-brand," - ".busymax-titlebar.busymax-modal-barrier " - ".busymax-header-brand:backdrop {" + ".busymax-titlebar.%s .busymax-header-control:disabled," + ".busymax-titlebar.%s headerbar button.titlebutton:disabled {" + "color: alpha(%s, %.2f);" + "-gtk-icon-effect: none;" + "}" + ".busymax-titlebar.%s .busymax-header-control:disabled," + ".busymax-titlebar.%s headerbar button.titlebutton:disabled {" + "color: alpha(%s, %.2f);" + "-gtk-icon-effect: none;" + "}", + kHeaderApplicationActiveStyleClass, + kHeaderApplicationActiveStyleClass, foreground_color, + kHeaderApplicationBackdropStyleClass, + kHeaderApplicationBackdropStyleClass, foreground_color, + kHeaderBackdropForegroundOpacity, + kHeaderApplicationActiveStyleClass, + kHeaderApplicationActiveStyleClass, foreground_color, + kHeaderApplicationBackdropStyleClass, + kHeaderApplicationBackdropStyleClass, foreground_color, + kHeaderBackdropForegroundOpacity, + kHeaderApplicationActiveStyleClass, + kHeaderApplicationActiveStyleClass, foreground_color, + kHeaderDisabledForegroundOpacity, + kHeaderApplicationBackdropStyleClass, + kHeaderApplicationBackdropStyleClass, foreground_color, + kHeaderDisabledBackdropForegroundOpacity); + g_autofree gchar* yaru_window_decoration_css = + use_legacy_yaru_compatibility + ? g_strdup_printf( + "window#busymax-window.csd:not(.solid-csd):" + "not(.maximized):not(.fullscreen):not(.tiled):" + "not(.tiled-top):not(.tiled-right):not(.tiled-bottom):" + "not(.tiled-left) > decoration {" + // Keep Yaru GTK 3's native diffuse shadow, but omit its legacy + // zero-blur outline. Current GTK 4/libadwaita Ubuntu apps use + // a much subtler edge, while Handy remains responsible for + // radius, clipping, and window-state geometry. + "box-shadow: 0 3px 9px 1px rgba(0,0,0,0.5);" + "}" + "window#busymax-window.csd:not(.solid-csd):" + "not(.maximized):not(.fullscreen):not(.tiled):" + "not(.tiled-top):not(.tiled-right):not(.tiled-bottom):" + "not(.tiled-left) > decoration:backdrop {" + "box-shadow: 0 3px 9px 1px transparent," + "0 2px 6px 2px rgba(0,0,0,0.2);" + "}" + "window#busymax-window.csd.tiled:not(.solid-csd):" + "not(.maximized):not(.fullscreen) > decoration," + "window#busymax-window.csd.tiled-top:not(.solid-csd):" + "not(.maximized):not(.fullscreen) > decoration," + "window#busymax-window.csd.tiled-right:not(.solid-csd):" + "not(.maximized):not(.fullscreen) > decoration," + "window#busymax-window.csd.tiled-bottom:not(.solid-csd):" + "not(.maximized):not(.fullscreen) > decoration," + "window#busymax-window.csd.tiled-left:not(.solid-csd):" + "not(.maximized):not(.fullscreen) > decoration {" + "box-shadow: 0 0 0 20px transparent;" + "}" + "messagedialog.%s.csd:not(.solid-csd):" + "not(.maximized):not(.fullscreen) > decoration {" + // Yaru GTK 3 adds a 65%-black zero-blur ring to message + // dialogs. Translate the current libadwaita message-dialog + // shadow to GTK 3's decoration node, leaving GTK in charge of + // every control, radius, layout, focus state, and action role. + "box-shadow: 0 0 14px 2px rgba(0,0,6,0.03)," + "0 0 5px 2px rgba(0,0,6,0.10)," + "0 0 0 1px rgba(0,0,0,0.05);" + "}" + "window.%s.%s.csd:not(.solid-csd):" + "not(.maximized):not(.fullscreen) > decoration {" + "box-shadow: 0 0 14px 2px rgba(0,0,6,0.03)," + "0 0 5px 2px rgba(0,0,6,0.10);" + "}", + kNativeDialogStyleClass, kNativeDialogStyleClass, + kNativeTimeZoneDialogStyleClass) + : g_strdup(""); + GtkWidget* header_bar = GTK_WIDGET(self->header_bar); + GtkStyleContext* context = gtk_widget_get_style_context(header_bar); + gtk_style_context_add_class(context, "busymax-flat-headerbar"); + g_autofree gchar* css = g_strdup_printf( + "window#busymax-window," + "window#busymax-window:backdrop {" "background-color: %s;" - "background-image: linear-gradient(%s, %s);" + "background-image: none;" "}" - ".busymax-titlebar.busymax-modal-barrier " + "%s" + "%s" + "%s" + "%s" + "%s" "headerbar.busymax-flat-headerbar," - ".busymax-titlebar.busymax-modal-barrier " "headerbar.busymax-flat-headerbar:backdrop {" "background-color: %s;" - "background-image: linear-gradient(%s, %s);" - "}" - ".busymax-titlebar button.busymax-header-button," - ".busymax-titlebar button.busymax-header-view-mode-button {" - "color: %s;" - "background-color: %s;" "background-image: none;" + "color: %s;" "border: none;" - "border-width: 0;" - "border-color: transparent;" - "border-image: none;" - "outline-color: transparent;" - "outline-style: none;" - "outline-width: 0;" "box-shadow: none;" - "text-shadow: none;" - "-gtk-icon-shadow: none;" - "transition: none;" - "min-height: %dpx;" - "min-width: %dpx;" - "padding: 0 %dpx;" - "border-radius: %dpx;" "}" - ".busymax-titlebar button.busymax-header-button:hover," - ".busymax-titlebar button.busymax-header-view-mode-button:hover {" - "background-color: %s;" + "headerbar.busymax-flat-headerbar," + "headerbar.busymax-flat-headerbar:backdrop {" + "padding-left: 0;" "}" - ".busymax-titlebar button.busymax-header-button:active," - ".busymax-titlebar button.busymax-header-button:checked," - ".busymax-titlebar button.busymax-header-view-mode-button:checked," - ".busymax-titlebar button.busymax-header-view-mode-button:active {" + ".busymax-titlebar .busymax-header-brand {" "background-color: %s;" - "}" - ".busymax-titlebar button.busymax-header-button:disabled," - ".busymax-titlebar button.busymax-header-view-mode-button:disabled {" + "background-image: none;" "color: %s;" - "background-color: transparent;" + "border-right: 1px solid %s;" "}" - ".busymax-titlebar button.busymax-header-primary-button {" + ".busymax-titlebar .busymax-header-brand label {" "color: %s;" - "background-color: %s;" + "font-weight: 800;" "}" - ".busymax-titlebar button.busymax-header-primary-button:hover," - ".busymax-titlebar button.busymax-header-primary-button:active," - ".busymax-titlebar button.busymax-header-primary-button:checked {" - "color: %s;" - "background-color: %s;" + ".busymax-titlebar .busymax-header-brand label:backdrop {" + "color: alpha(%s, %.2f);" "}" - ".busymax-titlebar button.busymax-header-primary-button:disabled {" + ".busymax-titlebar .busymax-header-title {" "color: %s;" - "background-color: transparent;" - "}" - ".busymax-titlebar .busymax-header-brand " - "button.busymax-brand-action-button," - ".busymax-titlebar .busymax-header-brand " - "button.busymax-brand-action-button:checked," - ".busymax-titlebar .busymax-header-brand " - "button.busymax-brand-action-button:active {" - "background-color: transparent;" - "}" - ".busymax-titlebar .busymax-header-brand " - "button.busymax-brand-action-button:hover {" - "background-color: %s;" - "}" - ".busymax-titlebar button.busymax-header-button.busymax-sidebar-toggle," - ".busymax-titlebar button.busymax-header-button.busymax-sidebar-toggle:checked," - ".busymax-titlebar button.busymax-header-button.busymax-sidebar-toggle:active {" - "background-color: transparent;" "}" - ".busymax-titlebar button.busymax-header-button.busymax-sidebar-toggle:hover {" - "background-color: %s;" + ".busymax-titlebar .busymax-header-title:backdrop {" + "color: alpha(%s, %.2f);" "}" - ".busymax-titlebar.busymax-modal-barrier label," - ".busymax-titlebar.busymax-modal-barrier button," - ".busymax-titlebar.busymax-modal-barrier button image {" + // GTK themes can assign an absolute backdrop foreground directly to + // buttons, overriding the semantic foreground inherited from the + // headerbar. Keep enabled BusyMax controls and GTK-generated window + // controls on the same semantic roles and native opacity metrics in + // focused, backdrop, and disabled states. + ".busymax-titlebar " + ".busymax-header-control:not(:disabled)," + ".busymax-titlebar " + "headerbar button.titlebutton:not(:disabled) {" "color: %s;" - "text-shadow: none;" - "-gtk-icon-shadow: none;" + "-gtk-icon-effect: none;" "}" - ".busymax-titlebar button.busymax-header-icon-button {" - "min-width: %dpx;" - "padding-left: 0;" - "padding-right: 0;" + ".busymax-titlebar " + ".busymax-header-control:not(:disabled):backdrop," + ".busymax-titlebar " + "headerbar button.titlebutton:not(:disabled):backdrop {" + "color: alpha(%s, %.2f);" + "-gtk-icon-effect: none;" "}" - "popover.busymax-header-popover," - "popover.background.busymax-header-popover," - ".busymax-header-popover.background," - "popover.busymax-header-popover:backdrop," - "popover.background.busymax-header-popover:backdrop," - ".busymax-header-popover.background:backdrop," - "popover.background.busymax-header-popover > contents," - ".busymax-header-popover.background > contents," - "popover.background.busymax-header-popover arrow," - ".busymax-header-popover.background arrow {" - "background-color: %s;" - "color: %s;" + ".busymax-titlebar " + ".busymax-header-control:disabled," + ".busymax-titlebar " + "headerbar button.titlebutton:disabled {" + "color: alpha(%s, %.2f);" + "-gtk-icon-effect: none;" "}" - "popover.busymax-header-popover > contents," - ".busymax-header-popover.background > contents {" - "border: 1px solid %s;" - "box-shadow: 0 6px 18px %s;" + ".busymax-titlebar " + ".busymax-header-control:disabled:backdrop," + ".busymax-titlebar " + "headerbar button.titlebutton:disabled:backdrop {" + "color: alpha(%s, %.2f);" + "-gtk-icon-effect: none;" "}" - "popover.busymax-header-popover button.busymax-header-popover-row {" - "color: %s;" + "%s" + // Yaru GTK 3 paints pressed and checked buttons with an absolute + // near-black image. That legacy state is incompatible with BusyMax's + // semantic header surfaces. Scope modern Yaru/libadwaita current-color + // layers to BusyMax controls so native focus and geometry remain + // GTK-owned without leaking an absolute palette color. + ".busymax-titlebar " + ".busymax-header-control:not(.suggested-action):not(:disabled) {" "background-color: transparent;" "background-image: none;" - "border: none;" - "border-width: 0;" "border-color: transparent;" - "border-image: none;" - "outline-color: transparent;" - "outline-style: none;" - "outline-width: 0;" - "outline-offset: 0;" "box-shadow: none;" - "text-shadow: none;" - "-gtk-icon-shadow: none;" - "transition: none;" - "min-height: %dpx;" - "padding: 0 %dpx;" - "border-radius: %dpx;" "}" - "popover.busymax-header-popover " - "button.busymax-header-popover-row:hover {" - "background-color: %s;" + ".busymax-titlebar " + ".busymax-header-control:not(.suggested-action):not(:disabled):hover {" + "background-color: alpha(currentColor, 0.07);" + "background-image: none;" "}" - "popover.busymax-header-popover " - "button.busymax-header-popover-row:focus {" - "border-color: transparent;" - "outline-color: transparent;" - "outline-style: none;" - "outline-width: 0;" - "box-shadow: none;" + ".busymax-titlebar " + ".busymax-header-control:not(.suggested-action):not(:disabled):active {" + "background-color: alpha(currentColor, 0.16);" + "background-image: none;" "}" - "popover.busymax-header-popover " - "button.busymax-header-popover-row:active," - "popover.busymax-header-popover " - "button.busymax-header-popover-row:checked {" - "background-color: transparent;" + ".busymax-titlebar " + ".busymax-header-control:not(.suggested-action):not(:disabled):checked {" + "background-color: alpha(currentColor, 0.10);" + "background-image: none;" "}" - "popover.busymax-header-popover " - "button.busymax-header-popover-row label {" - "color: %s;" + ".busymax-titlebar " + ".busymax-header-control:not(.suggested-action):" + "not(:disabled):checked:hover {" + "background-color: alpha(currentColor, 0.13);" + "background-image: none;" "}" - "popover.busymax-header-popover " - "button.busymax-header-popover-row image {" - "color: %s;" + ".busymax-titlebar " + ".busymax-header-control:not(.suggested-action):" + "not(:disabled):checked:active {" + "background-color: alpha(currentColor, 0.19);" + "background-image: none;" "}" - "tooltip," - "tooltip.background {" - "margin: 0;" - "padding: 0;" + ".busymax-titlebar .busymax-onboarding-text-button," + ".busymax-titlebar .busymax-onboarding-text-button:hover," + ".busymax-titlebar .busymax-onboarding-text-button:active," + ".busymax-titlebar .busymax-onboarding-text-button:disabled {" + "min-width: 0;" "min-height: 0;" - "border-radius: %dpx;" - "box-shadow: 0 5px 18px 2px %s;" - "}" - "tooltip > box," - "tooltip.background > box {" - "margin: 0;" "padding: 0;" - "min-height: 0;" + "border: none;" + "background-color: transparent;" + "background-image: none;" + "box-shadow: none;" "}" - "tooltip label {" - "margin: 0;" - "padding: %dpx %dpx;" - "min-height: 0;" - "border-radius: %dpx;" + ".busymax-titlebar .busymax-onboarding-text-button:hover label {" + "text-decoration-line: underline;" + "}" + // While a modal route is present, transient and checked control + // surfaces must not remain painted above the dimmed titlebar. The + // controls stay sensitive so GTK does not substitute disabled colors; + // the full-size input shield below owns interaction blocking. + ".busymax-titlebar.%s " + ".busymax-header-control," + ".busymax-titlebar.%s " + ".busymax-header-control:hover," + ".busymax-titlebar.%s " + ".busymax-header-control:active," + ".busymax-titlebar.%s " + ".busymax-header-control:checked," + ".busymax-titlebar.%s " + ".busymax-header-control:checked:hover," + ".busymax-titlebar.%s " + ".busymax-header-control:checked:active {" + "background-color: transparent;" + "background-image: none;" + "border-color: transparent;" + "box-shadow: none;" + "}" + ".busymax-titlebar .%s," + ".busymax-titlebar .%s:backdrop {" + "background-color: %s;" + "background-image: none;" "}", - window_css_background_color, shade_color, kHeaderWindowRadius, - background_color, kHeaderWindowRadius, kHeaderWindowRadius, - header_bar_left_radius, kHeaderWindowRadius, sidebar_background_color, - kHeaderWindowRadius, foreground_color, foreground_color, - background_color, modal_barrier_color, modal_barrier_color, - sidebar_background_color, modal_barrier_color, modal_barrier_color, - background_color, modal_barrier_color, modal_barrier_color, - foreground_color, control_color, kHeaderButtonHeight, - kHeaderButtonHeight, kHeaderButtonHorizontalPadding, kHeaderButtonRadius, - control_hover_color, control_pressed_color, foreground_disabled_color, - accent_foreground_color, accent_color, accent_foreground_color, - accent_color, foreground_disabled_color, - control_hover_color, control_hover_color, foreground_disabled_color, - kHeaderButtonHeight, - popover_background_color, foreground_color, foreground_color, - border_color, shade_color, kHeaderButtonHeight, - kHeaderButtonHorizontalPadding, kHeaderButtonRadius, - control_hover_color, foreground_color, muted_foreground_color, - kHeaderButtonRadius, shade_color, kHeaderTooltipVerticalPadding, - kHeaderTooltipHorizontalPadding, kHeaderButtonRadius); + window_background_color, yaru_window_decoration_css, + native_dialog_css, native_time_zone_dialog_css, + native_search_geometry_css, tooltip_css, + background_color, foreground_color, + sidebar_background_color, foreground_color, sidebar_border_color, + foreground_color, foreground_color, kHeaderBackdropForegroundOpacity, + foreground_color, foreground_color, kHeaderBackdropForegroundOpacity, + foreground_color, foreground_color, kHeaderBackdropForegroundOpacity, + foreground_color, kHeaderDisabledForegroundOpacity, foreground_color, + kHeaderDisabledBackdropForegroundOpacity, + header_focus_css, + kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, + kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, + kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, + kHeaderModalBarrierStyleClass, + kHeaderModalBarrierStyleClass, modal_barrier_color); g_autoptr(GError) error = nullptr; GtkCssProvider* provider = gtk_css_provider_new(); @@ -922,82 +2516,339 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { return; } + self->header_bar_theme_received = TRUE; gboolean prefer_dark = FALSE; if (fl_lookup_optional_bool_arg(args, "preferDark", &prefer_dark)) { set_gtk_theme_preference(prefer_dark); } + fl_lookup_optional_bool_arg(args, "highContrast", + &self->header_bar_high_contrast); set_css_color_field(&self->header_bar_window_background_color, fl_lookup_string_arg(args, "windowBackgroundColor")); set_css_color_field(&self->header_bar_background_color, fl_lookup_string_arg(args, "backgroundColor")); set_css_color_field(&self->header_bar_sidebar_background_color, fl_lookup_string_arg(args, "sidebarBackgroundColor")); + set_css_color_field(&self->header_bar_sidebar_border_color, + fl_lookup_string_arg(args, "sidebarBorderColor")); set_css_color_field(&self->header_bar_foreground_color, fl_lookup_string_arg(args, "foregroundColor")); - set_css_color_field(&self->header_bar_muted_foreground_color, - fl_lookup_string_arg(args, "mutedForegroundColor")); - set_css_color_field(&self->header_bar_disabled_foreground_color, - fl_lookup_string_arg(args, "disabledForegroundColor")); - set_css_color_field(&self->header_bar_control_color, - fl_lookup_string_arg(args, "controlColor")); - set_css_color_field(&self->header_bar_control_hover_color, - fl_lookup_string_arg(args, "controlHoverColor")); - set_css_color_field(&self->header_bar_control_active_color, - fl_lookup_string_arg(args, "controlActiveColor")); - set_css_color_field(&self->header_bar_accent_color, - fl_lookup_string_arg(args, "accentColor")); - set_css_color_field(&self->header_bar_accent_foreground_color, - fl_lookup_string_arg(args, "accentForegroundColor")); - set_css_color_field(&self->header_bar_popover_background_color, - fl_lookup_string_arg(args, "popoverBackgroundColor")); - set_css_color_field(&self->header_bar_border_color, - fl_lookup_string_arg(args, "borderColor")); - set_css_color_field(&self->header_bar_shade_color, - fl_lookup_string_arg(args, "shadeColor")); + set_css_color_field(&self->header_bar_dialog_background_color, + fl_lookup_string_arg(args, "dialogBackgroundColor")); + set_css_color_field(&self->header_bar_dialog_outline_color, + fl_lookup_string_arg(args, "dialogOutlineColor")); set_css_color_field(&self->header_bar_modal_barrier_color, fl_lookup_string_arg(args, "modalBarrierColor")); + FlValue* tooltip = fl_lookup_map_arg(args, "tooltip"); + if (tooltip != nullptr) { + set_css_color_field( + &self->header_bar_tooltip_background_color, + fl_lookup_string_arg(tooltip, "backgroundColor")); + set_css_color_field( + &self->header_bar_tooltip_foreground_color, + fl_lookup_string_arg(tooltip, "foregroundColor")); + set_css_color_field(&self->header_bar_tooltip_border_color, + fl_lookup_string_arg(tooltip, "borderColor")); + update_bounded_double_arg(tooltip, "borderRadius", 0, 64, + &self->header_bar_tooltip_radius); + update_bounded_double_arg(tooltip, "fontSize", 1, 64, + &self->header_bar_tooltip_font_size); + update_bounded_double_arg(tooltip, "horizontalPadding", 0, 64, + &self->header_bar_tooltip_horizontal_padding); + update_bounded_double_arg(tooltip, "verticalPadding", 0, 64, + &self->header_bar_tooltip_vertical_padding); + update_bounded_double_arg(tooltip, "minimumHeight", 1, 128, + &self->header_bar_tooltip_minimum_height); + } set_main_flutter_view_background(self); refresh_header_bar_css(self); } -static void set_header_bar_modal_barrier_visible(MyApplication* self, - gboolean visible) { - self->header_bar_modal_barrier_visible = visible; - if (self->titlebar_box != nullptr && GTK_IS_WIDGET(self->titlebar_box)) { - GtkStyleContext* context = gtk_widget_get_style_context(self->titlebar_box); - if (visible) { - gtk_style_context_add_class(context, "busymax-modal-barrier"); - } else { - gtk_style_context_remove_class(context, "busymax-modal-barrier"); - } - gtk_widget_set_sensitive(self->titlebar_box, !visible); +static void close_header_menu_button(GtkWidget* menu_button); +static void focus_flutter_view(MyApplication* self); + +static gboolean refresh_header_bar_focus_state_cb(gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (self->titlebar_handle == nullptr || + !GTK_IS_WIDGET(self->titlebar_handle)) { + return G_SOURCE_REMOVE; + } + + const gboolean application_active = + (self->main_window != nullptr && + gtk_window_is_active(self->main_window)) || + (self->header_focus_transient_window != nullptr && + gtk_window_is_active(self->header_focus_transient_window)); + GtkStyleContext* context = + gtk_widget_get_style_context(self->titlebar_handle); + gtk_style_context_remove_class( + context, kHeaderApplicationActiveStyleClass); + gtk_style_context_remove_class( + context, kHeaderApplicationBackdropStyleClass); + gtk_style_context_add_class( + context, + application_active ? kHeaderApplicationActiveStyleClass + : kHeaderApplicationBackdropStyleClass); + + // The headerbar is embedded above Flutter rather than installed as + // GtkWindow's titlebar. Reset its subtree after the compositor's focus + // transfer settles so :backdrop declarations cannot remain one event late. + gtk_widget_reset_style(self->titlebar_handle); + gtk_widget_queue_draw(self->titlebar_handle); + return G_SOURCE_REMOVE; +} + +static void schedule_header_bar_focus_state_refresh(MyApplication* self) { + g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, refresh_header_bar_focus_state_cb, + g_object_ref(self), g_object_unref); +} + +static void set_header_bar_modal_barrier_state(MyApplication* self, + gboolean visible, + gint shade_depth) { + const gint effective_shade_depth = + visible ? std::max(0, shade_depth) : 0; + if (self->header_bar_modal_barrier_visible == visible && + self->header_bar_modal_barrier_shade_depth == + effective_shade_depth) { + return; + } + const gboolean shade_changed = + self->header_bar_modal_barrier_shade_depth != effective_shade_depth; + self->header_bar_modal_barrier_visible = visible; + self->header_bar_modal_barrier_shade_depth = effective_shade_depth; + if (shade_changed) { + refresh_header_bar_css(self); + } + if (self->titlebar_handle != nullptr && + GTK_IS_WIDGET(self->titlebar_handle)) { + GtkStyleContext* context = + gtk_widget_get_style_context(self->titlebar_handle); + if (visible) { + gtk_style_context_add_class(context, kHeaderModalOpenStyleClass); + } else { + gtk_style_context_remove_class(context, kHeaderModalOpenStyleClass); + } + } + if (self->titlebar_modal_barrier != nullptr && + GTK_IS_WIDGET(self->titlebar_modal_barrier)) { + gtk_widget_set_visible(self->titlebar_modal_barrier, visible); + } + if (visible) { + close_header_menu_button(self->settings_menu_button); + close_header_menu_button(self->view_mode_button); + close_header_menu_button(self->create_button); + focus_flutter_view(self); + } +} + +static void set_header_bar_modal_barrier_visible(MyApplication* self, + gboolean visible) { + set_header_bar_modal_barrier_state(self, visible, visible ? 1 : 0); +} + +static void set_header_bar_modal_barrier_depth(MyApplication* self, + gint depth) { + const gint effective_depth = std::max(0, depth); + set_header_bar_modal_barrier_state( + self, effective_depth > 0, effective_depth); +} + +static void clear_header_bar_pointer(MyApplication* self) { + if (self->header_bar != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(self->header_bar), + reinterpret_cast(&self->header_bar)); + self->header_bar = nullptr; + } +} + +static void clear_widget_pointer(GtkWidget** target) { + if (*target != nullptr) { + g_object_remove_weak_pointer(G_OBJECT(*target), + reinterpret_cast(target)); + *target = nullptr; + } +} + +static void invoke_header_bar_action(MyApplication* self, + const gchar* action) { + if (self->header_bar_modal_barrier_visible || + self->header_bar_channel == nullptr || action == nullptr) { + return; + } + fl_method_channel_invoke_method(self->header_bar_channel, action, nullptr, + nullptr, nullptr, nullptr); +} + +static void invoke_header_bar_string_action(MyApplication* self, + const gchar* action, + const gchar* value) { + if (self->header_bar_modal_barrier_visible || + self->header_bar_channel == nullptr || action == nullptr) { + return; + } + g_autoptr(FlValue) args = fl_value_new_string(value == nullptr ? "" : value); + fl_method_channel_invoke_method(self->header_bar_channel, action, args, + nullptr, nullptr, nullptr); +} + +static void invoke_header_bar_bool_action(MyApplication* self, + const gchar* action, + gboolean value) { + if (self->header_bar_modal_barrier_visible || + self->header_bar_channel == nullptr || action == nullptr) { + return; + } + g_autoptr(FlValue) args = fl_value_new_bool(value); + fl_method_channel_invoke_method(self->header_bar_channel, action, args, + nullptr, nullptr, nullptr); +} + +enum class HeaderSearchQueryUpdateDisposition { + kAlreadyCurrent, + kPreserveNativeText, + kApplyDartSnapshot, +}; + +constexpr HeaderSearchQueryUpdateDisposition +resolve_header_search_query_update(bool queries_match, + bool native_entry_has_authority, + bool incoming_state_is_active) { + if (queries_match) { + return HeaderSearchQueryUpdateDisposition::kAlreadyCurrent; + } + return native_entry_has_authority && incoming_state_is_active + ? HeaderSearchQueryUpdateDisposition::kPreserveNativeText + : HeaderSearchQueryUpdateDisposition::kApplyDartSnapshot; +} + +static_assert( + resolve_header_search_query_update(false, true, true) == + HeaderSearchQueryUpdateDisposition::kPreserveNativeText, + "A newer focused native edit must survive a delayed Dart snapshot"); +static_assert( + resolve_header_search_query_update(false, false, true) == + HeaderSearchQueryUpdateDisposition::kApplyDartSnapshot, + "Dart owns search text while the native entry is not being edited"); +static_assert( + resolve_header_search_query_update(false, true, false) == + HeaderSearchQueryUpdateDisposition::kApplyDartSnapshot, + "Deactivation must reconcile the native entry with Dart's final query"); + +static void cache_header_search_query(MyApplication* self, + const gchar* query) { + const gchar* normalized_query = query == nullptr ? "" : query; + if (g_strcmp0(self->header_search_query, normalized_query) == 0) { + return; + } + g_free(self->header_search_query); + self->header_search_query = g_strdup(normalized_query); +} + +static void set_header_search_query(MyApplication* self, + const gchar* query, + gboolean incoming_state_is_active) { + const gchar* normalized_query = query == nullptr ? "" : query; + if (self->search_entry == nullptr || !GTK_IS_ENTRY(self->search_entry)) { + cache_header_search_query(self, normalized_query); + return; + } + const gchar* current_query = + gtk_entry_get_text(GTK_ENTRY(self->search_entry)); + const bool native_entry_has_authority = + self->header_search_active && + gtk_widget_has_focus(self->search_entry); + switch (resolve_header_search_query_update( + g_strcmp0(current_query, normalized_query) == 0, + native_entry_has_authority, incoming_state_is_active)) { + case HeaderSearchQueryUpdateDisposition::kAlreadyCurrent: + cache_header_search_query(self, normalized_query); + return; + case HeaderSearchQueryUpdateDisposition::kPreserveNativeText: + // GtkSearchEntry emits search-changed after a short delay. Dart can + // therefore publish an older mirrored snapshot after the user has + // already typed more text. While the active entry has focus, native + // text is authoritative. Do not update the cache here: a pending + // search-changed signal still needs to publish the newer native text. + return; + case HeaderSearchQueryUpdateDisposition::kApplyDartSnapshot: + break; + } + + cache_header_search_query(self, normalized_query); + const gboolean previous_suppression = self->suppress_header_bar_actions; + self->suppress_header_bar_actions = TRUE; + gtk_entry_set_text(GTK_ENTRY(self->search_entry), normalized_query); + self->suppress_header_bar_actions = previous_suppression; +} + +static void header_search_entry_search_changed_cb(GtkSearchEntry* entry, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (self->suppress_header_bar_actions || + self->header_bar_modal_barrier_visible || + !self->header_search_active) { + return; + } + const gchar* query = gtk_entry_get_text(GTK_ENTRY(entry)); + if (g_strcmp0(self->header_search_query, query) == 0) { + return; + } + cache_header_search_query(self, query); + invoke_header_bar_string_action(self, "searchQueryChanged", query); +} + +static gboolean header_search_entry_focus_in_cb(GtkWidget*, + GdkEventFocus*, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (!self->header_bar_modal_barrier_visible) { + invoke_header_bar_bool_action(self, "searchFocusChanged", TRUE); } + return FALSE; } -static void clear_header_bar_pointer(MyApplication* self) { - if (self->header_bar != nullptr) { - g_object_remove_weak_pointer( - G_OBJECT(self->header_bar), - reinterpret_cast(&self->header_bar)); - self->header_bar = nullptr; +static gboolean header_search_entry_focus_out_cb(GtkWidget*, + GdkEventFocus*, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (!self->header_bar_modal_barrier_visible) { + invoke_header_bar_bool_action(self, "searchFocusChanged", FALSE); } + return FALSE; } -static void clear_widget_pointer(GtkWidget** target) { - if (*target != nullptr) { - g_object_remove_weak_pointer(G_OBJECT(*target), - reinterpret_cast(target)); - *target = nullptr; +static void header_search_entry_icon_release_cb( + GtkEntry* entry, + GtkEntryIconPosition icon_position, + GdkEvent*, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (self->suppress_header_bar_actions || + self->header_bar_modal_barrier_visible || + !self->header_search_active || + icon_position != GTK_ENTRY_ICON_SECONDARY || + gtk_entry_get_text(entry)[0] == '\0') { + return; } + + // GtkSearchEntry clears its native secondary icon after this signal. Cache + // the semantic result now so its delayed search-changed signal is deduped. + cache_header_search_query(self, ""); + invoke_header_bar_action(self, "searchCleared"); } -static void invoke_header_bar_action(MyApplication* self, - const gchar* action) { - if (self->header_bar_channel == nullptr || action == nullptr) { +static void header_search_entry_stop_search_cb(GtkSearchEntry*, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (self->suppress_header_bar_actions || + self->header_bar_modal_barrier_visible || + !self->header_search_active) { return; } - fl_method_channel_invoke_method(self->header_bar_channel, action, nullptr, - nullptr, nullptr, nullptr); + invoke_header_bar_action(self, "searchEscapePressed"); } static void focus_flutter_view(MyApplication* self) { @@ -1009,7 +2860,8 @@ static void focus_flutter_view(MyApplication* self) { static void header_bar_action_clicked_cb(GtkWidget* widget, gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_bar_actions) { + if (self->suppress_header_bar_actions || + self->header_bar_modal_barrier_visible) { return; } const gchar* action = static_cast( @@ -1031,62 +2883,326 @@ static void close_header_menu_button(GtkWidget* menu_button) { if (menu_button == nullptr || !GTK_IS_MENU_BUTTON(menu_button)) { return; } - GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(menu_button)); - if (popover != nullptr && GTK_IS_POPOVER(popover)) { - gtk_popover_popdown(popover); + GtkMenu* menu = gtk_menu_button_get_popup(GTK_MENU_BUTTON(menu_button)); + if (menu != nullptr && GTK_IS_MENU(menu) && + gtk_widget_get_visible(GTK_WIDGET(menu))) { + gtk_menu_shell_deactivate(GTK_MENU_SHELL(menu)); } if (GTK_IS_TOGGLE_BUTTON(menu_button)) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(menu_button), FALSE); } } -static GtkWidget* create_header_popup_window(MyApplication* self) { - GtkWidget* popover = gtk_popover_new(nullptr); - gtk_popover_set_position(GTK_POPOVER(popover), GTK_POS_BOTTOM); - gtk_popover_set_modal(GTK_POPOVER(popover), TRUE); - gtk_style_context_add_class(gtk_widget_get_style_context(popover), - "busymax-header-popover"); - g_object_set_data(G_OBJECT(popover), "busymax-application", self); - return popover; +static const gchar* header_view_mode_action(const gchar* mode); +static void set_header_view_mode(MyApplication* self, const gchar* mode); +static void set_widget_tooltip_with_shortcut(GtkWidget* widget, + const gchar* tooltip, + const gchar* shortcut); + +static void replace_header_label(gchar** target, const gchar* value) { + if (value == nullptr) { + return; + } + g_free(*target); + *target = g_strdup(value); +} + +static const gchar* header_view_mode_label(MyApplication* self, + const gchar* mode) { + if (g_strcmp0(mode, "day") == 0) { + return self->header_day_label; + } + if (g_strcmp0(mode, "week") == 0) { + return self->header_week_label; + } + if (g_strcmp0(mode, "month") == 0) { + return self->header_month_label; + } + if (g_strcmp0(mode, "year") == 0) { + return self->header_year_label; + } + if (g_strcmp0(mode, "agenda") == 0) { + return self->header_agenda_label; + } + return ""; +} + +static const gchar* header_view_mode_icon_name(const gchar* mode) { + if (g_strcmp0(mode, "day") == 0) { + return "view-continuous-symbolic"; + } + if (g_strcmp0(mode, "week") == 0) { + return "calendar-week-symbolic"; + } + if (g_strcmp0(mode, "month") == 0) { + return "calendar-month-symbolic"; + } + if (g_strcmp0(mode, "year") == 0) { + return "view-app-grid-symbolic"; + } + if (g_strcmp0(mode, "agenda") == 0) { + return "view-list-symbolic"; + } + return "calendar-week-symbolic"; +} + +static const gchar* header_view_mode_shortcut(MyApplication* self, + const gchar* mode) { + if (g_strcmp0(mode, "day") == 0) { + return self->header_day_shortcut; + } + if (g_strcmp0(mode, "week") == 0) { + return self->header_week_shortcut; + } + if (g_strcmp0(mode, "month") == 0) { + return self->header_month_shortcut; + } + if (g_strcmp0(mode, "year") == 0) { + return self->header_year_shortcut; + } + if (g_strcmp0(mode, "agenda") == 0) { + return self->header_agenda_shortcut; + } + return ""; +} + +static void update_header_view_mode_presentation(MyApplication* self) { + if (self->view_mode_icon == nullptr || + !GTK_IS_IMAGE(self->view_mode_icon)) { + return; + } + const gchar* mode = + self->header_view_mode != nullptr ? self->header_view_mode : "week"; + const gchar* label = header_view_mode_label(self, mode); + gtk_image_set_from_icon_name(GTK_IMAGE(self->view_mode_icon), + header_view_mode_icon_name(mode), + GTK_ICON_SIZE_MENU); + set_widget_tooltip_with_shortcut(self->view_mode_button, label, + header_view_mode_shortcut(self, mode)); +} + +static void set_header_menu_button_model(GtkWidget* button, + GMenuModel* model, + GtkWidget** tracked_menu) { + if (button == nullptr || !GTK_IS_MENU_BUTTON(button) || model == nullptr) { + return; + } + close_header_menu_button(button); + // Pointer-opened header menus should not paint a keyboard focus ring around + // their first row. Keyboard traversal can still focus the trigger normally. + gtk_widget_set_focus_on_click(button, FALSE); + if (*tracked_menu != nullptr) { + clear_widget_pointer(tracked_menu); + } + // GtkMenu is a native xdg_popup on Wayland. A GtkPopover subsurface can have + // its GDK redraw clock stalled by Mutter while the Flutter parent is idle. + gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), FALSE); + gtk_menu_button_set_menu_model(GTK_MENU_BUTTON(button), model); + GtkMenu* menu = gtk_menu_button_get_popup(GTK_MENU_BUTTON(button)); + if (menu == nullptr || !GTK_IS_MENU(menu)) { + return; + } + track_widget_pointer(tracked_menu, GTK_WIDGET(menu)); +} + +static void append_header_action_item(GMenu* menu, + const gchar* label, + const gchar* action, + const gchar* icon_name, + const gchar* shortcut) { + g_autoptr(GMenuItem) item = g_menu_item_new(label, action); + if (icon_name != nullptr && icon_name[0] != '\0') { + g_autoptr(GIcon) icon = g_themed_icon_new(icon_name); + g_menu_item_set_icon(item, icon); + } + if (shortcut != nullptr && shortcut[0] != '\0') { + set_menu_item_accelerator(item, shortcut); + } + g_menu_append_item(menu, item); +} + +static void append_header_view_mode_item(GMenu* menu, + const gchar* label, + const gchar* mode, + const gchar* icon_name, + const gchar* shortcut) { + g_autoptr(GMenuItem) item = g_menu_item_new(label, nullptr); + g_menu_item_set_action_and_target(item, "header.view-mode", "s", mode); + if (icon_name != nullptr && icon_name[0] != '\0') { + g_autoptr(GIcon) icon = g_themed_icon_new(icon_name); + g_menu_item_set_icon(item, icon); + } + if (shortcut != nullptr && shortcut[0] != '\0') { + set_menu_item_accelerator(item, shortcut); + } + g_menu_append_item(menu, item); +} + +static void rebuild_header_settings_menu_model(MyApplication* self) { + if (self->settings_menu_button == nullptr) { + return; + } + g_autoptr(GMenu) menu = g_menu_new(); + append_header_action_item(menu, self->header_settings_label, + "header.settings", "preferences-system-symbolic", + self->header_settings_shortcut); + append_header_action_item( + menu, self->header_keyboard_shortcuts_label, + "header.keyboard-shortcuts", "input-keyboard-symbolic", + self->header_keyboard_shortcuts_shortcut); + append_header_action_item(menu, self->header_report_issue_label, + "header.report-issue", "dialog-warning-symbolic", + nullptr); + append_header_action_item(menu, self->header_about_label, "header.about", + "help-about-symbolic", nullptr); + set_header_menu_button_model(self->settings_menu_button, G_MENU_MODEL(menu), + &self->settings_menu); +} + +static void rebuild_header_view_mode_menu_model(MyApplication* self) { + if (self->view_mode_button == nullptr) { + return; + } + g_autoptr(GMenu) menu = g_menu_new(); + append_header_view_mode_item(menu, self->header_day_label, "day", + header_view_mode_icon_name("day"), + self->header_day_shortcut); + append_header_view_mode_item(menu, self->header_week_label, "week", + header_view_mode_icon_name("week"), + self->header_week_shortcut); + append_header_view_mode_item(menu, self->header_month_label, "month", + header_view_mode_icon_name("month"), + self->header_month_shortcut); + append_header_view_mode_item(menu, self->header_year_label, "year", + header_view_mode_icon_name("year"), + self->header_year_shortcut); + append_header_view_mode_item(menu, self->header_agenda_label, "agenda", + header_view_mode_icon_name("agenda"), + self->header_agenda_shortcut); + set_header_menu_button_model(self->view_mode_button, G_MENU_MODEL(menu), + &self->view_mode_menu); + update_header_view_mode_presentation(self); +} + +static void rebuild_header_create_menu_model(MyApplication* self) { + if (self->create_button == nullptr) { + return; + } + g_autoptr(GMenu) menu = g_menu_new(); + append_header_action_item(menu, self->header_create_event_label, + "header.create-event", "x-office-calendar-symbolic", + self->header_create_event_shortcut); + append_header_action_item(menu, self->header_create_task_label, + "header.create-task", "checkbox-checked-symbolic", + self->header_create_task_shortcut); + set_header_menu_button_model(self->create_button, G_MENU_MODEL(menu), + &self->create_menu); +} + +static void rebuild_header_menu_models(MyApplication* self) { + rebuild_header_settings_menu_model(self); + rebuild_header_view_mode_menu_model(self); + rebuild_header_create_menu_model(self); } -static GtkWidget* create_header_popover_box(GtkWidget* popover) { - GtkWidget* box = - gtk_box_new(GTK_ORIENTATION_VERTICAL, kHeaderPopoverRowSpacing); - gtk_widget_set_margin_top(box, kHeaderMenuPadding); - gtk_widget_set_margin_bottom(box, kHeaderMenuPadding); - gtk_widget_set_margin_start(box, kHeaderMenuPadding); - gtk_widget_set_margin_end(box, kHeaderMenuPadding); - gtk_container_add(GTK_CONTAINER(popover), box); - return box; +static void header_menu_action_activated_cb(GSimpleAction* action, + GVariant*, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (self->suppress_header_bar_actions || + self->header_bar_modal_barrier_visible) { + return; + } + const gchar* bridge_action = static_cast( + g_object_get_data(G_OBJECT(action), "busymax-header-action")); + focus_flutter_view(self); + invoke_header_bar_action(self, bridge_action); } -static void show_header_popover_content(GtkWidget* box) { - if (box != nullptr && GTK_IS_WIDGET(box)) { - gtk_widget_show_all(box); +static void header_view_mode_action_activated_cb(GSimpleAction* action, + GVariant* parameter, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (self->suppress_header_bar_actions || + self->header_bar_modal_barrier_visible || parameter == nullptr || + !g_variant_is_of_type(parameter, G_VARIANT_TYPE_STRING)) { + return; + } + const gchar* mode = g_variant_get_string(parameter, nullptr); + const gchar* bridge_action = header_view_mode_action(mode); + if (bridge_action == nullptr) { + return; + } + set_header_view_mode(self, mode); + focus_flutter_view(self); + invoke_header_bar_action(self, bridge_action); +} + +static GSimpleAction* create_header_bridge_action( + MyApplication* self, + const gchar* action_name, + const gchar* bridge_action) { + GSimpleAction* action = g_simple_action_new(action_name, nullptr); + g_object_set_data(G_OBJECT(action), "busymax-header-action", + const_cast(bridge_action)); + g_signal_connect(action, "activate", + G_CALLBACK(header_menu_action_activated_cb), self); + g_action_map_add_action(G_ACTION_MAP(self->header_menu_action_group), + G_ACTION(action)); + return action; +} + +static void initialize_header_menu_actions(MyApplication* self) { + self->header_menu_action_group = g_simple_action_group_new(); + + g_autoptr(GSimpleAction) settings = + create_header_bridge_action(self, "settings", "settings"); + g_autoptr(GSimpleAction) keyboard_shortcuts = create_header_bridge_action( + self, "keyboard-shortcuts", "keyboardShortcuts"); + g_autoptr(GSimpleAction) report_issue = + create_header_bridge_action(self, "report-issue", "reportIssue"); + g_autoptr(GSimpleAction) about = + create_header_bridge_action(self, "about", "aboutBusyMax"); + self->header_create_event_action = + create_header_bridge_action(self, "create-event", "createEvent"); + self->header_create_task_action = + create_header_bridge_action(self, "create-task", "createTask"); + + self->header_view_mode_menu_action = g_simple_action_new_stateful( + "view-mode", G_VARIANT_TYPE_STRING, g_variant_new_string("week")); + g_signal_connect(self->header_view_mode_menu_action, "activate", + G_CALLBACK(header_view_mode_action_activated_cb), self); + g_action_map_add_action(G_ACTION_MAP(self->header_menu_action_group), + G_ACTION(self->header_view_mode_menu_action)); + + if (self->main_window != nullptr && GTK_IS_WIDGET(self->main_window)) { + gtk_widget_insert_action_group( + GTK_WIDGET(self->main_window), "header", + G_ACTION_GROUP(self->header_menu_action_group)); } } static void make_header_icon_button_square(GtkWidget* button) { - gtk_style_context_add_class(gtk_widget_get_style_context(button), - "busymax-header-icon-button"); gtk_widget_set_size_request(button, kHeaderButtonHeight, kHeaderButtonHeight); gtk_widget_set_valign(button, GTK_ALIGN_CENTER); } +static void style_header_control(GtkWidget* button) { + gtk_button_set_relief(GTK_BUTTON(button), GTK_RELIEF_NONE); + GtkStyleContext* context = gtk_widget_get_style_context(button); + gtk_style_context_add_class(context, GTK_STYLE_CLASS_FLAT); + gtk_style_context_add_class(context, kHeaderControlStyleClass); + gtk_widget_set_valign(button, GTK_ALIGN_CENTER); +} + static GtkWidget* create_header_icon_button(const gchar* icon_name, const gchar* tooltip) { GtkWidget* button = gtk_button_new(); GtkWidget* image = gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU); gtk_button_set_image(GTK_BUTTON(button), image); - gtk_button_set_relief(GTK_BUTTON(button), GTK_RELIEF_NONE); gtk_widget_set_tooltip_text(button, tooltip); - gtk_widget_set_valign(button, GTK_ALIGN_CENTER); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - "busymax-header-button"); + style_header_control(button); make_header_icon_button_square(button); return button; } @@ -1096,12 +3212,8 @@ static GtkWidget* create_header_toggle_icon_button(const gchar* icon_name, GtkWidget* button = gtk_toggle_button_new(); GtkWidget* image = gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU); gtk_button_set_image(GTK_BUTTON(button), image); - gtk_button_set_relief(GTK_BUTTON(button), GTK_RELIEF_NONE); gtk_widget_set_tooltip_text(button, tooltip); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - "busymax-header-button"); + style_header_control(button); make_header_icon_button_square(button); return button; } @@ -1109,13 +3221,8 @@ static GtkWidget* create_header_toggle_icon_button(const gchar* icon_name, static GtkWidget* create_header_text_button(const gchar* label, const gchar* tooltip) { GtkWidget* button = gtk_button_new_with_label(label); - gtk_button_set_relief(GTK_BUTTON(button), GTK_RELIEF_NONE); gtk_widget_set_tooltip_text(button, tooltip); - gtk_widget_set_valign(button, GTK_ALIGN_CENTER); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - "busymax-header-button"); + style_header_control(button); return button; } @@ -1139,6 +3246,22 @@ static void set_widget_tooltip(GtkWidget* widget, const gchar* tooltip) { } } +static void set_widget_tooltip_with_shortcut(GtkWidget* widget, + const gchar* tooltip, + const gchar* shortcut) { + if (widget == nullptr || !GTK_IS_WIDGET(widget) || tooltip == nullptr) { + return; + } + if (shortcut == nullptr || shortcut[0] == '\0') { + gtk_widget_set_tooltip_text(widget, tooltip); + return; + } + g_autofree gchar* combined = + g_strdup_printf("%s (%s%s%s)", tooltip, kLtrIsolateStart, shortcut, + kBidiIsolateEnd); + gtk_widget_set_tooltip_text(widget, combined); +} + static void set_toggle_button_active(MyApplication* self, GtkWidget* button, gboolean active) { @@ -1156,6 +3279,24 @@ static void set_widget_sensitive(GtkWidget* widget, gboolean sensitive) { } } +static void set_header_create_capabilities(MyApplication* self, + gboolean can_create_event, + gboolean can_create_task) { + if (self->header_create_event_action != nullptr) { + g_simple_action_set_enabled(self->header_create_event_action, + can_create_event); + } + if (self->header_create_task_action != nullptr) { + g_simple_action_set_enabled(self->header_create_task_action, + can_create_task); + } + const gboolean can_create = can_create_event || can_create_task; + set_widget_sensitive(self->create_button, can_create); + if (!can_create) { + close_header_menu_button(self->create_button); + } +} + static void set_widget_visible(GtkWidget* widget, gboolean visible) { if (widget != nullptr && GTK_IS_WIDGET(widget)) { gtk_widget_set_visible(widget, visible); @@ -1191,219 +3332,112 @@ static const gchar* header_view_mode_action(const gchar* mode) { return nullptr; } -static void set_header_view_mode(MyApplication* self, const gchar* mode); - -static GtkWidget* header_view_mode_item(MyApplication* self, - const gchar* mode) { - if (g_strcmp0(mode, "day") == 0) { - return self->view_mode_day_item; - } - if (g_strcmp0(mode, "week") == 0) { - return self->view_mode_week_item; - } - if (g_strcmp0(mode, "month") == 0) { - return self->view_mode_month_item; - } - if (g_strcmp0(mode, "year") == 0) { - return self->view_mode_year_item; - } - if (g_strcmp0(mode, "agenda") == 0) { - return self->view_mode_agenda_item; - } - return nullptr; -} - -static void set_header_view_mode_item_label(GtkWidget* item, - const gchar* label) { - if (item == nullptr || label == nullptr) { - return; - } - g_object_set_data_full(G_OBJECT(item), "busymax-header-label", - g_strdup(label), g_free); - GtkWidget* label_widget = static_cast( - g_object_get_data(G_OBJECT(item), "busymax-header-label-widget")); - if (label_widget != nullptr && GTK_IS_LABEL(label_widget)) { - gtk_label_set_text(GTK_LABEL(label_widget), label); +static void set_header_view_mode_labels(MyApplication* self, + const gchar* day, + const gchar* week, + const gchar* month, + const gchar* year, + const gchar* agenda) { + replace_header_label(&self->header_day_label, day); + replace_header_label(&self->header_week_label, week); + replace_header_label(&self->header_month_label, month); + replace_header_label(&self->header_year_label, year); + replace_header_label(&self->header_agenda_label, agenda); + update_header_view_mode_presentation(self); +} + +static void update_header_title_fit(MyApplication* self) { + if (self->header_title_label == nullptr || + !GTK_IS_LABEL(self->header_title_label) || + self->header_title_text == nullptr || self->header_search_active) { return; } - if (GTK_IS_BUTTON(item)) { - gtk_button_set_label(GTK_BUTTON(item), label); - } -} - -static void set_header_view_mode_item_active(MyApplication* self, - GtkWidget* item, - gboolean active) { - if (item == nullptr || !GTK_IS_WIDGET(item)) { + const gint available_width = + self->header_title_stack != nullptr && + GTK_IS_WIDGET(self->header_title_stack) + ? gtk_widget_get_allocated_width(self->header_title_stack) + : gtk_widget_get_allocated_width(self->header_title_label); + PangoLayout* layout = gtk_widget_create_pango_layout( + self->header_title_label, self->header_title_text); + gint title_width = 0; + pango_layout_get_pixel_size(layout, &title_width, nullptr); + g_object_unref(layout); + const gchar* visible_title = + available_width <= 0 || title_width <= available_width + ? self->header_title_text + : ""; + if (g_strcmp0(gtk_label_get_text(GTK_LABEL(self->header_title_label)), + visible_title) != 0) { + gtk_label_set_text(GTK_LABEL(self->header_title_label), visible_title); + } +} + +static gboolean update_header_title_fit_cb(gpointer user_data) { + update_header_title_fit(MY_APPLICATION(user_data)); + return G_SOURCE_REMOVE; +} + +static void set_header_title(MyApplication* self, const gchar* title) { + if (title == nullptr) { return; } - GtkWidget* check_widget = static_cast( - g_object_get_data(G_OBJECT(item), "busymax-header-check-widget")); - if (check_widget != nullptr && GTK_IS_WIDGET(check_widget)) { - gtk_widget_set_opacity(check_widget, active ? 1.0 : 0.0); - } -} - -static const gchar* header_view_mode_item_label(GtkWidget* item) { - if (item == nullptr) { - return ""; - } - const gchar* stored_label = static_cast( - g_object_get_data(G_OBJECT(item), "busymax-header-label")); - if (stored_label != nullptr) { - return stored_label; - } - const gchar* label = nullptr; - if (GTK_IS_BUTTON(item)) { - label = gtk_button_get_label(GTK_BUTTON(item)); - } - return label != nullptr ? label : ""; + replace_header_label(&self->header_title_text, title); + update_header_title_fit(self); } -static void update_header_view_mode_label(MyApplication* self) { - if (self->view_mode_label == nullptr || - !GTK_IS_LABEL(self->view_mode_label)) { - return; +static gboolean focus_header_search_entry(MyApplication* self) { + if (self->header_bar_modal_barrier_visible || + !self->header_search_active || self->search_entry == nullptr || + !GTK_IS_ENTRY(self->search_entry) || + !gtk_widget_get_visible(self->search_entry) || + !gtk_widget_get_child_visible(self->search_entry) || + !gtk_widget_get_sensitive(self->search_entry)) { + return FALSE; } - const gchar* mode = - self->header_view_mode != nullptr ? self->header_view_mode : "week"; - const gchar* label = header_view_mode_item_label( - header_view_mode_item(self, mode)); - gtk_label_set_text(GTK_LABEL(self->view_mode_label), label); -} -static void update_header_view_mode_items(MyApplication* self) { - const gchar* mode = - self->header_view_mode != nullptr ? self->header_view_mode : "week"; - set_header_view_mode_item_active(self, self->view_mode_day_item, - g_strcmp0(mode, "day") == 0); - set_header_view_mode_item_active(self, self->view_mode_week_item, - g_strcmp0(mode, "week") == 0); - set_header_view_mode_item_active(self, self->view_mode_month_item, - g_strcmp0(mode, "month") == 0); - set_header_view_mode_item_active(self, self->view_mode_year_item, - g_strcmp0(mode, "year") == 0); - set_header_view_mode_item_active(self, self->view_mode_agenda_item, - g_strcmp0(mode, "agenda") == 0); -} - -static void header_view_mode_item_clicked_cb(GtkWidget* widget, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_bar_actions) { - return; - } - const gchar* mode = static_cast( - g_object_get_data(G_OBJECT(widget), "busymax-view-mode")); - if (header_view_mode_action(mode) == nullptr) { - return; - } - set_header_view_mode(self, mode); - close_header_menu_button(self->view_mode_button); - focus_flutter_view(self); - invoke_header_bar_action(self, header_view_mode_action(mode)); -} - -static GtkWidget* create_header_view_mode_item(MyApplication* self, - const gchar* mode, - const gchar* fallback_label) { - GtkWidget* item = gtk_button_new(); - GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - GtkWidget* check = - gtk_image_new_from_icon_name("object-select-symbolic", GTK_ICON_SIZE_MENU); - GtkWidget* label = gtk_label_new(fallback_label); - gtk_label_set_xalign(GTK_LABEL(label), 0.0); - gtk_widget_set_hexpand(label, TRUE); - gtk_box_pack_start(GTK_BOX(box), check, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(box), label, TRUE, TRUE, 0); - gtk_container_add(GTK_CONTAINER(item), box); - gtk_widget_set_opacity(check, 0.0); - gtk_button_set_relief(GTK_BUTTON(item), GTK_RELIEF_NONE); - gtk_widget_set_halign(item, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(item, TRUE); - gtk_style_context_add_class(gtk_widget_get_style_context(item), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(item), - "busymax-header-popover-row"); - g_object_set_data(G_OBJECT(item), "busymax-header-label-widget", label); - g_object_set_data(G_OBJECT(item), "busymax-header-check-widget", check); - g_object_set_data_full(G_OBJECT(item), "busymax-header-label", - g_strdup(fallback_label), g_free); - g_object_set_data_full(G_OBJECT(item), "busymax-view-mode", - g_strdup(mode), g_free); - g_signal_connect(item, "clicked", - G_CALLBACK(header_view_mode_item_clicked_cb), self); - return item; -} - -static void header_settings_item_clicked_cb(GtkWidget* widget, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_bar_actions) { - return; + gtk_widget_grab_focus(self->search_entry); + gtk_editable_select_region(GTK_EDITABLE(self->search_entry), 0, -1); + return gtk_widget_has_focus(self->search_entry); +} + +static void set_header_search_state(MyApplication* self, + gboolean active, + const gchar* query) { + const gboolean effective_active = + active && self->header_schedule_controls_visible; + const gboolean active_changed = + self->header_search_active != effective_active; + const gboolean previous_suppression = self->suppress_header_bar_actions; + self->suppress_header_bar_actions = TRUE; + // Install Dart-owned text before activation transfers editing authority to + // the native GtkSearchEntry. Deactivation explicitly revokes native editing + // authority so Escape/clear snapshots cannot leave stale cached text. + set_header_search_query(self, query, effective_active); + self->header_search_active = effective_active; + set_toggle_button_active(self, self->search_button, effective_active); + if (self->header_title_stack != nullptr && + GTK_IS_STACK(self->header_title_stack)) { + GtkWidget* visible_child = + effective_active ? self->search_entry : self->header_title_label; + if (visible_child != nullptr && GTK_IS_WIDGET(visible_child)) { + gtk_stack_set_visible_child(GTK_STACK(self->header_title_stack), + visible_child); + } } - const gchar* action = static_cast( - g_object_get_data(G_OBJECT(widget), "busymax-settings-action")); - close_header_menu_button(self->settings_menu_button); - focus_flutter_view(self); - invoke_header_bar_action(self, action); -} - -static GtkWidget* create_header_settings_item(MyApplication* self, - const gchar* action, - const gchar* fallback_label) { - GtkWidget* item = gtk_button_new(); - GtkWidget* label = gtk_label_new(fallback_label); - gtk_label_set_xalign(GTK_LABEL(label), 0.0); - gtk_widget_set_hexpand(label, TRUE); - gtk_container_add(GTK_CONTAINER(item), label); - gtk_button_set_relief(GTK_BUTTON(item), GTK_RELIEF_NONE); - gtk_widget_set_halign(item, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(item, TRUE); - gtk_style_context_add_class(gtk_widget_get_style_context(item), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(item), - "busymax-header-popover-row"); - g_object_set_data(G_OBJECT(item), "busymax-header-label-widget", label); - g_object_set_data_full(G_OBJECT(item), "busymax-header-label", - g_strdup(fallback_label), g_free); - g_object_set_data_full(G_OBJECT(item), "busymax-settings-action", - g_strdup(action), g_free); - g_signal_connect(item, "clicked", - G_CALLBACK(header_settings_item_clicked_cb), self); - return item; -} + self->suppress_header_bar_actions = previous_suppression; + update_header_control_visibility(self); + update_header_title_fit(self); -static void set_header_settings_item_label(GtkWidget* item, - const gchar* label) { - if (item == nullptr || label == nullptr) { - return; - } - GtkWidget* label_widget = static_cast( - g_object_get_data(G_OBJECT(item), "busymax-header-label-widget")); - if (label_widget != nullptr && GTK_IS_LABEL(label_widget)) { - gtk_label_set_text(GTK_LABEL(label_widget), label); + if (!active_changed) { return; } - if (GTK_IS_BUTTON(item)) { - gtk_button_set_label(GTK_BUTTON(item), label); + if (effective_active) { + focus_header_search_entry(self); + } else { + focus_flutter_view(self); } } -static void set_header_view_mode_labels(MyApplication* self, - const gchar* day, - const gchar* week, - const gchar* month, - const gchar* year, - const gchar* agenda) { - set_header_view_mode_item_label(self->view_mode_day_item, day); - set_header_view_mode_item_label(self->view_mode_week_item, week); - set_header_view_mode_item_label(self->view_mode_month_item, month); - set_header_view_mode_item_label(self->view_mode_year_item, year); - set_header_view_mode_item_label(self->view_mode_agenda_item, agenda); - update_header_view_mode_label(self); -} - static void set_header_view_mode(MyApplication* self, const gchar* mode) { if (header_view_mode_action(mode) == nullptr) { return; @@ -1412,20 +3446,20 @@ static void set_header_view_mode(MyApplication* self, const gchar* mode) { g_free(self->header_view_mode); self->header_view_mode = g_strdup(mode); } - update_header_view_mode_label(self); - update_header_view_mode_items(self); -} - -static void update_header_title_balance_spacer(MyApplication* self) { - if (self->header_title_balance_spacer == nullptr || - !GTK_IS_WIDGET(self->header_title_balance_spacer)) { - return; + if (self->header_view_mode_menu_action != nullptr) { + g_autoptr(GVariant) state = g_variant_ref_sink(g_variant_new_string(mode)); + GVariant* current_state = + g_action_get_state(G_ACTION(self->header_view_mode_menu_action)); + const gboolean state_changed = + current_state == nullptr || !g_variant_equal(current_state, state); + if (current_state != nullptr) { + g_variant_unref(current_state); + } + if (state_changed) { + g_simple_action_set_state(self->header_view_mode_menu_action, state); + } } - const gboolean visible = - !self->header_schedule_controls_visible && !self->header_back_visible; - gtk_widget_set_size_request(self->header_title_balance_spacer, - kHeaderWindowControlsBalanceWidth, -1); - set_widget_visible(self->header_title_balance_spacer, visible); + update_header_view_mode_presentation(self); } static void update_header_title_box_geometry(MyApplication* self) { @@ -1433,45 +3467,127 @@ static void update_header_title_box_geometry(MyApplication* self) { !GTK_IS_WIDGET(self->header_title_box)) { return; } - const gint width = self->header_onboarding_controls_visible - ? kHeaderOnboardingContentWidth - : -1; + const gboolean onboarding = self->header_onboarding_controls_visible; + gtk_widget_set_halign(self->header_title_box, + onboarding ? GTK_ALIGN_CENTER : GTK_ALIGN_FILL); + const gint width = + onboarding ? self->header_onboarding_content_width : -1; gtk_widget_set_size_request(self->header_title_box, width, -1); + if (!onboarding) { + gtk_widget_set_margin_start(self->header_title_box, 0); + gtk_widget_set_margin_end(self->header_title_box, 0); + } } -static void set_header_schedule_controls_visible(MyApplication* self, - gboolean visible) { - self->header_schedule_controls_visible = visible; +static gboolean recenter_onboarding_header_controls_cb(gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (!self->header_onboarding_controls_visible || + self->header_bar == nullptr || + !GTK_IS_WIDGET(self->header_bar) || + self->header_title_box == nullptr || + !GTK_IS_WIDGET(self->header_title_box)) { + return G_SOURCE_REMOVE; + } + + GtkAllocation header_allocation; + GtkAllocation title_allocation; + gtk_widget_get_allocation(GTK_WIDGET(self->header_bar), + &header_allocation); + gtk_widget_get_allocation(self->header_title_box, &title_allocation); + if (header_allocation.width <= 0 || title_allocation.width <= 0) { + return G_SOURCE_REMOVE; + } + + const gint header_center = + header_allocation.x + header_allocation.width / 2; + const gint title_center = + title_allocation.x + title_allocation.width / 2; + const gint physical_delta = header_center - title_center; + if (std::abs(physical_delta) <= 1) { + return G_SOURCE_REMOVE; + } + + const GtkTextDirection direction = + gtk_widget_get_direction(self->header_title_box); + const gint logical_delta = + direction == GTK_TEXT_DIR_RTL ? -physical_delta : physical_delta; + const gint current_bias = + gtk_widget_get_margin_start(self->header_title_box) - + gtk_widget_get_margin_end(self->header_title_box); + const gint target_bias = + std::clamp(current_bias + logical_delta * 2, + -header_allocation.width, header_allocation.width); + const gint start_margin = std::max(target_bias, 0); + const gint end_margin = std::max(-target_bias, 0); + if (start_margin == gtk_widget_get_margin_start(self->header_title_box) && + end_margin == gtk_widget_get_margin_end(self->header_title_box)) { + return G_SOURCE_REMOVE; + } + + gtk_widget_set_margin_start(self->header_title_box, start_margin); + gtk_widget_set_margin_end(self->header_title_box, end_margin); + return G_SOURCE_REMOVE; +} + +static void header_bar_size_allocate_cb(GtkWidget*, + GtkAllocation*, + gpointer user_data) { + g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, recenter_onboarding_header_controls_cb, + g_object_ref(user_data), g_object_unref); + g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, update_header_title_fit_cb, + g_object_ref(user_data), g_object_unref); +} + +static void update_header_control_visibility(MyApplication* self) { + const gboolean schedule_controls_visible = + self->header_schedule_controls_visible; + if (!schedule_controls_visible) { + close_header_menu_button(self->create_button); + } set_widget_visible(self->header_start_box, - visible || self->header_back_visible); - set_widget_visible(self->sidebar_collapsed_toggle_button, visible); - set_widget_visible(self->today_button, visible); + schedule_controls_visible || self->header_back_visible); + set_widget_visible(self->back_button, self->header_back_visible); + set_widget_visible(self->sidebar_collapsed_toggle_button, + schedule_controls_visible && + self->header_bar_can_show_sidebar); + set_widget_visible(self->today_button, + schedule_controls_visible && + !self->header_search_active); set_widget_visible(self->previous_button, - visible && self->header_navigation_visible); + schedule_controls_visible && + self->header_navigation_visible); set_widget_visible(self->next_button, - visible && self->header_navigation_visible); - set_widget_visible(self->header_view_box, visible); - set_widget_visible(self->search_button, visible); - set_widget_visible(self->create_button, visible); - set_widget_visible(self->refresh_button, visible); - update_header_title_balance_spacer(self); + schedule_controls_visible && + self->header_navigation_visible); + set_widget_visible(self->header_view_box, + schedule_controls_visible && + !self->header_search_active); + set_widget_visible(self->search_button, schedule_controls_visible); + set_widget_visible(self->create_button, schedule_controls_visible); + set_widget_visible(self->refresh_button, schedule_controls_visible); + set_widget_visible(self->settings_menu_button, + schedule_controls_visible || self->header_back_visible); +} + +static void set_header_schedule_controls_visible(MyApplication* self, + gboolean visible) { + self->header_schedule_controls_visible = visible; + if (!visible && self->header_search_active) { + set_header_search_state(self, FALSE, self->header_search_query); + } + update_header_control_visibility(self); } static void set_header_navigation_visible(MyApplication* self, gboolean visible) { self->header_navigation_visible = visible; - set_widget_visible(self->previous_button, - self->header_schedule_controls_visible && visible); - set_widget_visible(self->next_button, - self->header_schedule_controls_visible && visible); + update_header_control_visibility(self); } static void set_header_back_visible(MyApplication* self, gboolean visible) { self->header_back_visible = visible; - set_widget_visible(self->back_button, visible); - set_widget_visible(self->header_start_box, - visible || self->header_schedule_controls_visible); - update_header_title_balance_spacer(self); + update_header_control_visibility(self); } static void set_header_onboarding_controls(MyApplication* self, FlValue* args) { @@ -1481,6 +3597,12 @@ static void set_header_onboarding_controls(MyApplication* self, FlValue* args) { fl_lookup_bool_arg(args, "canContinue", FALSE); const gchar* back_label = fl_lookup_string_arg(args, "backLabel"); const gchar* continue_label = fl_lookup_string_arg(args, "continueLabel"); + gint64 content_width = 0; + if (fl_lookup_int_arg(args, "contentWidth", &content_width) && + content_width > 0 && content_width <= G_MAXINT) { + self->header_onboarding_content_width = + static_cast(content_width); + } self->header_onboarding_controls_visible = visible; set_widget_visible(self->onboarding_back_slot, visible); @@ -1494,25 +3616,138 @@ static void set_header_onboarding_controls(MyApplication* self, FlValue* args) { set_button_label_and_tooltip(self->onboarding_continue_button, continue_label, continue_label); update_header_title_box_geometry(self); - update_header_title_balance_spacer(self); +} + +static void update_header_sidebar_presentation(MyApplication* self) { + const gchar* label = self->header_bar_sidebar_visible + ? self->header_hide_sidebar_panel_label + : self->header_show_sidebar_panel_label; + set_widget_tooltip_with_shortcut(self->sidebar_collapsed_toggle_button, + label, self->header_sidebar_shortcut); } static void set_header_sidebar_visible(MyApplication* self, gboolean visible) { self->header_bar_sidebar_visible = visible; - set_widget_visible(self->sidebar_collapsed_toggle_button, - self->header_schedule_controls_visible); set_toggle_button_active(self, self->sidebar_collapsed_toggle_button, visible); + update_header_sidebar_presentation(self); + update_header_sidebar_brand_geometry(self); + refresh_header_bar_css(self); +} + +static void set_header_can_show_sidebar(MyApplication* self, + gboolean can_show_sidebar) { + self->header_bar_can_show_sidebar = can_show_sidebar; + update_header_control_visibility(self); + update_header_sidebar_brand_geometry(self); + refresh_header_bar_css(self); +} + +static void set_header_sidebar_width(MyApplication* self, gdouble width) { + if (width <= 0) { + return; + } + self->header_bar_sidebar_width = static_cast(width); update_header_sidebar_brand_geometry(self); refresh_header_bar_css(self); } -static void set_header_sidebar_width(MyApplication* self, gdouble width) { - if (width <= 0) { +static void set_header_text_direction(MyApplication* self, + const gchar* value) { + const GtkTextDirection direction = + g_strcmp0(value, "rtl") == 0 ? GTK_TEXT_DIR_RTL : GTK_TEXT_DIR_LTR; + GtkWidget* widgets[] = { + self->titlebar_handle, + self->titlebar_overlay, + self->titlebar_box, + GTK_WIDGET(self->header_bar), + self->header_start_box, + self->header_title_box, + self->header_title_stack, + self->header_sidebar_brand_box, + self->settings_menu_button, + self->settings_menu, + self->header_view_box, + self->search_entry, + self->back_button, + self->sidebar_collapsed_toggle_button, + self->today_button, + self->previous_button, + self->next_button, + self->view_mode_button, + self->view_mode_menu, + self->search_button, + self->create_button, + self->create_menu, + self->refresh_button, + }; + for (GtkWidget* widget : widgets) { + if (widget != nullptr && GTK_IS_WIDGET(widget)) { + gtk_widget_set_direction(widget, direction); + } + } +} + +static void set_header_bar_state(MyApplication* self, FlValue* args) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { return; } - self->header_bar_sidebar_width = static_cast(width); - update_header_sidebar_brand_geometry(self); - refresh_header_bar_css(self); + + gint64 schema_version = 0; + if (!fl_lookup_int_arg(args, "schemaVersion", &schema_version) || + schema_version != kHeaderBarStateSchemaVersion) { + g_warning("Ignoring unsupported BusyMax header bar state schema"); + return; + } + + set_header_title(self, fl_lookup_string_arg(args, "title")); + set_header_view_mode(self, fl_lookup_string_arg(args, "viewMode")); + + gboolean value = FALSE; + gboolean search_active = self->header_search_active; + fl_lookup_optional_bool_arg(args, "searchActive", &search_active); + const gchar* search_query = fl_lookup_string_arg(args, "searchQuery"); + if (search_query == nullptr) { + search_query = ""; + } + if (fl_lookup_optional_bool_arg(args, "canRefresh", &value)) { + set_widget_sensitive(self->refresh_button, value); + } + gboolean can_create_event = FALSE; + gboolean can_create_task = FALSE; + const gboolean has_can_create_event = fl_lookup_optional_bool_arg( + args, "canCreateEvent", &can_create_event); + const gboolean has_can_create_task = fl_lookup_optional_bool_arg( + args, "canCreateTask", &can_create_task); + if (has_can_create_event && has_can_create_task) { + set_header_create_capabilities(self, can_create_event, can_create_task); + } + + const gint previous_sidebar_width = header_sidebar_effective_width(self); + if (fl_lookup_optional_bool_arg(args, "canShowSidebar", &value)) { + self->header_bar_can_show_sidebar = value; + } + if (fl_lookup_optional_bool_arg(args, "sidebarVisible", &value)) { + self->header_bar_sidebar_visible = value; + set_toggle_button_active(self, self->sidebar_collapsed_toggle_button, value); + update_header_sidebar_presentation(self); + } + if (fl_lookup_optional_bool_arg(args, "navigationVisible", &value)) { + self->header_navigation_visible = value; + } + if (fl_lookup_optional_bool_arg(args, "scheduleControlsVisible", &value)) { + self->header_schedule_controls_visible = value; + } + if (fl_lookup_optional_bool_arg(args, "backVisible", &value)) { + self->header_back_visible = value; + } + + set_header_search_state(self, search_active, search_query); + update_header_control_visibility(self); + const gint sidebar_width = header_sidebar_effective_width(self); + if (sidebar_width != previous_sidebar_width) { + update_header_sidebar_brand_geometry(self); + refresh_header_bar_css(self); + } } static void set_header_localized_labels(MyApplication* self, FlValue* args) { @@ -1524,45 +3759,104 @@ static void set_header_localized_labels(MyApplication* self, FlValue* args) { const gchar* agenda = fl_lookup_string_arg(args, "agenda"); const gchar* search = fl_lookup_string_arg(args, "search"); const gchar* create = fl_lookup_string_arg(args, "create"); + const gchar* create_event = fl_lookup_string_arg(args, "createEvent"); + const gchar* create_task = fl_lookup_string_arg(args, "createTask"); const gchar* refresh = fl_lookup_string_arg(args, "refresh"); const gchar* menu = fl_lookup_string_arg(args, "menu"); const gchar* previous = fl_lookup_string_arg(args, "previous"); const gchar* next = fl_lookup_string_arg(args, "next"); - const gchar* sidebar = fl_lookup_string_arg(args, "sidebar"); + const gchar* show_sidebar_panel = + fl_lookup_string_arg(args, "showSidebarPanel"); + const gchar* hide_sidebar_panel = + fl_lookup_string_arg(args, "hideSidebarPanel"); const gchar* back = fl_lookup_string_arg(args, "back"); const gchar* settings = fl_lookup_string_arg(args, "settings"); const gchar* keyboard_shortcuts = fl_lookup_string_arg(args, "keyboardShortcuts"); + const gchar* report_issue = fl_lookup_string_arg(args, "reportIssue"); const gchar* about_busymax = fl_lookup_string_arg(args, "aboutBusyMax"); - - set_button_label_and_tooltip(self->today_button, today, today); + const gchar* today_shortcut = + fl_lookup_string_arg(args, "todayShortcut"); + const gchar* day_shortcut = fl_lookup_string_arg(args, "dayShortcut"); + const gchar* week_shortcut = fl_lookup_string_arg(args, "weekShortcut"); + const gchar* month_shortcut = fl_lookup_string_arg(args, "monthShortcut"); + const gchar* year_shortcut = fl_lookup_string_arg(args, "yearShortcut"); + const gchar* agenda_shortcut = + fl_lookup_string_arg(args, "agendaShortcut"); + const gchar* search_shortcut = + fl_lookup_string_arg(args, "searchShortcut"); + const gchar* sidebar_shortcut = + fl_lookup_string_arg(args, "sidebarShortcut"); + const gchar* create_event_shortcut = + fl_lookup_string_arg(args, "createEventShortcut"); + const gchar* create_task_shortcut = + fl_lookup_string_arg(args, "createTaskShortcut"); + const gchar* previous_shortcut = + fl_lookup_string_arg(args, "previousShortcut"); + const gchar* next_shortcut = fl_lookup_string_arg(args, "nextShortcut"); + const gchar* settings_shortcut = + fl_lookup_string_arg(args, "settingsShortcut"); + const gchar* keyboard_shortcuts_shortcut = + fl_lookup_string_arg(args, "keyboardShortcutsShortcut"); + + replace_header_label(&self->header_day_shortcut, day_shortcut); + replace_header_label(&self->header_week_shortcut, week_shortcut); + replace_header_label(&self->header_month_shortcut, month_shortcut); + replace_header_label(&self->header_year_shortcut, year_shortcut); + replace_header_label(&self->header_agenda_shortcut, agenda_shortcut); + replace_header_label(&self->header_show_sidebar_panel_label, + show_sidebar_panel); + replace_header_label(&self->header_hide_sidebar_panel_label, + hide_sidebar_panel); + replace_header_label(&self->header_sidebar_shortcut, sidebar_shortcut); + replace_header_label(&self->header_create_event_shortcut, + create_event_shortcut); + replace_header_label(&self->header_create_task_shortcut, + create_task_shortcut); + replace_header_label(&self->header_settings_shortcut, settings_shortcut); + replace_header_label(&self->header_keyboard_shortcuts_shortcut, + keyboard_shortcuts_shortcut); + + set_button_label_and_tooltip(self->today_button, today, nullptr); + set_widget_tooltip_with_shortcut(self->today_button, today, today_shortcut); set_header_view_mode_labels(self, day, week, month, year, agenda); set_widget_tooltip(self->back_button, back); - set_widget_tooltip(self->search_button, search); + set_widget_tooltip_with_shortcut(self->search_button, search, + search_shortcut); + if (self->search_entry != nullptr && GTK_IS_ENTRY(self->search_entry) && + search != nullptr) { + gtk_entry_set_placeholder_text(GTK_ENTRY(self->search_entry), search); + } set_widget_tooltip(self->create_button, create); + replace_header_label(&self->header_create_event_label, create_event); + replace_header_label(&self->header_create_task_label, create_task); set_widget_tooltip(self->settings_menu_button, menu); set_widget_tooltip(self->refresh_button, refresh); - set_widget_tooltip(self->previous_button, previous); - set_widget_tooltip(self->next_button, next); - set_widget_tooltip(self->sidebar_collapsed_toggle_button, sidebar); - set_header_settings_item_label(self->settings_item, settings); - set_header_settings_item_label(self->keyboard_shortcuts_item, - keyboard_shortcuts); - set_header_settings_item_label(self->about_item, about_busymax); -} - -static GtkWidget* create_busymax_header_bar(MyApplication* self) { + set_widget_tooltip_with_shortcut(self->previous_button, previous, + previous_shortcut); + set_widget_tooltip_with_shortcut(self->next_button, next, next_shortcut); + update_header_sidebar_presentation(self); + replace_header_label(&self->header_settings_label, settings); + replace_header_label(&self->header_keyboard_shortcuts_label, + keyboard_shortcuts); + replace_header_label(&self->header_report_issue_label, report_issue); + replace_header_label(&self->header_about_label, about_busymax); + rebuild_header_menu_models(self); +} + +static GtkWidget* create_busymax_titlebar(MyApplication* self) { track_widget_pointer(&self->titlebar_box, gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0)); gtk_widget_set_halign(self->titlebar_box, GTK_ALIGN_FILL); gtk_widget_set_hexpand(self->titlebar_box, TRUE); - gtk_style_context_add_class(gtk_widget_get_style_context(self->titlebar_box), - "busymax-titlebar"); + initialize_header_menu_actions(self); GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); track_header_bar_pointer(self, header_bar); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_widget_set_hexpand(GTK_WIDGET(header_bar), TRUE); + g_signal_connect(header_bar, "size-allocate", + G_CALLBACK(header_bar_size_allocate_cb), self); track_widget_pointer(&self->header_sidebar_brand_box, gtk_box_new(GTK_ORIENTATION_HORIZONTAL, @@ -1575,10 +3869,6 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { track_widget_pointer( &self->search_button, create_header_toggle_icon_button("system-search-symbolic", "")); - gtk_style_context_add_class(gtk_widget_get_style_context(self->search_button), - "busymax-brand-action-button"); - gtk_widget_set_margin_start(self->search_button, - kHeaderSidebarContentInset); connect_header_bar_action(self, self->search_button, "search"); GtkWidget* brand_center_box = @@ -1597,65 +3887,21 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { gtk_box_pack_start(GTK_BOX(brand_center_box), self->header_brand_label, FALSE, FALSE, 0); - track_widget_pointer(&self->settings_menu, create_header_popup_window(self)); - GtkWidget* settings_menu_box = create_header_popover_box(self->settings_menu); - - track_widget_pointer(&self->settings_item, - create_header_settings_item(self, "settings", - "Settings")); - track_widget_pointer(&self->keyboard_shortcuts_item, - create_header_settings_item(self, "keyboardShortcuts", - "Keyboard Shortcuts")); - track_widget_pointer(&self->about_item, - create_header_settings_item(self, "aboutBusyMax", - "About BusyMax")); - gtk_box_pack_start(GTK_BOX(settings_menu_box), self->settings_item, FALSE, - FALSE, 0); - gtk_box_pack_start(GTK_BOX(settings_menu_box), - self->keyboard_shortcuts_item, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(settings_menu_box), self->about_item, FALSE, - FALSE, 0); - show_header_popover_content(settings_menu_box); - track_widget_pointer(&self->settings_menu_button, gtk_menu_button_new()); - gtk_button_set_relief(GTK_BUTTON(self->settings_menu_button), - GTK_RELIEF_NONE); gtk_button_set_image(GTK_BUTTON(self->settings_menu_button), gtk_image_new_from_icon_name("open-menu-symbolic", GTK_ICON_SIZE_MENU)); - gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(self->settings_menu_button), - TRUE); - gtk_menu_button_set_popover(GTK_MENU_BUTTON(self->settings_menu_button), - self->settings_menu); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->settings_menu_button), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->settings_menu_button), - "busymax-header-button"); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->settings_menu_button), - "busymax-brand-action-button"); + style_header_control(self->settings_menu_button); make_header_icon_button_square(self->settings_menu_button); gtk_widget_set_margin_end(self->settings_menu_button, kHeaderSidebarContentInset); + rebuild_header_settings_menu_model(self); - gtk_box_pack_start(GTK_BOX(self->header_sidebar_brand_box), - self->search_button, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(self->header_sidebar_brand_box), brand_center_box, TRUE, TRUE, 0); - gtk_box_pack_end(GTK_BOX(self->header_sidebar_brand_box), - self->settings_menu_button, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(self->titlebar_box), self->header_sidebar_brand_box, FALSE, FALSE, 0); - track_widget_pointer(&self->header_title_balance_spacer, - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0)); - gtk_widget_set_size_request(self->header_title_balance_spacer, - kHeaderWindowControlsBalanceWidth, -1); - gtk_widget_set_visible(self->header_title_balance_spacer, FALSE); - gtk_header_bar_pack_start(header_bar, self->header_title_balance_spacer); - track_widget_pointer(&self->header_start_box, gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing)); @@ -1666,9 +3912,6 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { track_widget_pointer( &self->sidebar_collapsed_toggle_button, create_header_toggle_icon_button("sidebar-show-symbolic", "")); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->sidebar_collapsed_toggle_button), - "busymax-sidebar-toggle"); track_widget_pointer(&self->today_button, create_header_text_button("", "")); track_widget_pointer(&self->previous_button, @@ -1697,8 +3940,14 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { track_widget_pointer(&self->header_title_box, gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing)); - gtk_widget_set_halign(self->header_title_box, GTK_ALIGN_CENTER); + gtk_widget_set_halign(self->header_title_box, GTK_ALIGN_FILL); gtk_widget_set_hexpand(self->header_title_box, TRUE); + track_widget_pointer(&self->header_title_stack, gtk_stack_new()); + gtk_widget_set_halign(self->header_title_stack, GTK_ALIGN_FILL); + gtk_widget_set_hexpand(self->header_title_stack, TRUE); + gtk_stack_set_hhomogeneous(GTK_STACK(self->header_title_stack), FALSE); + gtk_stack_set_transition_type(GTK_STACK(self->header_title_stack), + GTK_STACK_TRANSITION_TYPE_NONE); track_widget_pointer(&self->onboarding_back_slot, gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0)); @@ -1708,6 +3957,9 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { track_widget_pointer(&self->onboarding_back_button, create_header_text_button("Back", "Back")); + gtk_style_context_add_class( + gtk_widget_get_style_context(self->onboarding_back_button), + kHeaderOnboardingTextButtonStyleClass); connect_header_bar_action(self, self->onboarding_back_button, "back"); gtk_widget_set_visible(self->onboarding_back_button, FALSE); gtk_box_pack_start(GTK_BOX(self->onboarding_back_slot), @@ -1718,13 +3970,42 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { gtk_style_context_add_class(gtk_widget_get_style_context( self->header_title_label), "busymax-header-title"); + gtk_style_context_add_class( + gtk_widget_get_style_context(self->header_title_label), + GTK_STYLE_CLASS_TITLE); gtk_label_set_ellipsize(GTK_LABEL(self->header_title_label), - PANGO_ELLIPSIZE_END); - gtk_label_set_max_width_chars(GTK_LABEL(self->header_title_label), 48); + PANGO_ELLIPSIZE_NONE); gtk_label_set_xalign(GTK_LABEL(self->header_title_label), 0.5); gtk_widget_set_halign(self->header_title_label, GTK_ALIGN_CENTER); gtk_widget_set_hexpand(self->header_title_label, TRUE); + track_widget_pointer(&self->search_entry, gtk_search_entry_new()); + gtk_style_context_add_class(gtk_widget_get_style_context(self->search_entry), + kHeaderSearchEntryStyleClass); + gtk_entry_set_placeholder_text(GTK_ENTRY(self->search_entry), ""); + gtk_entry_set_max_width_chars(GTK_ENTRY(self->search_entry), + kHeaderCenterMaximumWidthChars); + gtk_widget_set_halign(self->search_entry, GTK_ALIGN_FILL); + gtk_widget_set_valign(self->search_entry, GTK_ALIGN_CENTER); + gtk_widget_set_hexpand(self->search_entry, TRUE); + g_signal_connect(self->search_entry, "search-changed", + G_CALLBACK(header_search_entry_search_changed_cb), self); + g_signal_connect(self->search_entry, "focus-in-event", + G_CALLBACK(header_search_entry_focus_in_cb), self); + g_signal_connect(self->search_entry, "focus-out-event", + G_CALLBACK(header_search_entry_focus_out_cb), self); + g_signal_connect(self->search_entry, "icon-release", + G_CALLBACK(header_search_entry_icon_release_cb), self); + g_signal_connect(self->search_entry, "stop-search", + G_CALLBACK(header_search_entry_stop_search_cb), self); + + gtk_stack_add_named(GTK_STACK(self->header_title_stack), + self->header_title_label, "title"); + gtk_stack_add_named(GTK_STACK(self->header_title_stack), self->search_entry, + "search"); + gtk_stack_set_visible_child(GTK_STACK(self->header_title_stack), + self->header_title_label); + track_widget_pointer(&self->onboarding_continue_slot, gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0)); gtk_widget_set_size_request(self->onboarding_continue_slot, @@ -1735,7 +4016,7 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { create_header_text_button("Continue", "Continue")); gtk_style_context_add_class( gtk_widget_get_style_context(self->onboarding_continue_button), - "busymax-header-primary-button"); + kHeaderOnboardingTextButtonStyleClass); connect_header_bar_action(self, self->onboarding_continue_button, "continueSetup"); gtk_widget_set_visible(self->onboarding_continue_button, FALSE); @@ -1745,7 +4026,7 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { gtk_box_pack_start(GTK_BOX(self->header_title_box), self->onboarding_back_slot, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(self->header_title_box), - self->header_title_label, TRUE, TRUE, 0); + self->header_title_stack, TRUE, TRUE, 0); gtk_box_pack_start(GTK_BOX(self->header_title_box), self->onboarding_continue_slot, FALSE, FALSE, 0); gtk_header_bar_set_custom_title(header_bar, self->header_title_box); @@ -1755,64 +4036,38 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { track_widget_pointer(&self->header_view_box, gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0)); - track_widget_pointer(&self->view_mode_menu, create_header_popup_window(self)); - GtkWidget* view_mode_menu_box = - create_header_popover_box(self->view_mode_menu); - - track_widget_pointer(&self->view_mode_day_item, - create_header_view_mode_item(self, "day", "Day")); - track_widget_pointer(&self->view_mode_week_item, - create_header_view_mode_item(self, "week", "Week")); - track_widget_pointer(&self->view_mode_month_item, - create_header_view_mode_item(self, "month", "Month")); - track_widget_pointer(&self->view_mode_year_item, - create_header_view_mode_item(self, "year", "Year")); - track_widget_pointer(&self->view_mode_agenda_item, - create_header_view_mode_item(self, "agenda", "Agenda")); - gtk_box_pack_start(GTK_BOX(view_mode_menu_box), self->view_mode_day_item, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(view_mode_menu_box), self->view_mode_week_item, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(view_mode_menu_box), self->view_mode_month_item, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(view_mode_menu_box), self->view_mode_year_item, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(view_mode_menu_box), self->view_mode_agenda_item, - FALSE, FALSE, 0); - show_header_popover_content(view_mode_menu_box); track_widget_pointer(&self->view_mode_button, gtk_menu_button_new()); - gtk_button_set_relief(GTK_BUTTON(self->view_mode_button), GTK_RELIEF_NONE); - gtk_style_context_add_class(gtk_widget_get_style_context(self->view_mode_button), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(self->view_mode_button), - "busymax-header-view-mode-button"); - gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(self->view_mode_button), - TRUE); - gtk_menu_button_set_popover(GTK_MENU_BUTTON(self->view_mode_button), - self->view_mode_menu); + style_header_control(self->view_mode_button); GtkWidget* view_mode_button_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - track_widget_pointer(&self->view_mode_label, gtk_label_new("")); - gtk_label_set_ellipsize(GTK_LABEL(self->view_mode_label), - PANGO_ELLIPSIZE_END); + track_widget_pointer( + &self->view_mode_icon, + gtk_image_new_from_icon_name(header_view_mode_icon_name("week"), + GTK_ICON_SIZE_MENU)); GtkWidget* view_mode_arrow = gtk_image_new_from_icon_name("pan-down-symbolic", GTK_ICON_SIZE_MENU); - gtk_box_pack_start(GTK_BOX(view_mode_button_box), self->view_mode_label, - TRUE, TRUE, 0); + gtk_box_pack_start(GTK_BOX(view_mode_button_box), self->view_mode_icon, + FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(view_mode_button_box), view_mode_arrow, FALSE, FALSE, 0); gtk_container_add(GTK_CONTAINER(self->view_mode_button), view_mode_button_box); + rebuild_header_view_mode_menu_model(self); gtk_box_pack_start(GTK_BOX(self->header_view_box), self->view_mode_button, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(end_box), self->header_view_box, FALSE, FALSE, 0); - - track_widget_pointer(&self->create_button, - create_header_icon_button("list-add-symbolic", "")); - connect_header_bar_action(self, self->create_button, "create"); + gtk_box_pack_start(GTK_BOX(end_box), self->search_button, FALSE, FALSE, 0); + + track_widget_pointer(&self->create_button, gtk_menu_button_new()); + gtk_button_set_image( + GTK_BUTTON(self->create_button), + gtk_image_new_from_icon_name("list-add-symbolic", GTK_ICON_SIZE_MENU)); + style_header_control(self->create_button); + make_header_icon_button_square(self->create_button); + rebuild_header_create_menu_model(self); gtk_box_pack_start(GTK_BOX(end_box), self->create_button, FALSE, FALSE, 0); track_widget_pointer(&self->refresh_button, @@ -1820,6 +4075,8 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { "")); connect_header_bar_action(self, self->refresh_button, "refresh"); gtk_box_pack_start(GTK_BOX(end_box), self->refresh_button, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(end_box), self->settings_menu_button, FALSE, FALSE, + 0); gtk_header_bar_pack_end(header_bar, end_box); set_header_view_mode(self, "week"); @@ -1831,6 +4088,83 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { return self->titlebar_box; } +static gboolean consume_header_bar_modal_input_cb(GtkWidget*, + GdkEvent* event, + gpointer) { + switch (event->type) { + case GDK_BUTTON_PRESS: + case GDK_2BUTTON_PRESS: + case GDK_3BUTTON_PRESS: + case GDK_BUTTON_RELEASE: + case GDK_MOTION_NOTIFY: + case GDK_SCROLL: + case GDK_TOUCH_BEGIN: + case GDK_TOUCH_UPDATE: + case GDK_TOUCH_END: + case GDK_TOUCH_CANCEL: + case GDK_ENTER_NOTIFY: + case GDK_LEAVE_NOTIFY: + return TRUE; + default: + return FALSE; + } +} + +static GtkWidget* create_busymax_titlebar_handle(MyApplication* self) { + track_widget_pointer(&self->titlebar_handle, hdy_window_handle_new()); + gtk_widget_set_hexpand(self->titlebar_handle, TRUE); + gtk_style_context_add_class( + gtk_widget_get_style_context(self->titlebar_handle), + "busymax-titlebar"); + + track_widget_pointer(&self->titlebar_overlay, gtk_overlay_new()); + gtk_widget_set_hexpand(self->titlebar_overlay, TRUE); + gtk_container_add(GTK_CONTAINER(self->titlebar_overlay), + create_busymax_titlebar(self)); + + track_widget_pointer(&self->titlebar_modal_barrier, gtk_event_box_new()); + gtk_event_box_set_visible_window( + GTK_EVENT_BOX(self->titlebar_modal_barrier), TRUE); + // GtkOverlay allocates an aligned overlay child against the titlebar's + // existing allocation. Do not give this transient shield an expand request: + // a visible vexpand child propagates through HdyWindowHandle and makes the + // titlebar consume the window's spare vertical space while a modal is open. + gtk_widget_set_halign(self->titlebar_modal_barrier, GTK_ALIGN_FILL); + gtk_widget_set_valign(self->titlebar_modal_barrier, GTK_ALIGN_FILL); + gtk_widget_set_no_show_all(self->titlebar_modal_barrier, TRUE); + gtk_widget_add_events(self->titlebar_modal_barrier, GDK_ALL_EVENTS_MASK); + g_signal_connect(self->titlebar_modal_barrier, "event", + G_CALLBACK(consume_header_bar_modal_input_cb), nullptr); + gtk_style_context_add_class( + gtk_widget_get_style_context(self->titlebar_modal_barrier), + kHeaderModalBarrierStyleClass); + gtk_overlay_add_overlay(GTK_OVERLAY(self->titlebar_overlay), + self->titlebar_modal_barrier); + gtk_overlay_set_overlay_pass_through( + GTK_OVERLAY(self->titlebar_overlay), self->titlebar_modal_barrier, FALSE); + + gtk_container_add(GTK_CONTAINER(self->titlebar_handle), + self->titlebar_overlay); + return self->titlebar_handle; +} + +static gboolean show_header_create_menu(MyApplication* self) { + if (self->header_bar_modal_barrier_visible || + !self->header_schedule_controls_visible || + self->create_button == nullptr || + !GTK_IS_MENU_BUTTON(self->create_button) || + !gtk_widget_get_visible(self->create_button) || + !gtk_widget_get_sensitive(self->create_button)) { + return FALSE; + } + + // The Flutter command is keyboard-driven. Focus the native trigger before + // opening so GTK can move directly into its model rows. + gtk_widget_grab_focus(self->create_button); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(self->create_button), TRUE); + return gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(self->create_button)); +} + static void header_bar_method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { @@ -1839,12 +4173,11 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, FlValue* args = fl_method_call_get_args(method_call); if (strcmp(method, "initialize") == 0) { respond_bool(method_call, has_header_bar(self)); + } else if (strcmp(method, "setState") == 0) { + set_header_bar_state(self, args); + respond_success(method_call); } else if (strcmp(method, "setTitleRange") == 0) { - const gchar* value = fl_method_string_arg(args); - if (self->header_title_label != nullptr && - GTK_IS_LABEL(self->header_title_label) && value != nullptr) { - gtk_label_set_text(GTK_LABEL(self->header_title_label), value); - } + set_header_title(self, fl_method_string_arg(args)); respond_success(method_call); } else if (strcmp(method, "setViewMode") == 0) { set_header_view_mode(self, fl_method_string_arg(args)); @@ -1853,19 +4186,37 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, set_widget_sensitive(self->refresh_button, fl_method_bool_arg(args)); respond_success(method_call); } else if (strcmp(method, "setCanCreate") == 0) { - set_widget_sensitive(self->create_button, fl_method_bool_arg(args)); + const gboolean can_create = fl_method_bool_arg(args); + set_header_create_capabilities(self, can_create, can_create); respond_success(method_call); + } else if (strcmp(method, "showCreateMenu") == 0) { + respond_bool(method_call, show_header_create_menu(self)); } else if (strcmp(method, "setLocalizedLabels") == 0) { set_header_localized_labels(self, args); respond_success(method_call); } else if (strcmp(method, "setSidebarWidth") == 0) { set_header_sidebar_width(self, fl_method_double_arg(args, 300)); respond_success(method_call); + } else if (strcmp(method, "setTextDirection") == 0) { + set_header_text_direction(self, fl_method_string_arg(args)); + respond_success(method_call); } else if (strcmp(method, "setSearchActive") == 0) { - set_toggle_button_active(self, self->search_button, fl_method_bool_arg(args)); + set_header_search_state(self, fl_method_bool_arg(args), + self->header_search_query); + respond_success(method_call); + } else if (strcmp(method, "focusSearch") == 0) { + respond_bool(method_call, focus_header_search_entry(self)); + } else if (strcmp(method, "setCanShowSidebar") == 0) { + set_header_can_show_sidebar(self, fl_method_bool_arg(args)); respond_success(method_call); } else if (strcmp(method, "setSidebarVisible") == 0) { - set_header_sidebar_visible(self, fl_method_bool_arg(args)); + const gboolean visible = fl_method_bool_arg(args); + if (visible) { + // Preserve the behavior of the legacy method, where making the sidebar + // visible also made its native toggle available. + set_header_can_show_sidebar(self, TRUE); + } + set_header_sidebar_visible(self, visible); respond_success(method_call); } else if (strcmp(method, "setNavigationVisible") == 0) { set_header_navigation_visible(self, fl_method_bool_arg(args)); @@ -1882,6 +4233,19 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, } else if (strcmp(method, "setModalBarrierVisible") == 0) { set_header_bar_modal_barrier_visible(self, fl_method_bool_arg(args)); respond_success(method_call); + } else if (strcmp(method, "setModalBarrierDepth") == 0) { + set_header_bar_modal_barrier_depth(self, fl_method_int_arg(args, 0)); + respond_success(method_call); + } else if (strcmp(method, "setModalBarrierState") == 0) { + gint64 shade_depth = 0; + if (!fl_lookup_int_arg(args, "shadeDepth", &shade_depth) || + shade_depth < 0 || shade_depth > G_MAXINT) { + shade_depth = 0; + } + set_header_bar_modal_barrier_state( + self, fl_lookup_bool_arg(args, "visible", FALSE), + static_cast(shade_depth)); + respond_success(method_call); } else if (strcmp(method, "setTheme") == 0) { set_header_bar_theme(self, args); respond_success(method_call); @@ -2029,6 +4393,24 @@ static gboolean sample_widget_color(GtkWidget* widget, return color_is_visible(color); } +static gboolean sample_widget_color_with_opacity( + GtkWidget* widget, + const gchar* style_class, + GtkStateFlags state, + GdkRGBA* color) { + if (!sample_widget_color(widget, style_class, state, color)) { + return FALSE; + } + GtkStyleContext* context = gtk_widget_get_style_context(widget); + gdouble opacity = 1.0; + gtk_style_context_get(context, state, "opacity", &opacity, nullptr); + if (!std::isfinite(opacity)) { + opacity = 1.0; + } + color->alpha *= CLAMP(opacity, 0.0, 1.0); + return color_is_visible(color); +} + static const gchar* brightness_for_color(const GdkRGBA* color) { if (color == nullptr) { return "light"; @@ -2041,17 +4423,14 @@ static const gchar* brightness_for_color(const GdkRGBA* color) { static FlValue* get_gtk_theme_colors() { GtkWidget* window = gtk_window_new(GTK_WINDOW_TOPLEVEL); - GtkWidget* view = gtk_text_view_new(); - GtkWidget* sidebar = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - GtkWidget* header = gtk_header_bar_new(); - GtkWidget* card = gtk_frame_new(nullptr); - GtkWidget* dialog = gtk_dialog_new(); - GtkWidget* popover = gtk_popover_new(nullptr); GtkWidget* control = gtk_button_new(); + GtkWidget* separator = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL); + GtkWidget* dim_label = gtk_label_new(nullptr); GdkRGBA window_color = {0, 0, 0, 0}; GdkRGBA view_color = {0, 0, 0, 0}; GdkRGBA sidebar_color = {0, 0, 0, 0}; + GdkRGBA secondary_sidebar_color = {0, 0, 0, 0}; GdkRGBA header_color = {0, 0, 0, 0}; GdkRGBA card_color = {0, 0, 0, 0}; GdkRGBA dialog_color = {0, 0, 0, 0}; @@ -2060,39 +4439,79 @@ static FlValue* get_gtk_theme_colors() { GdkRGBA control_hover_color = {0, 0, 0, 0}; GdkRGBA control_active_color = {0, 0, 0, 0}; GdkRGBA accent_color = {0, 0, 0, 0}; + GdkRGBA accent_foreground_color = {0, 0, 0, 0}; GdkRGBA foreground_color = {0, 0, 0, 0}; GdkRGBA muted_foreground_color = {0, 0, 0, 0}; GdkRGBA border_color = {0, 0, 0, 0}; - GdkRGBA subtle_border_color = {0, 0, 0, 0}; + GdkRGBA divider_color = {0, 0, 0, 0}; + GdkRGBA card_shade_color = {0, 0, 0, 0}; + GdkRGBA floating_border_color = {0, 0, 0, 0}; GdkRGBA sidebar_border_color = {0, 0, 0, 0}; GdkRGBA shade_color = {0, 0, 0, 0}; GtkStyleContext* window_context = gtk_widget_get_style_context(window); gtk_style_context_add_class(window_context, GTK_STYLE_CLASS_BACKGROUND); - lookup_context_color(window_context, "theme_bg_color", &window_color) || + lookup_context_color(window_context, "window_bg_color", &window_color) || + lookup_context_color(window_context, "theme_bg_color", &window_color) || sample_widget_background(window, GTK_STYLE_CLASS_BACKGROUND, GTK_STATE_FLAG_NORMAL, &window_color); - lookup_context_color(window_context, "theme_base_color", &view_color) || - sample_widget_background(view, GTK_STYLE_CLASS_VIEW, - GTK_STATE_FLAG_NORMAL, &view_color); - lookup_context_color(window_context, "theme_fg_color", &foreground_color) || + // Publish only the explicit modern view role. GTK 3's theme_base_color and + // computed .view background describe editable/list content (normally pure + // white), not the application workspace. When the named role is absent, + // Dart keeps its semantic view fallback; application workspaces choose the + // native window role explicitly. + lookup_context_color(window_context, "view_bg_color", &view_color); + lookup_context_color(window_context, "window_fg_color", &foreground_color) || + lookup_context_color(window_context, "theme_fg_color", + &foreground_color) || sample_widget_color(window, GTK_STYLE_CLASS_BACKGROUND, GTK_STATE_FLAG_NORMAL, &foreground_color); - lookup_context_color(window_context, "theme_unfocused_fg_color", - &muted_foreground_color); + sample_widget_color_with_opacity( + dim_label, GTK_STYLE_CLASS_DIM_LABEL, GTK_STATE_FLAG_NORMAL, + &muted_foreground_color); lookup_context_color(window_context, "borders", &border_color); - lookup_context_color(window_context, "theme_selected_bg_color", - &accent_color); - - sample_widget_background(sidebar, GTK_STYLE_CLASS_SIDEBAR, - GTK_STATE_FLAG_NORMAL, &sidebar_color); - sample_widget_background(header, GTK_STYLE_CLASS_TITLEBAR, - GTK_STATE_FLAG_NORMAL, &header_color); - sample_widget_background(card, "card", GTK_STATE_FLAG_NORMAL, &card_color); - sample_widget_background(dialog, GTK_STYLE_CLASS_BACKGROUND, - GTK_STATE_FLAG_NORMAL, &dialog_color); - sample_widget_background(popover, GTK_STYLE_CLASS_BACKGROUND, - GTK_STATE_FLAG_NORMAL, &popover_color); + lookup_context_color(window_context, "sidebar_border_color", + &sidebar_border_color); + // `shade_color` is the semantic modal/floating shade. `wm_shadow` is a + // substantially stronger window-decoration shadow in GTK 3 Yaru and must + // not be exported under this role. If the theme does not publish the modern + // name, Dart supplies the matching brightness-aware semantic fallback. + lookup_context_color(window_context, "shade_color", &shade_color); + lookup_context_color(window_context, "accent_bg_color", &accent_color) || + lookup_context_color(window_context, "theme_selected_bg_color", + &accent_color); + lookup_context_color(window_context, "accent_fg_color", + &accent_foreground_color) || + lookup_context_color(window_context, "theme_selected_fg_color", + &accent_foreground_color); + + // Optional modern surface roles must remain semantic. A classic GTK 3 + // widget-class sample is valid for that widget, but it is not equivalent to + // Yaru/libadwaita's modern sidebar, card, dialog, or popover role. Omit a + // role when the theme does not publish its named color so Dart can use the + // matching modern fallback instead of mislabelling a legacy sample. + lookup_context_color(window_context, "sidebar_bg_color", &sidebar_color); + lookup_context_color(window_context, "secondary_sidebar_bg_color", + &secondary_sidebar_color); + if (!color_is_visible(&secondary_sidebar_color) && + color_is_visible(&sidebar_color)) { + secondary_sidebar_color = sidebar_color; + } + lookup_context_color(window_context, "headerbar_bg_color", &header_color); + lookup_context_color(window_context, "card_bg_color", &card_color); + lookup_context_color(window_context, "card_shade_color", &card_shade_color); + lookup_context_color(window_context, "dialog_bg_color", &dialog_color); + lookup_context_color(window_context, "popover_bg_color", &popover_color); + // Only publish a named floating-surface role. Sampling GTK 3's computed + // popover border here imports its legacy light rim into the Flutter GTK 4 + // palette, where modern Yaru uses a recessed edge instead. + lookup_context_color(window_context, "popover_border_color", + &floating_border_color) || + lookup_context_color(window_context, "floating_border_color", + &floating_border_color); + sample_widget_background(separator, GTK_STYLE_CLASS_SEPARATOR, + GTK_STATE_FLAG_NORMAL, ÷r_color); + sample_widget_background(control, nullptr, GTK_STATE_FLAG_NORMAL, &control_color); sample_widget_background(control, nullptr, GTK_STATE_FLAG_PRELIGHT, @@ -2100,15 +4519,6 @@ static FlValue* get_gtk_theme_colors() { sample_widget_background(control, nullptr, GTK_STATE_FLAG_ACTIVE, &control_active_color); - if (color_is_visible(&border_color)) { - subtle_border_color = border_color; - subtle_border_color.alpha *= 0.56; - sidebar_border_color = border_color; - sidebar_border_color.alpha *= 0.72; - shade_color = border_color; - shade_color.alpha *= 0.72; - } - FlValue* result = fl_value_new_map(); fl_value_set_string_take( result, "brightness", @@ -2116,9 +4526,8 @@ static FlValue* get_gtk_theme_colors() { set_theme_color(result, "window", &window_color); set_theme_color(result, "view", &view_color); set_theme_color(result, "sidebar", &sidebar_color); - set_theme_color(result, "secondarySidebar", &sidebar_color); + set_theme_color(result, "secondarySidebar", &secondary_sidebar_color); set_theme_color(result, "headerbar", &header_color); - set_theme_color(result, "headerbarFlat", &view_color); set_theme_color(result, "card", &card_color); set_theme_color(result, "dialog", &dialog_color); set_theme_color(result, "popover", &popover_color); @@ -2126,377 +4535,218 @@ static FlValue* get_gtk_theme_colors() { set_theme_color(result, "controlHover", &control_hover_color); set_theme_color(result, "controlActive", &control_active_color); set_theme_color(result, "accent", &accent_color); + set_theme_color(result, "accentForeground", &accent_foreground_color); set_theme_color(result, "activeToggle", &control_active_color); set_theme_color(result, "foreground", &foreground_color); set_theme_color(result, "mutedForeground", &muted_foreground_color); set_theme_color(result, "border", &border_color); - set_theme_color(result, "subtleBorder", &subtle_border_color); + set_theme_color(result, "divider", ÷r_color); + set_theme_color(result, "cardShade", &card_shade_color); + set_theme_color(result, "floatingBorder", &floating_border_color); set_theme_color(result, "sidebarBorder", &sidebar_border_color); set_theme_color(result, "shade", &shade_color); + gtk_widget_destroy(dim_label); + gtk_widget_destroy(separator); gtk_widget_destroy(control); - gtk_widget_destroy(popover); - gtk_widget_destroy(dialog); - gtk_widget_destroy(card); - gtk_widget_destroy(header); - gtk_widget_destroy(sidebar); - gtk_widget_destroy(view); gtk_widget_destroy(window); return result; } +static void apply_gtk_theme_to_bootstrap_chrome(MyApplication* self) { + g_autoptr(FlValue) colors = get_gtk_theme_colors(); + const gchar* window_color = fl_lookup_string_arg(colors, "window"); + const gchar* sidebar_color = fl_lookup_string_arg(colors, "sidebar"); + + // Dart sends the complete semantic palette after its first build. Until + // then, use GTK's already-resolved active variant so the native titlebar and + // Flutter backing surface never expose the runner's dark safety fallback. + set_css_color_field(&self->header_bar_window_background_color, window_color); + set_css_color_field(&self->header_bar_background_color, window_color); + set_css_color_field( + &self->header_bar_sidebar_background_color, + is_css_color_token(sidebar_color) ? sidebar_color : window_color); + set_css_color_field( + &self->header_bar_sidebar_border_color, + fl_lookup_string_arg(colors, "sidebarBorder")); + set_css_color_field(&self->header_bar_foreground_color, + fl_lookup_string_arg(colors, "foreground")); + set_css_color_field(&self->header_bar_dialog_background_color, + fl_lookup_string_arg(colors, "dialog")); +} + static void gtk_settings_method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { const gchar* method = fl_method_call_get_name(method_call); + FlValue* args = fl_method_call_get_args(method_call); if (strcmp(method, "getGtkFont") == 0) { g_autoptr(FlValue) result = get_gtk_font_settings(); fl_method_call_respond_success(method_call, result, nullptr); } else if (strcmp(method, "getGtkThemeColors") == 0) { g_autoptr(FlValue) result = get_gtk_theme_colors(); fl_method_call_respond_success(method_call, result, nullptr); + } else if (strcmp(method, "setGtkThemePreference") == 0) { + set_gtk_theme_preference(fl_method_bool_arg(args)); + fl_method_call_respond_success(method_call, nullptr, nullptr); } else { fl_method_call_respond_not_implemented(method_call, nullptr); } } static void disconnect_gtk_font_settings_signal(MyApplication* self) { - if (self->gtk_font_settings_signal_id == 0) { - return; - } - GtkSettings* settings = gtk_settings_get_default(); - if (settings != nullptr) { - g_signal_handler_disconnect(settings, self->gtk_font_settings_signal_id); - } - self->gtk_font_settings_signal_id = 0; -} - -static void send_gtk_font_settings_event(MyApplication* self) { - if (!self->gtk_font_settings_listening || - self->gtk_font_settings_event_channel == nullptr) { - return; - } - g_autoptr(FlValue) result = get_gtk_font_settings(); - g_autoptr(GError) error = nullptr; - if (!fl_event_channel_send(self->gtk_font_settings_event_channel, result, - nullptr, &error)) { - const gchar* message = error != nullptr ? error->message : "unknown error"; - g_warning("Failed to send GTK font settings event: %s", message); - } -} - -static void gtk_font_name_notify_cb(GObject* object, - GParamSpec* pspec, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - send_gtk_font_settings_event(self); -} - -static FlMethodErrorResponse* gtk_font_settings_listen_cb( - FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - self->gtk_font_settings_listening = TRUE; - - GtkSettings* settings = gtk_settings_get_default(); - if (settings != nullptr && self->gtk_font_settings_signal_id == 0) { - self->gtk_font_settings_signal_id = - g_signal_connect(settings, "notify::gtk-font-name", - G_CALLBACK(gtk_font_name_notify_cb), self); - } - - send_gtk_font_settings_event(self); - return nullptr; -} - -static FlMethodErrorResponse* gtk_font_settings_cancel_cb( - FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - self->gtk_font_settings_listening = FALSE; - disconnect_gtk_font_settings_signal(self); - return nullptr; -} - -static void disconnect_gtk_theme_colors_signals(MyApplication* self) { - GtkSettings* settings = gtk_settings_get_default(); - if (settings != nullptr && self->gtk_theme_name_signal_id != 0) { - g_signal_handler_disconnect(settings, self->gtk_theme_name_signal_id); - } - if (settings != nullptr && self->gtk_theme_dark_signal_id != 0) { - g_signal_handler_disconnect(settings, self->gtk_theme_dark_signal_id); - } - self->gtk_theme_name_signal_id = 0; - self->gtk_theme_dark_signal_id = 0; -} - -static void send_gtk_theme_colors_event(MyApplication* self) { - if (!self->gtk_theme_colors_listening || - self->gtk_theme_colors_event_channel == nullptr) { - return; - } - g_autoptr(FlValue) result = get_gtk_theme_colors(); - g_autoptr(GError) error = nullptr; - if (!fl_event_channel_send(self->gtk_theme_colors_event_channel, result, - nullptr, &error)) { - const gchar* message = error != nullptr ? error->message : "unknown error"; - g_warning("Failed to send GTK theme colors event: %s", message); - } -} - -static void gtk_theme_colors_notify_cb(GObject* object, - GParamSpec* pspec, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - send_gtk_theme_colors_event(self); -} - -static FlMethodErrorResponse* gtk_theme_colors_listen_cb( - FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - self->gtk_theme_colors_listening = TRUE; - - GtkSettings* settings = gtk_settings_get_default(); - if (settings != nullptr && self->gtk_theme_name_signal_id == 0) { - self->gtk_theme_name_signal_id = - g_signal_connect(settings, "notify::gtk-theme-name", - G_CALLBACK(gtk_theme_colors_notify_cb), self); - } - if (settings != nullptr && self->gtk_theme_dark_signal_id == 0) { - self->gtk_theme_dark_signal_id = g_signal_connect( - settings, "notify::gtk-application-prefer-dark-theme", - G_CALLBACK(gtk_theme_colors_notify_cb), self); - } - - send_gtk_theme_colors_event(self); - return nullptr; -} - -static FlMethodErrorResponse* gtk_theme_colors_cancel_cb( - FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - self->gtk_theme_colors_listening = FALSE; - disconnect_gtk_theme_colors_signals(self); - return nullptr; -} - -static void register_gtk_settings_channel(MyApplication* self, FlView* view) { - g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); - FlBinaryMessenger* messenger = - fl_engine_get_binary_messenger(fl_view_get_engine(view)); - self->gtk_settings_channel = fl_method_channel_new( - messenger, kGtkSettingsChannel, FL_METHOD_CODEC(codec)); - fl_method_channel_set_method_call_handler( - self->gtk_settings_channel, gtk_settings_method_call_cb, self, nullptr); - - self->gtk_font_settings_event_channel = fl_event_channel_new( - messenger, kGtkFontSettingsEventChannel, FL_METHOD_CODEC(codec)); - fl_event_channel_set_stream_handlers( - self->gtk_font_settings_event_channel, gtk_font_settings_listen_cb, - gtk_font_settings_cancel_cb, self, nullptr); - - self->gtk_theme_colors_event_channel = fl_event_channel_new( - messenger, kGtkThemeColorsEventChannel, FL_METHOD_CODEC(codec)); - fl_event_channel_set_stream_handlers( - self->gtk_theme_colors_event_channel, gtk_theme_colors_listen_cb, - gtk_theme_colors_cancel_cb, self, nullptr); -} - -struct CompactGtkSettingsBridge { - FlMethodChannel* settings_channel; - FlEventChannel* font_settings_event_channel; - FlEventChannel* theme_colors_event_channel; - gulong font_settings_signal_id; - gulong theme_name_signal_id; - gulong theme_dark_signal_id; - gboolean font_settings_listening; - gboolean theme_colors_listening; -}; - -static void compact_gtk_settings_bridge_disconnect_font( - CompactGtkSettingsBridge* bridge) { - if (bridge == nullptr || bridge->font_settings_signal_id == 0) { - return; - } - GtkSettings* settings = gtk_settings_get_default(); - if (settings != nullptr) { - g_signal_handler_disconnect(settings, bridge->font_settings_signal_id); - } - bridge->font_settings_signal_id = 0; -} - -static void compact_gtk_settings_bridge_disconnect_theme( - CompactGtkSettingsBridge* bridge) { - if (bridge == nullptr) { - return; - } - GtkSettings* settings = gtk_settings_get_default(); - if (settings != nullptr && bridge->theme_name_signal_id != 0) { - g_signal_handler_disconnect(settings, bridge->theme_name_signal_id); - } - if (settings != nullptr && bridge->theme_dark_signal_id != 0) { - g_signal_handler_disconnect(settings, bridge->theme_dark_signal_id); - } - bridge->theme_name_signal_id = 0; - bridge->theme_dark_signal_id = 0; -} - -static void compact_gtk_settings_bridge_free(gpointer data) { - CompactGtkSettingsBridge* bridge = - static_cast(data); - if (bridge == nullptr) { - return; - } - compact_gtk_settings_bridge_disconnect_font(bridge); - compact_gtk_settings_bridge_disconnect_theme(bridge); - g_clear_object(&bridge->settings_channel); - g_clear_object(&bridge->font_settings_event_channel); - g_clear_object(&bridge->theme_colors_event_channel); - g_free(bridge); -} - -static void compact_gtk_settings_send_font_event( - CompactGtkSettingsBridge* bridge) { - if (bridge == nullptr || !bridge->font_settings_listening || - bridge->font_settings_event_channel == nullptr) { - return; - } - g_autoptr(FlValue) result = get_gtk_font_settings(); - g_autoptr(GError) error = nullptr; - if (!fl_event_channel_send(bridge->font_settings_event_channel, result, - nullptr, &error)) { - const gchar* message = error != nullptr ? error->message : "unknown error"; - g_warning("Failed to send compact GTK font settings event: %s", message); + if (self->gtk_font_settings_signal_id == 0) { + return; + } + GtkSettings* settings = gtk_settings_get_default(); + if (settings != nullptr) { + g_signal_handler_disconnect(settings, self->gtk_font_settings_signal_id); } + self->gtk_font_settings_signal_id = 0; } -static void compact_gtk_settings_send_theme_event( - CompactGtkSettingsBridge* bridge) { - if (bridge == nullptr || !bridge->theme_colors_listening || - bridge->theme_colors_event_channel == nullptr) { +static void send_gtk_font_settings_event(MyApplication* self) { + if (!self->gtk_font_settings_listening || + self->gtk_font_settings_event_channel == nullptr) { return; } - g_autoptr(FlValue) result = get_gtk_theme_colors(); + g_autoptr(FlValue) result = get_gtk_font_settings(); g_autoptr(GError) error = nullptr; - if (!fl_event_channel_send(bridge->theme_colors_event_channel, result, + if (!fl_event_channel_send(self->gtk_font_settings_event_channel, result, nullptr, &error)) { const gchar* message = error != nullptr ? error->message : "unknown error"; - g_warning("Failed to send compact GTK theme colors event: %s", message); + g_warning("Failed to send GTK font settings event: %s", message); } } -static void compact_gtk_font_notify_cb(GObject* object, - GParamSpec* pspec, - gpointer user_data) { - compact_gtk_settings_send_font_event( - static_cast(user_data)); -} - -static void compact_gtk_theme_notify_cb(GObject* object, - GParamSpec* pspec, - gpointer user_data) { - compact_gtk_settings_send_theme_event( - static_cast(user_data)); +static void gtk_font_name_notify_cb(GObject* object, + GParamSpec* pspec, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + send_gtk_font_settings_event(self); } -static FlMethodErrorResponse* compact_gtk_font_settings_listen_cb( +static FlMethodErrorResponse* gtk_font_settings_listen_cb( FlEventChannel* channel, FlValue* args, gpointer user_data) { - CompactGtkSettingsBridge* bridge = - static_cast(user_data); - bridge->font_settings_listening = TRUE; + MyApplication* self = MY_APPLICATION(user_data); + self->gtk_font_settings_listening = TRUE; GtkSettings* settings = gtk_settings_get_default(); - if (settings != nullptr && bridge->font_settings_signal_id == 0) { - bridge->font_settings_signal_id = + if (settings != nullptr && self->gtk_font_settings_signal_id == 0) { + self->gtk_font_settings_signal_id = g_signal_connect(settings, "notify::gtk-font-name", - G_CALLBACK(compact_gtk_font_notify_cb), bridge); + G_CALLBACK(gtk_font_name_notify_cb), self); } - compact_gtk_settings_send_font_event(bridge); + send_gtk_font_settings_event(self); return nullptr; } -static FlMethodErrorResponse* compact_gtk_font_settings_cancel_cb( +static FlMethodErrorResponse* gtk_font_settings_cancel_cb( FlEventChannel* channel, FlValue* args, gpointer user_data) { - CompactGtkSettingsBridge* bridge = - static_cast(user_data); - bridge->font_settings_listening = FALSE; - compact_gtk_settings_bridge_disconnect_font(bridge); + MyApplication* self = MY_APPLICATION(user_data); + self->gtk_font_settings_listening = FALSE; + disconnect_gtk_font_settings_signal(self); return nullptr; } -static FlMethodErrorResponse* compact_gtk_theme_colors_listen_cb( - FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - CompactGtkSettingsBridge* bridge = - static_cast(user_data); - bridge->theme_colors_listening = TRUE; +static void disconnect_gtk_theme_colors_signals(MyApplication* self) { + GtkSettings* settings = gtk_settings_get_default(); + if (settings != nullptr && self->gtk_theme_name_signal_id != 0) { + g_signal_handler_disconnect(settings, self->gtk_theme_name_signal_id); + } + if (settings != nullptr && self->gtk_theme_dark_signal_id != 0) { + g_signal_handler_disconnect(settings, self->gtk_theme_dark_signal_id); + } + self->gtk_theme_name_signal_id = 0; + self->gtk_theme_dark_signal_id = 0; +} + +static void send_gtk_theme_colors_event(MyApplication* self) { + if (!self->gtk_theme_colors_listening || + self->gtk_theme_colors_event_channel == nullptr) { + return; + } + g_autoptr(FlValue) result = get_gtk_theme_colors(); + g_autoptr(GError) error = nullptr; + if (!fl_event_channel_send(self->gtk_theme_colors_event_channel, result, + nullptr, &error)) { + const gchar* message = error != nullptr ? error->message : "unknown error"; + g_warning("Failed to send GTK theme colors event: %s", message); + } +} + +static void gtk_theme_colors_notify_cb(GObject*, + GParamSpec*, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (!self->header_bar_theme_received) { + apply_gtk_theme_to_bootstrap_chrome(self); + set_main_flutter_view_background(self); + } + refresh_header_bar_css(self); + send_gtk_theme_colors_event(self); +} +static void connect_gtk_theme_colors_signals(MyApplication* self) { GtkSettings* settings = gtk_settings_get_default(); - if (settings != nullptr && bridge->theme_name_signal_id == 0) { - bridge->theme_name_signal_id = + if (settings != nullptr && self->gtk_theme_name_signal_id == 0) { + self->gtk_theme_name_signal_id = g_signal_connect(settings, "notify::gtk-theme-name", - G_CALLBACK(compact_gtk_theme_notify_cb), bridge); + G_CALLBACK(gtk_theme_colors_notify_cb), self); } - if (settings != nullptr && bridge->theme_dark_signal_id == 0) { - bridge->theme_dark_signal_id = g_signal_connect( + if (settings != nullptr && self->gtk_theme_dark_signal_id == 0) { + self->gtk_theme_dark_signal_id = g_signal_connect( settings, "notify::gtk-application-prefer-dark-theme", - G_CALLBACK(compact_gtk_theme_notify_cb), bridge); + G_CALLBACK(gtk_theme_colors_notify_cb), self); } +} + +static FlMethodErrorResponse* gtk_theme_colors_listen_cb( + FlEventChannel* channel, + FlValue* args, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + self->gtk_theme_colors_listening = TRUE; - compact_gtk_settings_send_theme_event(bridge); + connect_gtk_theme_colors_signals(self); + send_gtk_theme_colors_event(self); return nullptr; } -static FlMethodErrorResponse* compact_gtk_theme_colors_cancel_cb( +static FlMethodErrorResponse* gtk_theme_colors_cancel_cb( FlEventChannel* channel, FlValue* args, gpointer user_data) { - CompactGtkSettingsBridge* bridge = - static_cast(user_data); - bridge->theme_colors_listening = FALSE; - compact_gtk_settings_bridge_disconnect_theme(bridge); + MyApplication* self = MY_APPLICATION(user_data); + self->gtk_theme_colors_listening = FALSE; return nullptr; } -static void register_compact_gtk_settings_channel(FlView* view, - GtkWindow* window) { - CompactGtkSettingsBridge* bridge = - g_new0(CompactGtkSettingsBridge, 1); +static void register_gtk_settings_channel(MyApplication* self, FlView* view) { g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); FlBinaryMessenger* messenger = fl_engine_get_binary_messenger(fl_view_get_engine(view)); - - bridge->settings_channel = fl_method_channel_new( + self->gtk_settings_channel = fl_method_channel_new( messenger, kGtkSettingsChannel, FL_METHOD_CODEC(codec)); fl_method_channel_set_method_call_handler( - bridge->settings_channel, gtk_settings_method_call_cb, bridge, nullptr); + self->gtk_settings_channel, gtk_settings_method_call_cb, self, nullptr); - bridge->font_settings_event_channel = fl_event_channel_new( + self->gtk_font_settings_event_channel = fl_event_channel_new( messenger, kGtkFontSettingsEventChannel, FL_METHOD_CODEC(codec)); fl_event_channel_set_stream_handlers( - bridge->font_settings_event_channel, compact_gtk_font_settings_listen_cb, - compact_gtk_font_settings_cancel_cb, bridge, nullptr); + self->gtk_font_settings_event_channel, gtk_font_settings_listen_cb, + gtk_font_settings_cancel_cb, self, nullptr); - bridge->theme_colors_event_channel = fl_event_channel_new( + self->gtk_theme_colors_event_channel = fl_event_channel_new( messenger, kGtkThemeColorsEventChannel, FL_METHOD_CODEC(codec)); fl_event_channel_set_stream_handlers( - bridge->theme_colors_event_channel, compact_gtk_theme_colors_listen_cb, - compact_gtk_theme_colors_cancel_cb, bridge, nullptr); - - g_object_set_data_full(G_OBJECT(window), "busymax-compact-gtk-settings", - bridge, compact_gtk_settings_bridge_free); + self->gtk_theme_colors_event_channel, gtk_theme_colors_listen_cb, + gtk_theme_colors_cancel_cb, self, nullptr); } static gboolean window_delete_event_cb(GtkWidget* widget, @@ -2523,7 +4773,6 @@ static void restore_main_window(MyApplication* self) { gtk_widget_show(GTK_WIDGET(self->main_window)); gtk_window_deiconify(self->main_window); gtk_window_present_with_time(self->main_window, GDK_CURRENT_TIME); - gtk_window_present(self->main_window); } static void window_method_call_cb(FlMethodChannel* channel, @@ -2573,431 +4822,6 @@ static void register_window_channel(MyApplication* self, FlView* view) { self->window_channel, window_method_call_cb, self, nullptr); } -static gboolean clear_transparent_window_cb(GtkWidget* widget, - cairo_t* cr, - gpointer user_data) { - cairo_save(cr); - cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR); - cairo_paint(cr); - cairo_restore(cr); - return FALSE; -} - -static void rounded_window_realize_cb(GtkWidget* widget, gpointer user_data); - -static gboolean rounded_window_configure_event_cb(GtkWidget* widget, - GdkEventConfigure* event, - gpointer user_data); - -static void rounded_window_size_allocate_cb(GtkWidget* widget, - GtkAllocation* allocation, - gpointer user_data); - -static gboolean rounded_window_state_event_cb(GtkWidget* widget, - GdkEventWindowState* event, - gpointer user_data); - -static gboolean configure_transparent_window_backing(GtkWindow* window) { - GdkScreen* screen = gtk_window_get_screen(window); - GdkVisual* visual = gdk_screen_get_rgba_visual(screen); - if (visual == nullptr) { - return FALSE; - } - - gtk_widget_set_visual(GTK_WIDGET(window), visual); - gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE); - g_signal_connect(window, "draw", G_CALLBACK(clear_transparent_window_cb), - nullptr); - g_signal_connect_after(window, "realize", G_CALLBACK(rounded_window_realize_cb), - nullptr); - g_signal_connect_after(window, "configure-event", - G_CALLBACK(rounded_window_configure_event_cb), - nullptr); - g_signal_connect_after(window, "size-allocate", - G_CALLBACK(rounded_window_size_allocate_cb), nullptr); - g_signal_connect_after(window, "window-state-event", - G_CALLBACK(rounded_window_state_event_cb), nullptr); - return TRUE; -} - -static cairo_region_t* create_rounded_window_region(gint width, - gint height, - gint radius) { - cairo_region_t* region = cairo_region_create(); - if (width <= 0 || height <= 0) { - return region; - } - - if (radius <= 0 || width < radius * 2 || height < radius * 2) { - const cairo_rectangle_int_t rect = {0, 0, width, height}; - cairo_region_union_rectangle(region, &rect); - return region; - } - - const gdouble radius_squared = radius * radius; - for (gint y = 0; y < height; y++) { - gint inset = 0; - if (y < radius) { - const gdouble dy = radius - y - 1; - inset = radius - static_cast(std::sqrt(radius_squared - dy * dy)); - } else if (y >= height - radius) { - const gdouble dy = y - (height - radius); - inset = radius - static_cast(std::sqrt(radius_squared - dy * dy)); - } - - const gint row_width = width - inset * 2; - if (row_width <= 0) { - continue; - } - - const cairo_rectangle_int_t row = {inset, y, row_width, 1}; - cairo_region_union_rectangle(region, &row); - } - - return region; -} - -static gboolean rounded_window_state_requires_rectangular_shape( - GdkWindowState state) { - if ((state & GDK_WINDOW_STATE_MAXIMIZED) != 0 || - (state & GDK_WINDOW_STATE_FULLSCREEN) != 0) { - return TRUE; - } -#if GTK_CHECK_VERSION(3, 10, 0) - if ((state & GDK_WINDOW_STATE_TILED) != 0) { - return TRUE; - } -#endif -#if GTK_CHECK_VERSION(3, 22, 0) - if ((state & (GDK_WINDOW_STATE_TOP_TILED | GDK_WINDOW_STATE_RIGHT_TILED | - GDK_WINDOW_STATE_BOTTOM_TILED | GDK_WINDOW_STATE_LEFT_TILED)) != - 0) { - return TRUE; - } -#endif - return FALSE; -} - -static void configure_rounded_window_shape(GtkWidget* widget) { - if (widget == nullptr || !GTK_IS_WIDGET(widget) || - !gtk_widget_get_realized(widget)) { - return; - } - - GdkWindow* window = gtk_widget_get_window(widget); - if (window == nullptr || !GDK_IS_WINDOW(window)) { - return; - } - - const GdkWindowState state = gdk_window_get_state(window); - if (rounded_window_state_requires_rectangular_shape(state)) { - gdk_window_shape_combine_region(window, nullptr, 0, 0); - gtk_widget_queue_draw(widget); - return; - } - - const gint width = gtk_widget_get_allocated_width(widget); - const gint height = gtk_widget_get_allocated_height(widget); - if (width <= 0 || height <= 0) { - return; - } - - cairo_region_t* region = - create_rounded_window_region(width, height, kHeaderWindowRadius); - gdk_window_shape_combine_region(window, region, 0, 0); - cairo_region_destroy(region); - gtk_widget_queue_draw(widget); -} - -static void rounded_window_realize_cb(GtkWidget* widget, gpointer user_data) { - configure_rounded_window_shape(widget); -} - -static gboolean rounded_window_configure_event_cb(GtkWidget* widget, - GdkEventConfigure* event, - gpointer user_data) { - configure_rounded_window_shape(widget); - return FALSE; -} - -static void rounded_window_size_allocate_cb(GtkWidget* widget, - GtkAllocation* allocation, - gpointer user_data) { - configure_rounded_window_shape(widget); -} - -static gboolean rounded_window_state_event_cb(GtkWidget* widget, - GdkEventWindowState* event, - gpointer user_data) { - if (event != nullptr && - rounded_window_state_requires_rectangular_shape( - event->new_window_state)) { - GdkWindow* window = gtk_widget_get_window(widget); - if (window != nullptr && GDK_IS_WINDOW(window)) { - gdk_window_shape_combine_region(window, nullptr, 0, 0); - gtk_widget_queue_draw(widget); - return FALSE; - } - } - configure_rounded_window_shape(widget); - return FALSE; -} - -static void install_compact_agenda_window_css(GtkWindow* window) { - static const gchar* css = - "window#busymax-compact-agenda-window," - "window#busymax-compact-agenda-window:backdrop {" - "background-color: transparent;" - "background-image: none;" - "}" - "window#busymax-compact-agenda-window decoration," - "window#busymax-compact-agenda-window decoration:backdrop {" - "background-color: transparent;" - "background-image: none;" - "border: none;" - "}"; - - g_autoptr(GError) error = nullptr; - GtkCssProvider* provider = gtk_css_provider_new(); - gtk_css_provider_load_from_data(provider, css, -1, &error); - if (error != nullptr) { - g_warning("Failed to load compact Agenda CSS: %s", error->message); - g_object_unref(provider); - return; - } - - gtk_style_context_add_provider_for_screen( - gtk_window_get_screen(window), GTK_STYLE_PROVIDER(provider), - GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); - g_object_unref(provider); -} - -static gboolean compact_agenda_number_arg(FlValue* args, - const gchar* key, - gdouble* value) { - if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { - return FALSE; - } - FlValue* arg = fl_value_lookup_string(args, key); - if (arg == nullptr) { - return FALSE; - } - - switch (fl_value_get_type(arg)) { - case FL_VALUE_TYPE_FLOAT: - *value = fl_value_get_float(arg); - return std::isfinite(*value); - case FL_VALUE_TYPE_INT: - *value = static_cast(fl_value_get_int(arg)); - return std::isfinite(*value); - default: - return FALSE; - } -} - -static gint compact_agenda_dimension_arg(FlValue* args, - const gchar* key, - gint fallback, - gint minimum, - gint maximum) { - gdouble value = fallback; - if (!compact_agenda_number_arg(args, key, &value)) { - return fallback; - } - if (value < minimum) { - return minimum; - } - if (value > maximum) { - return maximum; - } - return static_cast(value); -} - -static gboolean compact_agenda_position_arg(FlValue* args, - const gchar* key, - gint* value) { - gdouble parsed = 0; - if (!compact_agenda_number_arg(args, key, &parsed)) { - return FALSE; - } - *value = static_cast(parsed); - return TRUE; -} - -static void log_compact_agenda_geometry(GtkWindow* window, - gboolean has_position, - gint x, - gint y, - gint width, - gint height, - const gchar* native_move_status, - const gchar* phase) { - GdkDisplay* display = gtk_widget_get_display(GTK_WIDGET(window)); - const gchar* backend = - display != nullptr ? G_OBJECT_TYPE_NAME(display) : ""; - const gchar* session = g_getenv("XDG_SESSION_TYPE"); - if (session == nullptr || strlen(session) == 0) { - session = ""; - } - - GdkRectangle workarea = {-1, -1, -1, -1}; - if (display != nullptr) { - GdkMonitor* monitor = has_position - ? gdk_display_get_monitor_at_point(display, x, y) - : gdk_display_get_primary_monitor(display); - if (monitor != nullptr) { - gdk_monitor_get_workarea(monitor, &workarea); - } - } - - g_debug( - "BusyMax compact agenda positioning: phase=%s requested=%s " - "requested_x=%d requested_y=%d workarea=%d,%d,%dx%d final_size=%dx%d " - "backend=%s session=%s native_move_call_succeeded=%s", - phase, has_position ? "true" : "false", has_position ? x : -1, - has_position ? y : -1, workarea.x, workarea.y, workarea.width, - workarea.height, width, height, backend, session, native_move_status); -} - -static const gchar* move_compact_agenda_window_if_supported(GtkWindow* window, - gboolean has_position, - gint x, - gint y) { - if (!has_position) { - return "false"; - } - - GdkDisplay* display = gtk_widget_get_display(GTK_WIDGET(window)); -#ifdef GDK_WINDOWING_X11 - if (display != nullptr && GDK_IS_X11_DISPLAY(display)) { - gtk_window_move(window, x, y); - return "true"; - } -#endif - - return "skipped-non-x11"; -} - -static void apply_compact_agenda_geometry(GtkWindow* window, FlValue* args) { - const gint width = compact_agenda_dimension_arg( - args, "width", kCompactAgendaWindowWidth, kCompactAgendaWindowMinWidth, - kCompactAgendaWindowMaxWidth); - const gint height = compact_agenda_dimension_arg( - args, "height", kCompactAgendaWindowHeight, kCompactAgendaWindowMinHeight, - kCompactAgendaWindowMaxHeight); - - gtk_window_set_position(window, GTK_WIN_POS_NONE); - gtk_window_set_gravity(window, GDK_GRAVITY_NORTH_EAST); - gtk_window_set_default_size(window, width, height); - gtk_window_resize(window, width, height); - gtk_widget_set_size_request(GTK_WIDGET(window), width, height); - - GtkWidget* child = gtk_bin_get_child(GTK_BIN(window)); - if (child != nullptr) { - gtk_widget_set_size_request(child, width, height); - } - - gint x = 0; - gint y = 0; - const gboolean has_position = - compact_agenda_position_arg(args, "x", &x) && - compact_agenda_position_arg(args, "y", &y); - const gchar* native_move_status = - move_compact_agenda_window_if_supported(window, has_position, x, y); - log_compact_agenda_geometry(window, has_position, x, y, width, height, - native_move_status, "apply"); -} - -static void compact_agenda_window_method_call_cb(FlMethodChannel* channel, - FlMethodCall* method_call, - gpointer user_data) { - GtkWindow* window = GTK_WINDOW(user_data); - const gchar* method = fl_method_call_get_name(method_call); - FlValue* args = fl_method_call_get_args(method_call); - - if (strcmp(method, "setPlacement") == 0) { - apply_compact_agenda_geometry(window, args); - respond_bool(method_call, TRUE); - } else if (strcmp(method, "show") == 0) { - apply_compact_agenda_geometry(window, args); - gtk_widget_show(GTK_WIDGET(window)); - gtk_window_present(window); - apply_compact_agenda_geometry(window, args); - respond_bool(method_call, TRUE); - } else { - fl_method_call_respond_not_implemented(method_call, nullptr); - } -} - -static void register_compact_agenda_window_channel(FlView* view, - GtkWindow* window) { - g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); - FlMethodChannel* channel = fl_method_channel_new( - fl_engine_get_binary_messenger(fl_view_get_engine(view)), - kCompactAgendaWindowChannel, FL_METHOD_CODEC(codec)); - fl_method_channel_set_method_call_handler( - channel, compact_agenda_window_method_call_cb, g_object_ref(window), - g_object_unref); - g_object_set_data_full(G_OBJECT(window), "busymax-compact-agenda-channel", - channel, g_object_unref); -} - -static void configure_compact_agenda_subwindow(FlPluginRegistry* registry) { - // BusyMax creates desktop_multi_window subwindows for the compact tray - // Agenda. Configure the GTK shell before Dart/window_manager can show the - // plugin's default 1280x720 secondary window. - if (!FL_IS_VIEW(registry)) { - return; - } - - FlView* view = FL_VIEW(registry); - GtkWidget* toplevel = gtk_widget_get_toplevel(GTK_WIDGET(view)); - if (!GTK_IS_WINDOW(toplevel)) { - return; - } - - GtkWindow* window = GTK_WINDOW(toplevel); - GdkRGBA transparent = {0, 0, 0, 0}; - fl_view_set_background_color(view, &transparent); - - gtk_widget_set_name(GTK_WIDGET(window), "busymax-compact-agenda-window"); - install_compact_agenda_window_css(window); - GtkWidget* titlebar = gtk_window_get_titlebar(window); - if (titlebar != nullptr) { - gtk_widget_hide(titlebar); - } - gtk_window_set_decorated(window, FALSE); - gtk_window_set_type_hint(window, GDK_WINDOW_TYPE_HINT_UTILITY); - gtk_window_set_skip_taskbar_hint(window, TRUE); - gtk_window_set_skip_pager_hint(window, TRUE); - gtk_window_set_keep_above(window, TRUE); - gtk_window_set_gravity(window, GDK_GRAVITY_NORTH_EAST); - gtk_window_set_position(window, GTK_WIN_POS_NONE); - gtk_window_set_title(window, "BusyMax Agenda"); - gtk_window_set_resizable(window, FALSE); - gtk_window_set_default_size(window, kCompactAgendaWindowWidth, - kCompactAgendaWindowHeight); - gtk_window_resize(window, kCompactAgendaWindowWidth, - kCompactAgendaWindowHeight); - gtk_widget_set_size_request(GTK_WIDGET(window), kCompactAgendaWindowWidth, - kCompactAgendaWindowHeight); - gtk_widget_set_size_request(GTK_WIDGET(view), kCompactAgendaWindowWidth, - kCompactAgendaWindowHeight); - - GdkGeometry geometry = {}; - geometry.min_width = kCompactAgendaWindowMinWidth; - geometry.min_height = kCompactAgendaWindowMinHeight; - geometry.max_width = kCompactAgendaWindowMaxWidth; - geometry.max_height = kCompactAgendaWindowMaxHeight; - gtk_window_set_geometry_hints( - window, GTK_WIDGET(window), &geometry, - static_cast(GDK_HINT_MIN_SIZE | GDK_HINT_MAX_SIZE)); - - register_compact_agenda_window_channel(view, window); - register_compact_gtk_settings_channel(view, window); - register_native_date_time_picker_for_subwindow(view, window); -} - // Called when first Flutter frame received. static void first_frame_cb(MyApplication* self, FlView* view) { gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); @@ -3011,37 +4835,14 @@ static void my_application_activate(GApplication* application) { return; } - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + apply_gtk_theme_to_bootstrap_chrome(self); + GtkWindow* window = GTK_WINDOW(hdy_application_window_new()); + gtk_application_add_window(GTK_APPLICATION(application), window); self->main_window = window; gtk_widget_set_name(GTK_WIDGET(window), "busymax-window"); - self->main_window_transparent_backing = - configure_transparent_window_backing(window); - - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkWidget* titlebar = create_busymax_header_bar(self); - gtk_widget_show_all(titlebar); - gtk_window_set_titlebar(window, titlebar); - } else { - gtk_window_set_title(window, kApplicationDisplayName); - } + + GtkWidget* titlebar_handle = create_busymax_titlebar_handle(self); + gtk_widget_show_all(titlebar_handle); g_autoptr(GdkPixbuf) application_icon = load_application_icon(); if (application_icon != nullptr) { @@ -3054,9 +4855,13 @@ static void my_application_activate(GApplication* application) { if (application_icon == nullptr) { gtk_window_set_icon_name(window, APPLICATION_ID); } - gtk_window_set_default_size(window, 1280, 720); + gtk_window_set_default_size(window, kMainWindowDefaultWidth, + kMainWindowDefaultHeight); g_signal_connect(window, "delete-event", G_CALLBACK(window_delete_event_cb), self); + g_signal_connect( + window, "notify::is-active", + G_CALLBACK(header_focus_window_is_active_notify_cb), self); g_autoptr(FlDartProject) project = fl_dart_project_new(); fl_dart_project_set_dart_entrypoint_arguments( @@ -3066,7 +4871,13 @@ static void my_application_activate(GApplication* application) { track_widget_pointer(&self->flutter_view, GTK_WIDGET(view)); set_main_flutter_view_background(self); gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + GtkWidget* window_content = + gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_box_pack_start(GTK_BOX(window_content), titlebar_handle, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(window_content), GTK_WIDGET(view), TRUE, TRUE, 0); + gtk_widget_show(window_content); + gtk_container_add(GTK_CONTAINER(window), window_content); // Show the window when Flutter renders. // Requires the view to be realized so we can start rendering. @@ -3075,17 +4886,15 @@ static void my_application_activate(GApplication* application) { gtk_widget_realize(GTK_WIDGET(view)); fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - desktop_multi_window_plugin_set_window_created_callback( - [](FlPluginRegistry* registry) { - configure_compact_agenda_subwindow(registry); - fl_register_plugins(registry); - }); register_native_date_time_picker(self, view, window); + register_native_dialogs(self, view, window); + register_native_menus(self, view); register_window_channel(self, view); register_header_bar_channel(self, view); register_gtk_settings_channel(self, view); gtk_widget_grab_focus(GTK_WIDGET(view)); + schedule_header_bar_focus_state_refresh(self); } // Implements GApplication::local_command_line. @@ -3111,11 +4920,9 @@ static gboolean my_application_local_command_line(GApplication* application, // Implements GApplication::startup. static void my_application_startup(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application startup. - G_APPLICATION_CLASS(my_application_parent_class)->startup(application); + hdy_init(); + connect_gtk_theme_colors_signals(MY_APPLICATION(application)); } // Implements GApplication::shutdown. @@ -3130,26 +4937,46 @@ static void my_application_shutdown(GApplication* application) { // Implements GObject::dispose. static void my_application_dispose(GObject* object) { MyApplication* self = MY_APPLICATION(object); + disconnect_gtk_theme_colors_signals(self); if (self->header_bar_css_provider != nullptr) { gtk_style_context_remove_provider_for_screen( gdk_screen_get_default(), GTK_STYLE_PROVIDER(self->header_bar_css_provider)); g_clear_object(&self->header_bar_css_provider); } g_clear_object(&self->native_date_time_picker_channel); + g_clear_object(&self->native_dialog_channel); + g_clear_object(&self->native_menu_channel); g_clear_object(&self->window_channel); g_clear_object(&self->header_bar_channel); g_clear_object(&self->gtk_settings_channel); disconnect_gtk_font_settings_signal(self); g_clear_object(&self->gtk_font_settings_event_channel); - disconnect_gtk_theme_colors_signals(self); g_clear_object(&self->gtk_theme_colors_event_channel); + if (self->main_window != nullptr && GTK_IS_WIDGET(self->main_window)) { + gtk_widget_insert_action_group(GTK_WIDGET(self->main_window), "header", + nullptr); + } + g_clear_object(&self->header_view_mode_menu_action); + g_clear_object(&self->header_create_event_action); + g_clear_object(&self->header_create_task_action); + g_clear_object(&self->header_menu_action_group); + if (self->header_focus_transient_window != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(self->header_focus_transient_window), + reinterpret_cast( + &self->header_focus_transient_window)); + self->header_focus_transient_window = nullptr; + } self->main_window = nullptr; clear_widget_pointer(&self->flutter_view); + clear_widget_pointer(&self->titlebar_handle); + clear_widget_pointer(&self->titlebar_overlay); + clear_widget_pointer(&self->titlebar_modal_barrier); clear_widget_pointer(&self->titlebar_box); clear_header_bar_pointer(self); clear_widget_pointer(&self->header_start_box); - clear_widget_pointer(&self->header_title_balance_spacer); clear_widget_pointer(&self->header_title_box); + clear_widget_pointer(&self->header_title_stack); clear_widget_pointer(&self->onboarding_back_slot); clear_widget_pointer(&self->onboarding_back_button); clear_widget_pointer(&self->onboarding_continue_slot); @@ -3158,43 +4985,58 @@ static void my_application_dispose(GObject* object) { clear_widget_pointer(&self->header_brand_label); clear_widget_pointer(&self->settings_menu_button); clear_widget_pointer(&self->settings_menu); - clear_widget_pointer(&self->settings_item); - clear_widget_pointer(&self->keyboard_shortcuts_item); - clear_widget_pointer(&self->about_item); clear_widget_pointer(&self->header_view_box); clear_widget_pointer(&self->header_title_label); + clear_widget_pointer(&self->search_entry); clear_widget_pointer(&self->back_button); clear_widget_pointer(&self->sidebar_collapsed_toggle_button); clear_widget_pointer(&self->today_button); clear_widget_pointer(&self->previous_button); clear_widget_pointer(&self->next_button); clear_widget_pointer(&self->view_mode_button); - clear_widget_pointer(&self->view_mode_label); + clear_widget_pointer(&self->view_mode_icon); clear_widget_pointer(&self->view_mode_menu); - clear_widget_pointer(&self->view_mode_day_item); - clear_widget_pointer(&self->view_mode_week_item); - clear_widget_pointer(&self->view_mode_month_item); - clear_widget_pointer(&self->view_mode_year_item); - clear_widget_pointer(&self->view_mode_agenda_item); clear_widget_pointer(&self->search_button); clear_widget_pointer(&self->create_button); + clear_widget_pointer(&self->create_menu); clear_widget_pointer(&self->refresh_button); g_clear_pointer(&self->header_bar_window_background_color, g_free); g_clear_pointer(&self->header_bar_background_color, g_free); g_clear_pointer(&self->header_bar_sidebar_background_color, g_free); + g_clear_pointer(&self->header_bar_sidebar_border_color, g_free); g_clear_pointer(&self->header_bar_foreground_color, g_free); - g_clear_pointer(&self->header_bar_muted_foreground_color, g_free); - g_clear_pointer(&self->header_bar_disabled_foreground_color, g_free); - g_clear_pointer(&self->header_bar_control_color, g_free); - g_clear_pointer(&self->header_bar_control_hover_color, g_free); - g_clear_pointer(&self->header_bar_control_active_color, g_free); - g_clear_pointer(&self->header_bar_accent_color, g_free); - g_clear_pointer(&self->header_bar_accent_foreground_color, g_free); - g_clear_pointer(&self->header_bar_popover_background_color, g_free); - g_clear_pointer(&self->header_bar_border_color, g_free); - g_clear_pointer(&self->header_bar_shade_color, g_free); + g_clear_pointer(&self->header_bar_dialog_background_color, g_free); + g_clear_pointer(&self->header_bar_dialog_outline_color, g_free); g_clear_pointer(&self->header_bar_modal_barrier_color, g_free); + g_clear_pointer(&self->header_bar_tooltip_background_color, g_free); + g_clear_pointer(&self->header_bar_tooltip_foreground_color, g_free); + g_clear_pointer(&self->header_bar_tooltip_border_color, g_free); g_clear_pointer(&self->header_view_mode, g_free); + g_clear_pointer(&self->header_title_text, g_free); + g_clear_pointer(&self->header_day_label, g_free); + g_clear_pointer(&self->header_week_label, g_free); + g_clear_pointer(&self->header_month_label, g_free); + g_clear_pointer(&self->header_year_label, g_free); + g_clear_pointer(&self->header_agenda_label, g_free); + g_clear_pointer(&self->header_show_sidebar_panel_label, g_free); + g_clear_pointer(&self->header_hide_sidebar_panel_label, g_free); + g_clear_pointer(&self->header_create_event_label, g_free); + g_clear_pointer(&self->header_create_task_label, g_free); + g_clear_pointer(&self->header_settings_label, g_free); + g_clear_pointer(&self->header_keyboard_shortcuts_label, g_free); + g_clear_pointer(&self->header_report_issue_label, g_free); + g_clear_pointer(&self->header_about_label, g_free); + g_clear_pointer(&self->header_day_shortcut, g_free); + g_clear_pointer(&self->header_week_shortcut, g_free); + g_clear_pointer(&self->header_month_shortcut, g_free); + g_clear_pointer(&self->header_year_shortcut, g_free); + g_clear_pointer(&self->header_agenda_shortcut, g_free); + g_clear_pointer(&self->header_sidebar_shortcut, g_free); + g_clear_pointer(&self->header_create_event_shortcut, g_free); + g_clear_pointer(&self->header_create_task_shortcut, g_free); + g_clear_pointer(&self->header_settings_shortcut, g_free); + g_clear_pointer(&self->header_keyboard_shortcuts_shortcut, g_free); + g_clear_pointer(&self->header_search_query, g_free); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); G_OBJECT_CLASS(my_application_parent_class)->dispose(object); } @@ -3210,6 +5052,8 @@ static void my_application_class_init(MyApplicationClass* klass) { static void my_application_init(MyApplication* self) { self->native_date_time_picker_channel = nullptr; + self->native_dialog_channel = nullptr; + self->native_menu_channel = nullptr; self->window_channel = nullptr; self->header_bar_channel = nullptr; self->gtk_settings_channel = nullptr; @@ -3225,36 +5069,48 @@ static void my_application_init(MyApplication* self) { self->header_schedule_controls_visible = TRUE; self->header_back_visible = FALSE; self->header_onboarding_controls_visible = FALSE; - self->main_window_transparent_backing = FALSE; + self->header_onboarding_content_width = kHeaderOnboardingContentWidth; self->header_bar_css_provider = nullptr; self->header_bar_window_background_color = - g_strdup(kDefaultHeaderBarBackgroundColor); + g_strdup(kDefaultWindowBackgroundColor); self->header_bar_background_color = g_strdup(kDefaultHeaderBarBackgroundColor); self->header_bar_sidebar_background_color = g_strdup(kDefaultHeaderBarSidebarBackgroundColor); + self->header_bar_sidebar_border_color = nullptr; self->header_bar_foreground_color = nullptr; - self->header_bar_muted_foreground_color = nullptr; - self->header_bar_disabled_foreground_color = nullptr; - self->header_bar_control_color = nullptr; - self->header_bar_control_hover_color = nullptr; - self->header_bar_control_active_color = nullptr; - self->header_bar_accent_color = nullptr; - self->header_bar_accent_foreground_color = nullptr; - self->header_bar_popover_background_color = nullptr; - self->header_bar_border_color = nullptr; - self->header_bar_shade_color = nullptr; + self->header_bar_dialog_background_color = nullptr; + self->header_bar_dialog_outline_color = + g_strdup(kDefaultDialogOutlineColor); self->header_bar_modal_barrier_color = nullptr; + self->header_bar_tooltip_background_color = nullptr; + self->header_bar_tooltip_foreground_color = nullptr; + self->header_bar_tooltip_border_color = nullptr; + self->header_bar_tooltip_radius = kDefaultTooltipRadius; + self->header_bar_tooltip_font_size = kDefaultTooltipFontSize; + self->header_bar_tooltip_horizontal_padding = + kDefaultTooltipHorizontalPadding; + self->header_bar_tooltip_vertical_padding = + kDefaultTooltipVerticalPadding; + self->header_bar_tooltip_minimum_height = kDefaultTooltipMinimumHeight; + self->header_bar_high_contrast = FALSE; self->header_bar_sidebar_width = 300; + self->header_bar_can_show_sidebar = TRUE; self->header_bar_sidebar_visible = TRUE; self->header_bar_modal_barrier_visible = FALSE; + self->header_bar_modal_barrier_shade_depth = 0; + self->header_bar_theme_received = FALSE; self->main_window = nullptr; + self->header_focus_transient_window = nullptr; self->flutter_view = nullptr; + self->titlebar_handle = nullptr; + self->titlebar_overlay = nullptr; + self->titlebar_modal_barrier = nullptr; self->titlebar_box = nullptr; self->header_bar = nullptr; self->header_start_box = nullptr; - self->header_title_balance_spacer = nullptr; self->header_title_box = nullptr; + self->header_title_stack = nullptr; self->onboarding_back_slot = nullptr; self->onboarding_back_button = nullptr; self->onboarding_continue_slot = nullptr; @@ -3263,28 +5119,52 @@ static void my_application_init(MyApplication* self) { self->header_brand_label = nullptr; self->settings_menu_button = nullptr; self->settings_menu = nullptr; - self->settings_item = nullptr; - self->keyboard_shortcuts_item = nullptr; - self->about_item = nullptr; self->header_view_box = nullptr; self->header_title_label = nullptr; + self->search_entry = nullptr; self->back_button = nullptr; self->sidebar_collapsed_toggle_button = nullptr; self->today_button = nullptr; self->previous_button = nullptr; self->next_button = nullptr; self->view_mode_button = nullptr; - self->view_mode_label = nullptr; + self->view_mode_icon = nullptr; self->view_mode_menu = nullptr; - self->view_mode_day_item = nullptr; - self->view_mode_week_item = nullptr; - self->view_mode_month_item = nullptr; - self->view_mode_year_item = nullptr; - self->view_mode_agenda_item = nullptr; self->search_button = nullptr; self->create_button = nullptr; + self->create_menu = nullptr; self->refresh_button = nullptr; + self->header_menu_action_group = nullptr; + self->header_view_mode_menu_action = nullptr; + self->header_create_event_action = nullptr; + self->header_create_task_action = nullptr; self->header_view_mode = nullptr; + self->header_title_text = g_strdup(""); + self->header_day_label = g_strdup("Day"); + self->header_week_label = g_strdup("Week"); + self->header_month_label = g_strdup("Month"); + self->header_year_label = g_strdup("Year"); + self->header_agenda_label = g_strdup("Agenda"); + self->header_show_sidebar_panel_label = g_strdup("Show sidebar panel"); + self->header_hide_sidebar_panel_label = g_strdup("Hide sidebar panel"); + self->header_create_event_label = g_strdup("Event"); + self->header_create_task_label = g_strdup("Task"); + self->header_settings_label = g_strdup("Settings"); + self->header_keyboard_shortcuts_label = g_strdup("Keyboard Shortcuts"); + self->header_report_issue_label = g_strdup("Report an issue"); + self->header_about_label = g_strdup("About BusyMax"); + self->header_day_shortcut = g_strdup("1"); + self->header_week_shortcut = g_strdup("2"); + self->header_month_shortcut = g_strdup("3"); + self->header_year_shortcut = g_strdup("4"); + self->header_agenda_shortcut = g_strdup("5"); + self->header_sidebar_shortcut = g_strdup("F9"); + self->header_create_event_shortcut = g_strdup("E"); + self->header_create_task_shortcut = g_strdup("T"); + self->header_settings_shortcut = g_strdup("Ctrl+Alt+S"); + self->header_keyboard_shortcuts_shortcut = g_strdup("Ctrl+Alt+K"); + self->header_search_query = g_strdup(""); + self->header_search_active = FALSE; self->header_navigation_visible = TRUE; } diff --git a/pubspec.lock b/pubspec.lock index ad7d353..64fa673 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -241,14 +241,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.14" - desktop_multi_window: - dependency: "direct main" - description: - name: desktop_multi_window - sha256: "60ba38725b8887b60e44d15afdcf0c3813568b5da2ccaf1e7f6fd09a380a6e24" - url: "https://pub.dev" - source: hosted - version: "0.3.0" desktop_notifications: dependency: "direct main" description: @@ -474,14 +466,6 @@ packages: description: flutter source: sdk version: "0.0.0" - form_field_validator: - dependency: transitive - description: - name: form_field_validator - sha256: c1fc6c89b5525288c02fe989a88c6f30bb6072da4f9dded17a04cf68c4abfb87 - url: "https://pub.dev" - source: hosted - version: "1.1.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -756,14 +740,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" - password_strength: - dependency: transitive - description: - name: password_strength - sha256: "0e51e3d864e37873a1347e658147f88b66e141ee36c58e19828dc5637961e1ce" - url: "https://pub.dev" - source: hosted - version: "0.2.0" path: dependency: "direct main" description: @@ -925,7 +901,7 @@ packages: source: hosted version: "2.6.1" screen_retriever: - dependency: "direct main" + dependency: transitive description: name: screen_retriever sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" @@ -1105,6 +1081,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" + url: "https://pub.dev" + source: hosted + version: "0.11.1" typed_data: dependency: transitive description: @@ -1121,14 +1105,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.2+3" - ubuntu_widgets: - dependency: "direct main" - description: - name: ubuntu_widgets - sha256: "7eaa3bcfde7197c8ae18e4546339316fa95d445e3fa8e1fdb2d3c2ea569a5d38" - url: "https://pub.dev" - source: hosted - version: "0.8.1" url_launcher: dependency: "direct main" description: @@ -1274,7 +1250,7 @@ packages: source: hosted version: "6.3.0" window_manager: - dependency: "direct main" + dependency: transitive description: name: window_manager sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd" @@ -1316,10 +1292,10 @@ packages: dependency: "direct main" description: name: yaru - sha256: "02475cdb668b96ea6bc11a483cafe3ea80c1aee5c3f2d856361c6ea1352e5849" + sha256: "95e801c52dfda458bcb772baee6b2575c711fd951616a24910d6d30e70f5fb0d" url: "https://pub.dev" source: hosted - version: "10.1.0" + version: "10.2.0" yaru_window: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bd2d7f2..3d74a7a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymax description: BusyMax calendar and task manager. publish_to: 'none' -version: 0.1.2 +version: 0.1.4 environment: sdk: ^3.12.0 @@ -16,7 +16,6 @@ dependencies: crypto: ^3.0.0 cryptography: ^2.9.0 dbus: ^0.7.14 - desktop_multi_window: ^0.3.0 desktop_notifications: ^0.6.3 drift: ^2.33.0 file_selector: ^1.0.3 @@ -30,17 +29,15 @@ dependencies: package_info_plus: ^10.1.0 path: ^1.9.0 path_provider: ^2.1.0 - screen_retriever: ^0.2.0 sqlite3: ^3.3.0 sqlite3_flutter_libs: ^0.6.0 system_theme: ^3.3.0 + timezone: ^0.11.1 ubuntu_localizations: ^0.5.2+3 - ubuntu_widgets: ^0.8.1 url_launcher: ^6.3.0 uuid: ^4.5.0 - window_manager: ^0.5.1 xdg_status_notifier_item: ^0.0.1 - yaru: ^10.1.0 + yaru: ^10.2.0 dev_dependencies: flutter_test: diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index d3cdf54..e2d0db3 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,12 +1,13 @@ name: busymax title: BusyMax -version: "0.1.2" +version: "0.1.4" summary: Calendar and task manager description: | BusyMax is a Linux desktop calendar and task manager. It supports Google Calendar, Google Tasks, Microsoft Calendar, and Microsoft To Do. +# Snap Store listing translations are managed outside Snapcraft, via the Snap Store web UI. license: Apache-2.0 base: core24 @@ -42,6 +43,7 @@ apps: - x11 environment: GDK_BACKEND: wayland,x11 + LIBGWEATHER_LOCATIONS_PATH: $SNAP/usr/lib/x86_64-linux-gnu/libgweather-4/Locations.bin SECRET_BACKEND: file XDG_CACHE_HOME: $SNAP_USER_DATA/.cache XDG_CONFIG_HOME: $SNAP_USER_DATA/.config @@ -58,6 +60,9 @@ parts: plugin: dump source: build/linux/x64/release/bundle stage-packages: + - fonts-noto-core + - libhandy-1-0 + - libgweather-4-0t64 - liblzma5 - libsecret-1-0 override-prime: | diff --git a/test/app/about_dialog_test.dart b/test/app/about_dialog_test.dart index 3dc2675..b4aef80 100644 --- a/test/app/about_dialog_test.dart +++ b/test/app/about_dialog_test.dart @@ -1,52 +1,388 @@ import 'dart:io'; import 'package:busymax/src/app/busymax_about_dialog.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_dialog_identity.dart'; +import 'package:busymax/src/app/busymax_dialogs.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/platform/linux_header_bar_service.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:yaru/yaru.dart'; import '../test_localized_app.dart'; void main() { + setUp(() { + _setPackageInfo(version: '1.2.3', buildNumber: '45'); + }); + testWidgets('about dialog shows app identity and app links', (tester) async { await tester.pumpWidget( localizedTestApp(child: const BusyMaxAboutDialog()), ); + await tester.pumpAndSettle(); expect(find.text('BusyMax'), findsOneWidget); - expect(find.text('ToDo and Calendar'), findsOneWidget); + expect(find.text('Calendar and tasks'), findsOneWidget); + expect(find.text('License'), findsOneWidget); + expect(find.text('Apache License 2.0'), findsOneWidget); expect(find.text('Website'), findsOneWidget); - expect(find.text('Send feedback'), findsOneWidget); - expect(find.text('Report an issue'), findsOneWidget); - expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.text('https://busystack.org'), findsOneWidget); + expect(find.text('Source code'), findsOneWidget); + expect(find.text('https://github.com/busystack/busymax/'), findsOneWidget); + expect(find.text('Send feedback'), findsNothing); + expect(find.text('Report an issue'), findsNothing); + expect(find.text('v1.2.3+45'), findsOneWidget); + expect(find.byType(YaruDialogTitleBar), findsOneWidget); + expect(find.byType(YaruWindowControl), findsOneWidget); expect(find.text('Close'), findsNothing); + + final identity = find.byType(BusyMaxDialogIdentity); + final title = tester.widget( + find.descendant(of: identity, matching: find.text('BusyMax')), + ); + expect(identity, findsOneWidget); + expect( + tester.getSize( + find.descendant(of: identity, matching: find.byType(Image)), + ), + const Size.square(BusyMaxDialogIdentity.visualExtent), + ); + expect(title.style?.fontWeight, BusyMaxDialogIdentity.titleWeight); + + final titleBar = tester.widget( + find.byType(YaruDialogTitleBar), + ); + final closeButton = tester.widget( + find.byType(YaruWindowControl), + ); + expect(titleBar.isActive, isTrue); + expect(titleBar.border, BorderSide.none); + expect(closeButton.type, YaruWindowControlType.close); + expect( + tester.getSize(find.byType(YaruWindowControl)), + const Size.square(kYaruWindowControlSize), + ); }); - test('about links point to BusyStack repository', () { + testWidgets( + 'informational titlebar retains its semantic high-contrast divider', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: Colors.black, + highContrast: true, + ); + + await tester.pumpWidget( + localizedTestApp(theme: theme, child: const BusyMaxAboutDialog()), + ); + await tester.pumpAndSettle(); + + final titleBar = tester.widget( + find.byType(YaruDialogTitleBar), + ); + final colors = theme.extension()!; + expect(titleBar.border, BorderSide(color: colors.divider)); + }, + ); + + for (final textScale in [1.0, 2.0]) { + testWidgets( + 'about dialog remains scrollable in a short window at ${textScale}x text', + (tester) async { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = const Size(480, 320); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + localizedTestApp( + textScaler: TextScaler.linear(textScale), + child: const BusyMaxAboutDialog(), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + final scrollView = find.byType(SingleChildScrollView); + final closeButton = find.byType(YaruWindowControl); + expect(scrollView, findsOneWidget); + expect(closeButton.hitTestable(), findsOneWidget); + final closePosition = tester.getTopLeft(closeButton); + + await tester.drag(scrollView, const Offset(0, -400)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Source code').hitTestable(), findsOneWidget); + expect(closeButton.hitTestable(), findsOneWidget); + expect(tester.getTopLeft(closeButton), closePosition); + }, + ); + } + + testWidgets( + 'about modal route stays responsive and restores the native barrier', + (tester) async { + const channel = MethodChannel('busymax_test/about_modal_route'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return call.method == 'initialize' ? true : null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + final service = LinuxHeaderBarService(channel: channel, isLinux: true); + addTearDown(service.dispose); + await service.initialize(); + + tester.view + ..devicePixelRatio = 1 + ..physicalSize = const Size(480, 320); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + textScaler: const TextScaler.linear(2), + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + + late BuildContext dialogContext; + final result = showBusyMaxModalDialog( + hostContext, + headerBarService: service, + builder: (context) { + dialogContext = context; + return const BusyMaxAboutDialog(); + }, + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(BusyMaxAboutDialog), findsOneWidget); + expect(MediaQuery.textScalerOf(dialogContext).scale(10), 20); + expect( + tester.widget(find.byType(Dialog)).clipBehavior, + Clip.antiAlias, + ); + expect( + tester + .state( + find.descendant( + of: find.byType(BusyMaxAboutDialog), + matching: find.byType(Scrollable), + ), + ) + .position + .maxScrollExtent, + greaterThan(0), + ); + expect( + calls + .where((call) => call.method == 'setModalBarrierState') + .single + .arguments, + {'visible': true, 'shadeDepth': 1}, + ); + + await tester.tap(find.byType(YaruWindowControl)); + await tester.pumpAndSettle(); + + await result; + expect(find.byType(BusyMaxAboutDialog), findsNothing); + final barrierCalls = calls + .where((call) => call.method == 'setModalBarrierState') + .toList(); + expect(barrierCalls, hasLength(2)); + expect(barrierCalls.last.arguments, {'visible': false, 'shadeDepth': 0}); + expect(tester.takeException(), isNull); + }, + ); + + for (final brightness in Brightness.values) { + testWidgets('about version badge uses the shared neutral outlined pill in ' + '$brightness', (tester) async { + const accent = Color(0xFF3584E4); + _setPackageInfo(version: '1.2.3', buildNumber: ''); + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: accent, + ); + + await tester.pumpWidget( + localizedTestApp(theme: theme, child: const BusyMaxAboutDialog()), + ); + await tester.pumpAndSettle(); + + expect(find.text('v1.2.3'), findsOneWidget); + expect(find.text('v1.2.3+'), findsNothing); + final colors = theme.extension()!; + final badgeFinder = find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).borderRadius == + BorderRadius.circular(BusyMaxRadius.pill), + ); + final badge = tester.widget(badgeFinder); + final decoration = badge.decoration as BoxDecoration; + expect(decoration.color, colors.control); + expect( + decoration.borderRadius, + BorderRadius.circular(BusyMaxRadius.pill), + ); + expect((decoration.border! as Border).top.color, colors.divider); + expect( + find.ancestor( + of: find.text('v1.2.3'), + matching: find.byWidgetPredicate( + (widget) => + widget is Padding && + widget.padding == + const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.md, + vertical: BusyMaxSpacing.xs, + ), + ), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: badgeFinder, + matching: find.byWidgetPredicate( + (widget) => + widget is Directionality && + widget.textDirection == TextDirection.ltr, + ), + ), + findsOneWidget, + ); + final versionText = tester.widget(find.text('v1.2.3')); + final textColor = versionText.style!.color!; + expect(textColor, colors.foreground); + expect(versionText.style?.fontWeight, FontWeight.w600); + }); + } + + for (final (brightness, dialogColor, popoverColor) in const [ + (Brightness.light, Color(0xFFFAFAFA), Color(0xFFFAFAFA)), + (Brightness.dark, Color(0xFF3E3E3E), Color(0xFF3E3E3E)), + ]) { + testWidgets('about dialog keeps grouped content distinct in $brightness', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + localizedTestApp(theme: theme, child: const BusyMaxAboutDialog()), + ); + + final dialogMaterials = tester.widgetList( + find.descendant( + of: find.byType(Dialog), + matching: find.byType(Material), + ), + ); + final expectedGroupedColor = Color.alphaBlend( + colors.groupedSurface, + colors.dialog, + ); + final groupedMaterial = tester.widget( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byWidgetPredicate( + (widget) => + widget is Material && + widget.color?.toARGB32() == expectedGroupedColor.toARGB32(), + ), + ), + ); + expect(colors.dialog, dialogColor); + expect(colors.popover, popoverColor); + expect(colors.dialog, isNot(colors.sidebar)); + expect( + dialogMaterials.any((material) => material.color == dialogColor), + isTrue, + ); + expect( + groupedMaterial.color?.toARGB32(), + expectedGroupedColor.toARGB32(), + ); + expect(groupedMaterial.elevation, theme.cardTheme.elevation); + expect( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byType(Card), + ), + findsOneWidget, + ); + expect(groupedMaterial.color, isNot(dialogColor)); + if (brightness == Brightness.dark) { + expect( + groupedMaterial.color?.toARGB32(), + isNot(colors.card.toARGB32()), + ); + expect( + groupedMaterial.color!.computeLuminance(), + greaterThan(colors.dialog.computeLuminance()), + ); + } + }); + } + + test('about links match the BusyStack product metadata', () { final source = File( 'lib/src/app/busymax_about_dialog.dart', ).readAsStringSync(); + expect(source, contains('https://busystack.org')); expect(source, contains('https://github.com/busystack/busymax')); - expect(source, contains('https://github.com/busystack/busymax/issues')); + expect(source, contains('https://www.apache.org/licenses/LICENSE-2.0')); + expect(source, isNot(contains('/issues'))); expect(source, isNot(contains('https://github.com/albertgee/busymax'))); }); test( - 'about dialog uses native headerbar dimming and circular close button', + 'about dialog uses native headerbar dimming and shared close adapter', () { final source = File( 'lib/src/app/busymax_about_dialog.dart', ).readAsStringSync(); - final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final dialogs = File( + 'lib/src/app/busymax_dialogs.dart', + ).readAsStringSync(); - expect(source, contains('setModalBarrierVisible(true)')); - expect(source, contains('setModalBarrierVisible(false)')); + expect(source, contains('showBusyMaxModalDialog')); + expect(source, contains('headerBarService: headerBarService')); + expect(dialogs, contains('acquireBusyMaxModalBarrier')); + expect(dialogs, contains('releaseBusyMaxModalBarrier')); + expect(dialogs, contains('await acquireBusyMaxModalBarrier(')); + expect(dialogs, contains('await releaseBusyMaxModalBarrier(')); + expect(dialogs, contains('shadesHeader: shadesHeader')); expect(source, isNot(contains('barrierColor: Colors.transparent'))); - expect(source, contains('BusyMaxDialogCloseButton')); - expect(design, contains('CircleBorder()')); - expect(design, contains('color: surfaceColors.control')); - expect(design, contains('BusyMaxSizes.aboutCloseButton')); - expect(design, contains('BusyMaxSizes.iconSm')); + expect(source, contains('BusyMaxInformationalDialog(')); + expect(source, isNot(contains('BusyMaxPopoverIconButton('))); + expect(source, isNot(contains('BusyMaxDialogCloseButton'))); }, ); @@ -64,3 +400,13 @@ void main() { expect(source, isNot(contains('YaruIcons.calendar'))); }); } + +void _setPackageInfo({required String version, required String buildNumber}) { + PackageInfo.setMockInitialValues( + appName: 'BusyMax', + packageName: 'com.busystack.busymax', + version: version, + buildNumber: buildNumber, + buildSignature: '', + ); +} diff --git a/test/app/app_bootstrap_provider_test.dart b/test/app/app_bootstrap_provider_test.dart index 822f7f0..feb2926 100644 --- a/test/app/app_bootstrap_provider_test.dart +++ b/test/app/app_bootstrap_provider_test.dart @@ -269,18 +269,6 @@ class _FakeOAuthGateway implements OAuthGateway { @override Future refreshActiveToken() async => _tokenSet(); - @override - Future signOutAccount(String accountId) async { - if (activeId == accountId) { - activeId = null; - } - } - - @override - Future signOut() async { - activeId = null; - } - @override Future revokeAndSignOutAccount(String accountId) async { if (activeId == accountId) { @@ -289,9 +277,7 @@ class _FakeOAuthGateway implements OAuthGateway { } @override - Future revokeAndSignOut() async { - activeId = null; - } + Future revokeAuthorization(String accountId) async {} @override Future clearLocalSession({String? accountId}) async { diff --git a/test/app/app_settings_test.dart b/test/app/app_settings_test.dart new file mode 100644 index 0000000..164afe6 --- /dev/null +++ b/test/app/app_settings_test.dart @@ -0,0 +1,218 @@ +import 'package:busymax/src/app/app_settings.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('language defaults to the complete system locale preference list', () { + final settings = AppSettings.defaults(); + + expect(settings.localeTag, isNull); + expect(settings.locale, isNull); + expect(settings.toJson()['localeTag'], isNull); + }); + + test('language override persists as a canonical supported locale tag', () { + final regionalEnglish = AppSettings.fromJson(const {'localeTag': 'en_CA'}); + final traditionalChinese = AppSettings.fromJson(const { + 'localeTag': 'zh-TW', + }); + final unsupported = AppSettings.fromJson(const {'localeTag': 'xx-ZZ'}); + + expect(regionalEnglish.localeTag, 'en'); + expect(regionalEnglish.locale, const Locale('en')); + expect(traditionalChinese.localeTag, 'zh-Hant'); + expect( + traditionalChinese.locale, + const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), + ); + expect(unsupported.localeTag, isNull); + expect(unsupported.locale, isNull); + }); + + test('language preference can be persisted and reset to system', () async { + final store = _MemorySettingsStore(); + final controller = AppSettingsController(store); + addTearDown(controller.dispose); + await controller.ready; + + await controller.setLocaleTag('it'); + expect(controller.state.localeTag, 'it'); + expect(store.value['localeTag'], 'it'); + + await controller.setLocaleTag(null); + expect(controller.state.localeTag, isNull); + expect(store.value['localeTag'], isNull); + }); + + test( + 'notification detail level is the only persisted runtime setting', + () async { + final store = _MemorySettingsStore(); + final controller = AppSettingsController(store); + addTearDown(controller.dispose); + await controller.ready; + + await controller.setNotificationDetailLevel( + NotificationDetailLevel.private, + ); + + expect( + controller.state.notificationDetailLevel, + NotificationDetailLevel.private, + ); + expect(store.value['notificationDetailLevel'], 'private'); + expect(store.value, isNot(contains('detailedNotifications'))); + }, + ); + + test( + 'legacy notification privacy data migrates without overriding new data', + () { + final currentFormatWins = AppSettings.fromJson(const { + 'detailedNotifications': true, + 'notificationDetailLevel': 'private', + }); + final legacyDetailed = AppSettings.fromJson(const { + 'detailedNotifications': true, + }); + final legacyPrivate = AppSettings.fromJson(const { + 'detailedNotifications': false, + }); + + expect( + currentFormatWins.notificationDetailLevel, + NotificationDetailLevel.private, + ); + expect( + legacyDetailed.notificationDetailLevel, + NotificationDetailLevel.normal, + ); + expect( + legacyPrivate.notificationDetailLevel, + NotificationDetailLevel.private, + ); + }, + ); + + test('quiet hours persist only normalized, distinct times', () async { + final store = _MemorySettingsStore(); + final first = AppSettingsController(store); + addTearDown(first.dispose); + await first.ready; + + await first.setQuietHoursStart('21:5'); + await first.setQuietHoursEnd('6:45'); + expect(first.state.quietHoursStart, '21:05'); + expect(first.state.quietHoursEnd, '06:45'); + + await first.setQuietHoursStart('invalid'); + await first.setQuietHoursEnd('21:05'); + expect(first.state.quietHoursStart, '21:05'); + expect(first.state.quietHoursEnd, '06:45'); + + final second = AppSettingsController(store); + addTearDown(second.dispose); + await second.ready; + expect(second.state.quietHoursStart, '21:05'); + expect(second.state.quietHoursEnd, '06:45'); + }); + + test('invalid persisted quiet-hour ranges fall back safely', () { + final invalidFormat = AppSettings.fromJson(const { + 'quietHoursStart': '25:00', + 'quietHoursEnd': 'not-a-time', + }); + final equalRange = AppSettings.fromJson(const { + 'quietHoursStart': '08:00', + 'quietHoursEnd': '08:00', + }); + + expect(invalidFormat.quietHoursStart, '22:00'); + expect(invalidFormat.quietHoursEnd, '07:00'); + expect(equalRange.quietHoursStart, '22:00'); + expect(equalRange.quietHoursEnd, '07:00'); + }); + + test('tray-dependent preferences remain internally consistent', () async { + final controller = AppSettingsController(_MemorySettingsStore()); + addTearDown(controller.dispose); + await controller.ready; + + await controller.setShowTrayIcon(false); + expect(controller.state.showTrayIcon, isFalse); + expect(controller.state.runInBackgroundWhenClosed, isFalse); + expect(controller.state.startMinimizedToTray, isFalse); + + await controller.setRunInBackgroundWhenClosed(true); + expect(controller.state.showTrayIcon, isTrue); + expect(controller.state.runInBackgroundWhenClosed, isTrue); + + await controller.setShowTrayIcon(false); + await controller.setStartMinimizedToTray(true); + expect(controller.state.showTrayIcon, isTrue); + expect(controller.state.startMinimizedToTray, isTrue); + }); + + test( + 'legacy background preferences retain a recoverable tray entry point', + () { + final migrated = AppSettings.fromJson(const { + 'showTrayIcon': false, + 'runInBackgroundWhenClosed': true, + }); + + expect(migrated.showTrayIcon, isTrue); + expect(migrated.runInBackgroundWhenClosed, isTrue); + }, + ); + + test( + 'preloaded settings are available before the first provider frame', + () async { + final store = _MemorySettingsStore() + ..value = {'themeModePreference': 'dark'}; + final initialSettings = await loadInitialAppSettings(store); + final controller = AppSettingsController( + store, + initialSettings: initialSettings, + ); + addTearDown(controller.dispose); + + expect( + controller.state.themeModePreference, + BusyMaxThemeModePreference.dark, + ); + await controller.ready; + expect( + controller.state.themeModePreference, + BusyMaxThemeModePreference.dark, + ); + }, + ); + + test('preloading malformed settings falls back to defaults', () async { + final settings = await loadInitialAppSettings(_ThrowingSettingsStore()); + + expect(settings.themeModePreference, BusyMaxThemeModePreference.system); + }); +} + +class _MemorySettingsStore implements LocalSettingsStore { + Map value = {}; + + @override + Future> load() async => value; + + @override + Future save(Map json) async { + value = Map.from(json); + } +} + +class _ThrowingSettingsStore implements LocalSettingsStore { + @override + Future> load() => Future.error(const FormatException()); + + @override + Future save(Map json) async {} +} diff --git a/test/app/busymax_dialogs_test.dart b/test/app/busymax_dialogs_test.dart new file mode 100644 index 0000000..bd53b0f --- /dev/null +++ b/test/app/busymax_dialogs_test.dart @@ -0,0 +1,748 @@ +import 'dart:async'; + +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_dialogs.dart'; +import 'package:busymax/src/app/busymax_shortcuts.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/platform/linux_header_bar_provider.dart'; +import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +import '../test_localized_app.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'prompt action header and confirmation title bar use the dialog surface', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFFE95420), + ); + final colors = theme.extension()!; + expect(colors.window, isNot(colors.dialog)); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: const BusyMaxPromptDialog( + title: 'Rename calendar', + label: 'Name', + actionLabel: 'Rename', + ), + ), + ); + + expect(find.byType(BusyMaxEditorHeader), findsOneWidget); + expect(find.byType(YaruDialogTitleBar), findsNothing); + final dialog = tester.widget(find.byType(Dialog)); + expect(dialog.backgroundColor, colors.dialog); + expect(dialog.surfaceTintColor, colors.dialog); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: const BusyMaxConfirmDialog( + title: 'Discard changes?', + message: 'Unsaved changes will be lost.', + confirmLabel: 'Discard', + destructive: true, + ), + ), + ); + + final titleBar = tester.widget( + find.byType(YaruDialogTitleBar), + ); + final confirmation = tester.widget(find.byType(Dialog)); + final titleBarTheme = Theme.of( + tester.element(find.byType(YaruDialogTitleBar)), + ).appBarTheme; + final cancelButton = tester.widget( + find.widgetWithText(FilledButton, 'Cancel'), + ); + final discardButton = tester.widget( + find.widgetWithText(ElevatedButton, 'Discard'), + ); + final standardShape = + theme.filledButtonTheme.style!.shape!.resolve({})! + as RoundedRectangleBorder; + final destructiveShape = + theme.elevatedButtonTheme.style!.shape!.resolve({})! + as RoundedRectangleBorder; + expect(titleBar.backgroundColor, colors.dialog); + expect(titleBar.border, BorderSide.none); + expect(titleBarTheme.backgroundColor, colors.dialog); + expect(titleBarTheme.surfaceTintColor, colors.dialog); + expect(titleBarTheme.shadowColor, Colors.transparent); + expect(confirmation.backgroundColor, colors.dialog); + expect(confirmation.surfaceTintColor, colors.dialog); + expect(confirmation.clipBehavior, Clip.antiAlias); + expect(find.byType(BusyMaxDialogShell), findsOneWidget); + expect(cancelButton.style, isNull); + expect(discardButton.style?.shape?.resolve({}), isNull); + expect( + theme.filledButtonTheme.style?.backgroundColor?.resolve({}), + colors.control, + ); + expect( + discardButton.style?.backgroundColor?.resolve({}), + theme.colorScheme.error, + ); + expect( + standardShape.borderRadius, + BorderRadius.circular(kYaruButtonRadius), + ); + expect( + destructiveShape.borderRadius, + BorderRadius.circular(kYaruButtonRadius), + ); + }, + ); + + testWidgets('modal coordinator synchronizes the native barrier', ( + tester, + ) async { + const channel = MethodChannel('busymax_test/modal_barrier'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return call.method == 'initialize' ? true : null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService(channel: channel, isLinux: true); + addTearDown(service.dispose); + await service.initialize(); + + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final result = showBusyMaxConfirm( + hostContext, + title: 'Remove item?', + message: 'This action cannot be undone.', + confirmLabel: 'Remove', + destructive: true, + headerBarService: service, + ); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxConfirmDialog), findsOneWidget); + expect( + calls.where((call) => call.method == 'setModalBarrierState'), + hasLength(1), + ); + expect(calls.last.arguments, {'visible': true, 'shadeDepth': 1}); + + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + + expect(await result, isTrue); + final barrierCalls = calls + .where((call) => call.method == 'setModalBarrierState') + .toList(); + expect(barrierCalls, hasLength(2)); + expect(barrierCalls.first.arguments, {'visible': true, 'shadeDepth': 1}); + expect(barrierCalls.last.arguments, {'visible': false, 'shadeDepth': 0}); + }); + + testWidgets('open modal barrier follows live theme changes', (tester) async { + const accent = Color(0xFF3584E4); + final lightTheme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: accent, + ); + final darkTheme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: accent, + ); + final themeMode = ValueNotifier(ThemeMode.light); + addTearDown(themeMode.dispose); + late BuildContext hostContext; + + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: themeMode, + builder: (context, mode, child) { + return MaterialApp( + theme: lightTheme, + darkTheme: darkTheme, + themeMode: mode, + home: Builder( + builder: (context) { + hostContext = context; + return const SizedBox.shrink(); + }, + ), + ); + }, + ), + ); + + final result = showBusyMaxModalDialog( + hostContext, + builder: (context) => const Dialog(child: Text('Theme-aware dialog')), + ); + await tester.pumpAndSettle(); + + Color? currentBarrierColor() { + return tester + .widget(find.byType(AnimatedModalBarrier).last) + .color + .value; + } + + expect( + currentBarrierColor(), + lightTheme.extension()!.shade, + ); + + themeMode.value = ThemeMode.dark; + await tester.pumpAndSettle(); + + expect( + currentBarrierColor(), + darkTheme.extension()!.shade, + ); + + themeMode.value = ThemeMode.light; + await tester.pumpAndSettle(); + + expect( + currentBarrierColor(), + lightTheme.extension()!.shade, + ); + + Navigator.of(hostContext, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + await result; + }); + + testWidgets('confirmation scrolls in a short window at 2x text', ( + tester, + ) async { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = const Size(480, 320); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + const message = + 'Unsaved changes will be permanently discarded. ' + 'This cannot be undone, and any edits made since the last save will ' + 'be lost. Review the warning carefully before choosing an action.'; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: const BusyMaxConfirmDialog( + title: 'Discard all unsaved changes?', + message: message, + confirmLabel: 'Discard changes', + destructive: true, + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + final scrollView = find.descendant( + of: find.byType(BusyMaxDialogShell), + matching: find.byType(SingleChildScrollView), + ); + expect(scrollView, findsOneWidget); + final scrollable = find.descendant( + of: find.byType(BusyMaxDialogShell), + matching: find.byType(Scrollable), + ); + final position = tester.state(scrollable).position; + expect(position.maxScrollExtent, greaterThan(0)); + + await tester.drag(scrollView, const Offset(0, -120)); + await tester.pumpAndSettle(); + + expect(position.pixels, greaterThan(0)); + expect(tester.takeException(), isNull); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Discard changes'), findsOneWidget); + }); + + testWidgets('nested modals do not re-dim the native headerbar', ( + tester, + ) async { + const channel = MethodChannel('busymax_test/nested_modal_barrier'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return call.method == 'initialize' ? true : null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService(channel: channel, isLinux: true); + addTearDown(service.dispose); + await service.initialize(); + + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final first = showBusyMaxModalDialog( + hostContext, + headerBarService: service, + builder: (context) => const Dialog(child: Text('First dialog')), + ); + await tester.pumpAndSettle(); + final second = showBusyMaxModalDialog( + hostContext, + headerBarService: service, + barrierColor: Colors.transparent, + builder: (context) => const Dialog(child: Text('Second dialog')), + ); + await tester.pumpAndSettle(); + + expect( + calls + .where((call) => call.method == 'setModalBarrierState') + .map((call) => call.arguments), + [ + {'visible': true, 'shadeDepth': 1}, + ], + ); + + Navigator.of(hostContext, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + await second; + expect( + calls + .where((call) => call.method == 'setModalBarrierState') + .map((call) => call.arguments), + [ + {'visible': true, 'shadeDepth': 1}, + ], + ); + + Navigator.of(hostContext, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + await first; + + final barrierCalls = calls + .where((call) => call.method == 'setModalBarrierState') + .toList(); + expect(barrierCalls.map((call) => call.arguments), [ + {'visible': true, 'shadeDepth': 1}, + {'visible': false, 'shadeDepth': 0}, + ]); + + await acquireBusyMaxModalBarrier(service); + await acquireBusyMaxModalBarrier(service); + await releaseBusyMaxModalBarrier(service); + await releaseBusyMaxModalBarrier(service); + + expect( + calls + .where((call) => call.method == 'setModalBarrierState') + .map((call) => call.arguments) + .skip(2), + [ + {'visible': true, 'shadeDepth': 1}, + {'visible': true, 'shadeDepth': 2}, + {'visible': true, 'shadeDepth': 1}, + {'visible': false, 'shadeDepth': 0}, + ], + reason: 'two visibly shaded surfaces must still compound normally', + ); + }); + + testWidgets('serializes rapid manual native barrier transitions', ( + tester, + ) async { + const channel = MethodChannel('busymax_test/serialized_modal_barrier'); + final firstUpdate = Completer(); + final transitions = <({bool visible, int shadeDepth})>[]; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'initialize') { + return true; + } + if (call.method == 'setModalBarrierState') { + final arguments = call.arguments! as Map; + transitions.add(( + visible: arguments['visible']! as bool, + shadeDepth: arguments['shadeDepth']! as int, + )); + if (transitions.length == 1) { + await firstUpdate.future; + } + } + return null; + }); + addTearDown(() { + if (!firstUpdate.isCompleted) { + firstUpdate.complete(); + } + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService(channel: channel, isLinux: true); + addTearDown(service.dispose); + await service.initialize(); + + final acquire = acquireBusyMaxModalBarrier(service); + await tester.pump(); + expect(transitions, [(visible: true, shadeDepth: 1)]); + + final release = releaseBusyMaxModalBarrier(service); + await tester.pump(); + expect( + transitions, + [(visible: true, shadeDepth: 1)], + reason: 'the native hide must wait for the in-flight native show', + ); + + firstUpdate.complete(); + await Future.wait([acquire, release]); + + expect(transitions, [ + (visible: true, shadeDepth: 1), + (visible: false, shadeDepth: 0), + ]); + }); + + testWidgets('failed native barrier acquisition rolls back and can retry', ( + tester, + ) async { + final service = _FailingModalBarrierService(); + addTearDown(service.dispose); + + await expectLater( + acquireBusyMaxModalBarrier(service), + throwsA(isA()), + ); + expect( + service.transitions, + [(visible: true, shadeDepth: 1), (visible: false, shadeDepth: 0)], + reason: 'a failed native show requires a best-effort native rollback', + ); + + await acquireBusyMaxModalBarrier(service); + await releaseBusyMaxModalBarrier(service); + + expect(service.transitions, [ + (visible: true, shadeDepth: 1), + (visible: false, shadeDepth: 0), + (visible: true, shadeDepth: 1), + (visible: false, shadeDepth: 0), + ]); + }); + + testWidgets('modal coordinator resolves the service from ProviderScope', ( + tester, + ) async { + const channel = MethodChannel('busymax_test/automatic_modal_barrier'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return call.method == 'initialize' ? true : null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService(channel: channel, isLinux: true); + addTearDown(service.dispose); + await service.initialize(); + late BuildContext hostContext; + await tester.pumpWidget( + ProviderScope( + overrides: [linuxHeaderBarServiceProvider.overrideWithValue(service)], + child: localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ), + ); + + final result = showBusyMaxConfirm( + hostContext, + title: 'Remove item?', + message: 'This action cannot be undone.', + confirmLabel: 'Remove', + ); + await tester.pumpAndSettle(); + + expect(calls.first.method, 'initialize'); + expect( + calls + .where((call) => call.method == 'setModalBarrierState') + .single + .arguments, + {'visible': true, 'shadeDepth': 1}, + ); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(await result, isFalse); + final barrierCalls = calls + .where((call) => call.method == 'setModalBarrierState') + .toList(); + expect(barrierCalls.last.arguments, {'visible': false, 'shadeDepth': 0}); + }); + + testWidgets('editor dialog requires an explicit cancel action', ( + tester, + ) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final result = showBusyMaxModalEditorDialog( + hostContext, + builder: (dialogContext) => SizedBox( + width: 320, + height: 200, + child: Center( + child: TextButton( + onPressed: () => Navigator.of(dialogContext).pop('cancelled'), + child: const Text('Cancel editor'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tapAt(const Offset(2, 2)); + await tester.pumpAndSettle(); + expect(find.text('Cancel editor'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text('Cancel editor'), findsOneWidget); + + await tester.tap(find.text('Cancel editor')); + await tester.pumpAndSettle(); + expect(await result, 'cancelled'); + }); + + testWidgets( + 'text prompt uses the shared grouped input and selects its value', + (tester) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final result = showBusyMaxTextPrompt( + hostContext, + title: 'Rename item', + label: 'Name', + actionLabel: 'Rename', + initialValue: 'Draft name', + message: 'Choose a distinctive name.', + ); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxGroupedList), findsOneWidget); + expect(find.text('Choose a distinctive name.'), findsOneWidget); + final textField = tester.widget(find.byType(TextField)); + expect(textField.controller?.text, 'Draft name'); + expect( + textField.controller?.selection, + const TextSelection(baseOffset: 0, extentOffset: 10), + ); + expect(textField.decoration?.labelText, 'Name'); + expect(textField.decoration?.filled, isFalse); + expect(textField.decoration?.border, InputBorder.none); + expect(find.byType(BusyMaxEditorHeader), findsOneWidget); + expect(find.byType(YaruDialogTitleBar), findsNothing); + expect(find.byType(OverflowBar), findsNothing); + final inputGroupRect = tester.getRect(find.byType(BusyMaxGroupedList)); + final cancelRect = tester.getRect( + find.widgetWithText(FilledButton, 'Cancel'), + ); + final renameRect = tester.getRect( + find.widgetWithText(ElevatedButton, 'Rename'), + ); + expect(cancelRect.bottom, lessThan(inputGroupRect.top)); + expect(renameRect.bottom, lessThan(inputGroupRect.top)); + + await tester.enterText(find.byType(TextField), 'Edited name'); + await tester.tapAt(const Offset(2, 2)); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('Rename item'), findsOneWidget); + expect(find.text('Edited name'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(await result, isNull); + }, + ); + + testWidgets( + 'text prompt rejects blank input and submits valid text on Enter', + (tester) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final result = showBusyMaxTextPrompt( + hostContext, + title: 'Rename item', + label: 'Name', + actionLabel: 'Rename', + ); + await tester.pumpAndSettle(); + + final renameButton = find.widgetWithText(ElevatedButton, 'Rename'); + expect(tester.widget(renameButton).onPressed, isNull); + + await tester.enterText(find.byType(TextField), ' '); + await tester.pump(); + expect(tester.widget(renameButton).onPressed, isNull); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxPromptDialog), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'Work'); + await tester.pump(); + expect(tester.widget(renameButton).onPressed, isNotNull); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(await result, 'Work'); + expect(find.byType(BusyMaxPromptDialog), findsNothing); + }, + ); + + testWidgets('modal shortcut boundary blocks application navigation', ( + tester, + ) async { + var applicationNavigationCount = 0; + await tester.pumpWidget( + localizedTestApp( + child: Shortcuts( + shortcuts: const { + BusyMaxShortcutActivators.settings: _ApplicationNavigationIntent(), + BusyMaxShortcutActivators.keyboardShortcuts: + _ApplicationNavigationIntent(), + }, + child: Actions( + actions: { + _ApplicationNavigationIntent: + CallbackAction<_ApplicationNavigationIntent>( + onInvoke: (_) { + applicationNavigationCount += 1; + return null; + }, + ), + }, + child: const BusyMaxModalShortcutBoundary( + child: Material(child: TextField(autofocus: true)), + ), + ), + ), + ), + ); + await tester.pump(); + + for (final key in [LogicalKeyboardKey.keyS, LogicalKeyboardKey.keyK]) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(key); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + } + + expect(applicationNavigationCount, 0); + }); +} + +class _ApplicationNavigationIntent extends Intent { + const _ApplicationNavigationIntent(); +} + +class _FailingModalBarrierService extends LinuxHeaderBarService { + _FailingModalBarrierService() : super(isLinux: false); + + final transitions = <({bool visible, int shadeDepth})>[]; + var _failNextShow = true; + + @override + Future setModalBarrierState({ + required bool visible, + required int shadeDepth, + }) async { + transitions.add((visible: visible, shadeDepth: shadeDepth)); + if (visible && _failNextShow) { + _failNextShow = false; + throw StateError('simulated native response failure'); + } + } +} diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart new file mode 100644 index 0000000..327bb7f --- /dev/null +++ b/test/app/busymax_grouped_surface_test.dart @@ -0,0 +1,2278 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:busymax/src/app/app_router.dart'; +import 'package:busymax/src/app/app_theme.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +import '../test_localized_app.dart'; + +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); + +void main() { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); + }); + + testWidgets('filled surfaces use the shared native card shadow profile', ( + tester, + ) async { + const semanticShadow = Color(0xFF123456); + const semanticElevation = 3.0; + final baseTheme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final theme = baseTheme.copyWith( + cardTheme: baseTheme.cardTheme.copyWith( + elevation: semanticElevation, + shadowColor: semanticShadow, + ), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const BusyMaxSurface(child: SizedBox(width: 120, height: 48)), + ), + ); + + final card = tester.widget( + find.descendant( + of: find.byType(BusyMaxSurface), + matching: find.byType(Card), + ), + ); + final material = tester.widget( + find.descendant(of: find.byType(Card), matching: find.byType(Material)), + ); + final shadowDecoration = _nativeCardDecoration( + tester, + find.byType(BusyMaxSurface), + ); + expect(card.elevation, isNull); + expect(card.shadowColor, Colors.transparent); + expect(material.elevation, semanticElevation); + expect(material.shadowColor, Colors.transparent); + expect( + shadowDecoration.shadows, + BusyMaxShadow.nativeCardShadows(semanticShadow), + ); + }); + + testWidgets( + 'Flutter popover surfaces use the native layered shadow profile', + (tester) async { + const semanticShadow = Color.fromRGBO(32, 80, 128, 0.8); + const menuKey = ValueKey('menu-popover'); + final baseTheme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final theme = baseTheme.copyWith( + colorScheme: baseTheme.colorScheme.copyWith(shadow: semanticShadow), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Column( + children: [ + BusyMaxPopoverSurface( + key: menuKey, + color: colors.popover, + child: const SizedBox(width: 120, height: 48), + ), + const BusyMaxContentPopoverSurface( + child: SizedBox(width: 120, height: 48), + ), + ], + ), + ), + ), + ); + + final menuDecorationFinder = find.descendant( + of: find.byKey(menuKey), + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is ShapeDecoration && + ((widget.decoration as ShapeDecoration).shadows?.isNotEmpty ?? + false), + ), + ); + final contentDecorationFinder = find.descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is ShapeDecoration && + ((widget.decoration as ShapeDecoration).shadows?.isNotEmpty ?? + false), + ), + ); + + expect(menuDecorationFinder, findsOneWidget); + expect(contentDecorationFinder, findsOneWidget); + expect( + find.descendant( + of: find.byType(BusyMaxPopoverSurface), + matching: find.byType(PhysicalShape), + ), + findsNothing, + ); + final expectedMenuShadows = BusyMaxShadow.nativePopoverShadows( + semanticShadow, + ); + final expectedDetailsShadows = BusyMaxShadow.nativePopoverShadows( + semanticShadow, + role: BusyMaxPopoverShadowRole.details, + ); + for (final (finder, expectedShadows) in [ + (menuDecorationFinder, expectedMenuShadows), + (contentDecorationFinder, expectedDetailsShadows), + ]) { + final decoratedBox = tester.widget(finder); + final decoration = decoratedBox.decoration as ShapeDecoration; + expect(decoration.shadows, expectedShadows); + for (final shadow in decoration.shadows!) { + expect(shadow.color.r, semanticShadow.r); + expect(shadow.color.g, semanticShadow.g); + expect(shadow.color.b, semanticShadow.b); + } + } + expect(expectedMenuShadows[0].blurRadius, 14); + expect(expectedMenuShadows[1].blurRadius, 5); + expect(expectedDetailsShadows[0].blurRadius, 13.5); + expect(expectedDetailsShadows[1].blurRadius, 4.5); + expect(expectedMenuShadows[0].color.a, closeTo(0.8 * 0.05, 0.0001)); + expect(expectedMenuShadows[1].color.a, closeTo(0.8 * 0.09, 0.0001)); + expect( + expectedDetailsShadows.map((shadow) => shadow.color), + expectedMenuShadows.map((shadow) => shadow.color), + ); + expect(BusyMaxShadow.nativePopoverPaintMargin, 31); + }, + ); + + test('Settings and reused Year calendars use shared grouped cards', () { + final settings = File( + 'lib/src/features/settings/presentation/settings_screen.dart', + ).readAsStringSync(); + final yearView = File( + 'lib/src/features/schedule/presentation/schedule_year_view.dart', + ).readAsStringSync(); + + expect(settings, contains('BusyMaxGroupedList(')); + expect(yearView, contains('YearMonthMiniCalendar(')); + expect(yearView, contains('BusyMaxGroupedSurface(')); + for (final source in [settings, yearView]) { + expect(source, isNot(contains('BoxShadow('))); + expect(source, isNot(contains('elevation:'))); + } + }); + + for (final brightness in Brightness.values) { + testWidgets( + 'grouped list uses the semantic $brightness surface and Yaru rows', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow( + title: 'Calendar', + subtitle: 'Personal account', + onTap: () {}, + ), + const BusyMaxSwitchRow( + title: 'Notifications', + subtitle: 'Sync changes automatically', + value: true, + onChanged: _ignoreBool, + ), + ], + ), + ), + ), + ); + + final groupedSurface = find.byType(BusyMaxGroupedSurface); + expect(groupedSurface, findsOneWidget); + final materialSurface = tester.widget( + find.descendant( + of: groupedSurface, + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == colors.card, + ), + ), + ); + expect(materialSurface.color, theme.cardTheme.color); + expect(materialSurface.color?.a, 1); + expect(materialSurface.elevation, theme.cardTheme.elevation); + expect(materialSurface.shadowColor, Colors.transparent); + expect( + _nativeCardDecoration(tester, groupedSurface).shadows, + BusyMaxShadow.nativeCardShadows(theme.colorScheme.shadow), + ); + final shape = materialSurface.shape! as RoundedRectangleBorder; + expect(shape.side, BorderSide.none); + expect( + find.descendant(of: groupedSurface, matching: find.byType(Card)), + findsOneWidget, + ); + expect( + find.descendant( + of: groupedSurface, + matching: find.byType(YaruListTile), + ), + findsNWidgets(2), + ); + final divider = tester.widget( + find.descendant(of: groupedSurface, matching: find.byType(Divider)), + ); + expect(divider.height, 1); + expect(divider.thickness, 1); + expect(divider.color, colors.cardShade); + expect(colors.cardShade, isNot(colors.divider)); + expect( + Color.alphaBlend(colors.cardShade, colors.card).toARGB32(), + switch (brightness) { + Brightness.light => const Color(0xFFEDEDED).toARGB32(), + Brightness.dark => const Color(0xFF272727).toARGB32(), + }, + ); + + final materialLayers = tester.widgetList( + find.descendant(of: groupedSurface, matching: find.byType(Material)), + ); + expect( + materialLayers.where((material) => material.color == colors.control), + isEmpty, + ); + expect( + DefaultTextStyle.of( + tester.element(find.text('Personal account')), + ).style.color, + colors.mutedForeground, + ); + expect( + DefaultTextStyle.of( + tester.element(find.text('Sync changes automatically')), + ).style.color, + colors.mutedForeground, + ); + }, + ); + } + + testWidgets('unfilled surfaces stay flat and borderless', (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const BusyMaxSurface( + filled: false, + child: SizedBox(width: 120, height: 48), + ), + ), + ); + + final material = tester.widget( + find.descendant( + of: find.byType(BusyMaxSurface), + matching: find.byType(Material), + ), + ); + final shape = material.shape! as RoundedRectangleBorder; + expect(material.color, Colors.transparent); + expect(material.elevation, 0); + expect(shape.side, BorderSide.none); + expect( + find.descendant( + of: find.byType(BusyMaxSurface), + matching: find.byType(Card), + ), + findsNothing, + ); + }); + + testWidgets('dialog grouped surface stays raised in dark mode', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + late Color resolvedSurface; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: BusyMaxSurfaceScope( + role: BusyMaxSurfaceRole.window, + child: Builder( + builder: (context) { + resolvedSurface = busyMaxGroupedSurfaceColor( + context, + parentRole: BusyMaxSurfaceRole.dialog, + ); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + expect( + resolvedSurface.toARGB32(), + Color.alphaBlend(colors.groupedSurface, colors.dialog).toARGB32(), + ); + expect(resolvedSurface, isNot(colors.dialog)); + expect( + resolvedSurface.computeLuminance(), + greaterThan(colors.dialog.computeLuminance()), + ); + }); + + testWidgets('disabled grouped subtitles use the semantic disabled role', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: BusyMaxActionRow( + title: 'Calendar', + subtitle: 'Account unavailable', + enabled: false, + ), + ), + ), + ); + + expect( + DefaultTextStyle.of( + tester.element(find.text('Account unavailable')), + ).style.color, + colors.disabledForeground, + ); + }); + + testWidgets( + 'disabled shared header and destructive actions use semantic roles', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Builder( + builder: (context) => Column( + children: [ + BusyMaxHeaderIconButton( + key: const ValueKey('disabled-header-icon'), + tooltip: 'Disabled icon action', + icon: const Icon(Icons.edit_outlined), + onPressed: null, + foregroundColor: colors.foreground, + backgroundColor: busyMaxHeaderButtonBackground(context), + ), + TextButton( + key: const ValueKey('disabled-header-text'), + onPressed: null, + style: busyMaxHeaderTextButtonStyle( + context, + foregroundColor: colors.foreground, + backgroundColor: busyMaxHeaderButtonBackground(context), + ), + child: const Text('Disabled text action'), + ), + const BusyMaxActionRow( + title: 'Disabled destructive action', + destructive: true, + enabled: false, + ), + ], + ), + ), + ), + ), + ); + + final icon = find.descendant( + of: find.byKey(const ValueKey('disabled-header-icon')), + matching: find.byIcon(Icons.edit_outlined), + ); + expect( + IconTheme.of(tester.element(icon)).color, + colors.disabledForeground, + ); + expect( + DefaultTextStyle.of( + tester.element(find.text('Disabled text action')), + ).style.color, + colors.disabledForeground, + ); + expect( + tester + .widget(find.text('Disabled destructive action')) + .style + ?.color, + colors.disabledForeground, + ); + expect( + busyMaxHeaderButtonBackground( + tester.element(find.byKey(const ValueKey('disabled-header-icon'))), + ).resolve({WidgetState.disabled}), + colors.disabledControl, + ); + final iconButton = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('disabled-header-icon')), + matching: find.byType(IconButton), + ), + ); + expect(iconButton.style?.backgroundColor?.resolve({}), colors.control); + expect( + iconButton.style?.backgroundColor?.resolve({WidgetState.hovered}), + colors.controlHover, + ); + expect( + iconButton.style?.backgroundColor?.resolve({WidgetState.pressed}), + colors.controlActive, + ); + }, + ); + + testWidgets('grouped cards add a semantic outline in high contrast', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + highContrast: true, + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const MediaQuery( + data: MediaQueryData(highContrast: true), + child: BusyMaxGroupedSurface(child: SizedBox(height: 48)), + ), + ), + ); + + final materialSurface = tester.widget( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byType(Material), + ), + ); + final shape = materialSurface.shape! as RoundedRectangleBorder; + expect(shape.side.color, theme.colorScheme.outline); + expect(shape.side.width, BusyMaxStroke.outline); + }); + + testWidgets('grouped cards preserve inherited card geometry and outline', ( + tester, + ) async { + const inheritedSide = BorderSide(color: Color(0xFF4A4A4A), width: 2); + const inheritedRadius = BorderRadius.all(Radius.circular(9)); + final baseTheme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final theme = baseTheme.copyWith( + cardTheme: baseTheme.cardTheme.copyWith( + shape: const RoundedRectangleBorder( + borderRadius: inheritedRadius, + side: inheritedSide, + ), + ), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const BusyMaxGroupedSurface( + child: SizedBox(width: 120, height: 48), + ), + ), + ); + + final materialSurface = tester.widget( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byType(Material), + ), + ); + final shape = materialSurface.shape! as RoundedRectangleBorder; + expect(shape.borderRadius, inheritedRadius); + expect(shape.side, inheritedSide); + }); + + testWidgets('grouped cards inherit the semantic card shadow color', ( + tester, + ) async { + const semanticShadow = Color(0x80123456); + final baseTheme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final theme = baseTheme.copyWith( + cardTheme: baseTheme.cardTheme.copyWith(shadowColor: semanticShadow), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const BusyMaxGroupedSurface( + child: SizedBox(width: 120, height: 48), + ), + ), + ); + + final decoration = _nativeCardDecoration( + tester, + find.byType(BusyMaxGroupedSurface), + ); + final expectedShadows = BusyMaxShadow.nativeCardShadows(semanticShadow); + expect(decoration.shadows, expectedShadows); + for (final shadow in decoration.shadows!) { + expect(shadow.color.r, semanticShadow.r); + expect(shadow.color.g, semanticShadow.g); + expect(shadow.color.b, semanticShadow.b); + } + expect( + expectedShadows[0].color.a, + closeTo(semanticShadow.a * 0.03, 0.0001), + ); + expect( + expectedShadows[1].color.a, + closeTo(semanticShadow.a * 0.07, 0.0001), + ); + expect( + expectedShadows[2].color.a, + closeTo(semanticShadow.a * 0.03, 0.0001), + ); + }); + + testWidgets( + 'grouped card paints its opaque semantic role in a dark editor sheet', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: BusyMaxModalEditorSurface( + child: BusyMaxGroupedList( + filled: true, + children: [BusyMaxActionRow(title: 'Calendar', onTap: () {})], + ), + ), + ), + ), + ); + + final groupedMaterial = tester.widget( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == colors.card, + ), + ), + ); + final editorDialog = tester.widget( + find.descendant( + of: find.byType(BusyMaxModalEditorSurface), + matching: find.byType(Dialog), + ), + ); + final cardOnEditor = Color.alphaBlend( + colors.groupedSurface, + colors.window, + ); + expect(colors.groupedSurface.a, lessThan(1)); + expect(cardOnEditor.toARGB32(), colors.card.toARGB32()); + expect(colors.card.a, 1); + expect(editorDialog.backgroundColor, colors.window); + expect(editorDialog.surfaceTintColor, colors.window); + expect(theme.dialogTheme.backgroundColor, colors.dialog); + expect(colors.dialog, isNot(colors.window)); + expect(cardOnEditor, isNot(colors.window)); + expect( + cardOnEditor.computeLuminance(), + greaterThan(colors.window.computeLuminance()), + ); + expect(groupedMaterial.color, colors.card); + expect(groupedMaterial.color?.a, 1); + expect(groupedMaterial.elevation, theme.cardTheme.elevation); + expect(groupedMaterial.shadowColor, Colors.transparent); + expect( + _nativeCardDecoration( + tester, + find.byType(BusyMaxGroupedSurface), + ).shadows, + BusyMaxShadow.nativeCardShadows(theme.colorScheme.shadow), + ); + }, + ); + + for (final brightness in Brightness.values) { + testWidgets('rows use the native $brightness boxed-row hover role', ( + tester, + ) async { + final baseTheme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + const rowHover = Color(0x1A2A7FFF); + final theme = baseTheme.copyWith(hoverColor: rowHover); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Column( + children: [ + BusyMaxActionRow(title: 'Calendar', onTap: () {}), + const BusyMaxSwitchRow( + title: 'Notifications', + value: true, + onChanged: _ignoreBool, + ), + ], + ), + ), + ), + ); + + expect(theme.hoverColor, rowHover); + final expectedHover = brightness == Brightness.dark + ? rowHover + : rowHover.withValues( + alpha: rowHover.a * BusyMaxAlpha.groupedRowLightHoverStrength, + ); + final actionTile = tester.widget( + find.descendant( + of: find.byType(BusyMaxActionRow), + matching: find.byType(YaruListTile), + ), + ); + final switchTile = tester.widget( + find.descendant( + of: find.byType(BusyMaxSwitchRow), + matching: find.byType(YaruListTile), + ), + ); + expect(actionTile.hoverColor, expectedHover); + expect(switchTile.hoverColor, expectedHover); + if (brightness == Brightness.light) { + expect(expectedHover.a, lessThan(rowHover.a)); + } else { + expect(expectedHover, rowHover); + } + expect(expectedHover.r, rowHover.r); + expect(expectedHover.g, rowHover.g); + expect(expectedHover.b, rowHover.b); + }); + + testWidgets('rows apply the native $brightness boxed-row hover strength', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: BusyMaxActionRow(title: 'Calendar', onTap: () {}), + ), + ), + ); + + final tile = tester.widget( + find.descendant( + of: find.byType(BusyMaxActionRow), + matching: find.byType(YaruListTile), + ), + ); + expect(theme.hoverColor.a, closeTo(10 / 255, 0.0001)); + final expectedAlpha = brightness == Brightness.dark + ? theme.hoverColor.a + : theme.hoverColor.a * BusyMaxAlpha.groupedRowLightHoverStrength; + expect(tile.hoverColor?.a, closeTo(expectedAlpha, 0.0001)); + expect( + tile.hoverColor?.a, + closeTo(brightness == Brightness.dark ? 10 / 255 : 0.02, 0.001), + ); + expect(tile.hoverColor, isNot(colors.controlHover)); + }); + } + + testWidgets('high-contrast grouped rows retain the full hover signal', ( + tester, + ) async { + final theme = buildBusyMaxTheme( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + highContrast: true, + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: BusyMaxActionRow(title: 'Calendar', onTap: () {}), + ), + ), + ); + + final tile = tester.widget( + find.descendant( + of: find.byType(BusyMaxActionRow), + matching: find.byType(YaruListTile), + ), + ); + expect(theme.colorScheme.isHighContrast, isTrue); + expect(tile.hoverColor, theme.hoverColor); + }); + + testWidgets('grouped entry keeps interaction inside its field', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Builder( + builder: (context) => YaruListTile.square( + title: TextField( + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: 'Title', + ), + ), + ), + ), + ), + ), + ); + + final entryTile = tester.widget(find.byType(YaruListTile)); + final field = tester.widget(find.byType(TextField)); + expect(entryTile.onTap, isNull); + expect(entryTile.hoverColor, isNull); + expect(field.decoration?.hoverColor, Colors.transparent); + }); + + testWidgets('sidebar surface draws the semantic directional end boundary', ( + tester, + ) async { + await tester.pumpWidget( + _testApp( + const BusyMaxSidebarSurface( + child: SizedBox(width: BusyMaxSizes.sidebarWidth, height: 200), + ), + ), + ); + final context = tester.element(find.byType(BusyMaxSidebarSurface)); + final colors = BusyMaxSurfaceColors.of(context); + final material = tester.widget( + find.descendant( + of: find.byType(BusyMaxSidebarSurface), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == colors.sidebar, + ), + ), + ); + final decorated = tester.widget( + find.descendant( + of: find.byType(BusyMaxSidebarSurface), + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).border is BorderDirectional, + ), + ), + ); + final border = (decorated.decoration as BoxDecoration).border!; + + expect(material.color, colors.sidebar); + expect(decorated.position, DecorationPosition.foreground); + expect(border, isA()); + expect((border as BorderDirectional).end.color, colors.sidebarBorder); + expect(border.end.width, BusyMaxStroke.outline); + }); + + testWidgets('startup view preserves the responsive workspace split', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + theme: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), + home: const BusyMaxStartupView(), + ), + ); + + final sidebar = find.byKey(const ValueKey('startup-sidebar')); + final content = find.byKey(const ValueKey('startup-content')); + final sidebarRect = tester.getRect(sidebar); + final contentRect = tester.getRect(content); + expect(sidebarRect.width, BusyMaxSizes.sidebarWidth); + expect(sidebarRect.left, 0); + expect(sidebarRect.right, contentRect.left); + expect(sidebarRect.height, 720); + expect(find.byType(BusyMaxSidebarSurface), findsOneWidget); + expect(find.byType(YaruCircularProgressIndicator), findsOneWidget); + + tester.view.physicalSize = const Size(600, 720); + await tester.pump(); + + expect(sidebar, findsNothing); + expect(tester.getRect(content), const Rect.fromLTWH(0, 0, 600, 720)); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'sidebar navigation delegates native geometry and states to Yaru', + (tester) async { + var selectedSchedule = false; + await tester.pumpWidget( + _testApp( + SizedBox( + width: BusyMaxSizes.sidebarWidth, + height: 200, + child: BusyMaxSidebarSurface( + child: BusyMaxSidebarNavigation( + children: [ + BusyMaxSidebarNavigationTile( + selected: true, + leading: const Icon(YaruIcons.user), + title: const Text('Accounts'), + onTap: () {}, + ), + BusyMaxSidebarNavigationTile( + selected: false, + leading: const Icon(YaruIcons.calendar_day), + title: const Text('Schedule'), + onTap: () => selectedSchedule = true, + ), + ], + ), + ), + ), + ), + ); + + expect(find.byType(YaruMasterTile), findsNWidgets(2)); + expect(find.byType(YaruNavigationRailItem), findsNothing); + + final firstTile = find.byType(YaruMasterTile).first; + final localContext = tester.element(firstTile); + final localTheme = Theme.of(localContext); + final colors = BusyMaxSurfaceColors.of(localContext); + final listTileTheme = localTheme.listTileTheme; + final parentTheme = Theme.of( + tester.element(find.byType(BusyMaxSidebarNavigation)), + ); + + expect(tester.widget(firstTile).selected, isTrue); + expect(tester.getSize(firstTile).height, BusyMaxSizes.sidebarRowHeight); + expect( + listTileTheme.selectedTileColor, + Color.alphaBlend(colors.control, colors.sidebar), + ); + expect(listTileTheme.selectedColor, colors.foreground); + expect(listTileTheme.iconColor, colors.mutedForeground); + expect(listTileTheme.titleTextStyle, localTheme.textTheme.bodyMedium); + expect(listTileTheme.minTileHeight, BusyMaxSizes.sidebarRowHeight); + expect(listTileTheme.horizontalTitleGap, BusyMaxSpacing.sm); + expect(listTileTheme.minLeadingWidth, BusyMaxSizes.iconSm); + expect(localTheme.hoverColor, parentTheme.hoverColor); + expect(localTheme.focusColor, parentTheme.focusColor); + expect(localTheme.highlightColor, parentTheme.highlightColor); + + await tester.tap(find.text('Schedule')); + await tester.pump(); + expect(selectedSchedule, isTrue); + }, + ); + + test('all primary sidebars reuse the shared boundary surface', () { + for (final path in [ + 'lib/src/features/schedule/presentation/schedule_sidebar.dart', + 'lib/src/features/settings/presentation/settings_screen.dart', + ]) { + final source = File(path).readAsStringSync(); + expect(source, contains('BusyMaxSidebarSurface('), reason: path); + expect( + source, + isNot(contains('color: BusyMaxSurfaceColors.of(context).sidebar')), + reason: path, + ); + } + }); + + testWidgets('action row distinguishes keyboard and pointer activation', ( + tester, + ) async { + final activations = []; + await tester.pumpWidget( + _testApp( + BusyMaxActionRow( + title: 'Calendar', + onActivated: (_, globalPosition) { + activations.add(globalPosition); + }, + ), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(activations, [isNull]); + + final pointerPosition = tester.getCenter(find.text('Calendar')); + await tester.tapAt(pointerPosition); + await tester.pump(); + + expect(activations, [isNull, pointerPosition]); + + final tile = tester.widget( + find.descendant( + of: find.byType(BusyMaxActionRow), + matching: find.byType(YaruListTile), + ), + ); + tile.onTap!(); + + expect(activations, [isNull, pointerPosition, isNull]); + }); + + testWidgets('nested checkbox does not activate its action row', ( + tester, + ) async { + final activations = []; + var completionChanges = 0; + await tester.pumpWidget( + _testApp( + BusyMaxActionRow( + title: 'Prepare release', + trailing: YaruCheckbox( + value: false, + onChanged: (_) => completionChanges += 1, + ), + onActivated: (_, globalPosition) { + activations.add(globalPosition); + }, + ), + ), + ); + + await tester.tap(find.byType(YaruCheckbox)); + await tester.pump(); + + expect(completionChanges, 1); + expect(activations, isEmpty); + + final tile = tester.widget( + find.descendant( + of: find.byType(BusyMaxActionRow), + matching: find.byType(YaruListTile), + ), + ); + tile.onTap!(); + + expect(activations, [isNull]); + }); + + testWidgets('combo row keeps a trailing action independently accessible', ( + tester, + ) async { + final selections = []; + var trailingActivations = 0; + final semanticsHandle = tester.ensureSemantics(); + + await tester.pumpWidget( + _testApp( + BusyMaxComboRow( + title: 'Reminder', + values: const ['5 minutes', '10 minutes'], + selected: '5 minutes', + labelFor: (value) => value, + onSelected: selections.add, + trailingAction: BusyMaxHeaderIconButton( + icon: const Icon(YaruIcons.window_close), + tooltip: 'Remove reminder', + onPressed: () => trailingActivations += 1, + ), + ), + ), + ); + + expect(find.bySemanticsLabel('Reminder'), findsOneWidget); + final trailingButton = find.byType(IconButton); + expect(tester.getSemantics(trailingButton).tooltip, 'Remove reminder'); + + await tester.tap(trailingButton); + await tester.pump(); + + expect(trailingActivations, 1); + expect(selections, isEmpty); + expect( + find + .byWidgetPredicate((widget) => widget is PopupMenuItem) + .hitTestable(), + findsNothing, + ); + semanticsHandle.dispose(); + }); + + testWidgets('disabled combo row cannot open or receive keyboard focus', ( + tester, + ) async { + final selected = []; + await tester.pumpWidget( + _testApp( + BusyMaxComboRow( + title: 'Calendar', + values: const ['Personal', 'Work'], + selected: 'Personal', + labelFor: (value) => value, + onSelected: selected.add, + enabled: false, + ), + ), + ); + + final combo = find.byType(BusyMaxComboRow); + final trigger = find.descendant(of: combo, matching: _comboTriggerFinder()); + expect(trigger, findsOneWidget); + + await tester.tap(trigger, warnIfMissed: false); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect( + find + .byWidgetPredicate((widget) => widget is PopupMenuItem) + .hitTestable(), + findsNothing, + ); + expect(find.byType(YaruRadio).hitTestable(), findsNothing); + expect(selected, isEmpty); + final disabledSemantics = tester.widget( + find.descendant( + of: combo, + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.label == 'Calendar', + ), + ), + ); + expect(disabledSemantics.properties.button, isTrue); + expect(disabledSemantics.properties.enabled, isFalse); + expect(disabledSemantics.properties.onTap, isNull); + expect(disabledSemantics.properties.value, 'Personal'); + }); + + testWidgets('combo row delegates selection to the shared native menu path', ( + tester, + ) async { + final selections = []; + await tester.pumpWidget( + _testApp( + Directionality( + textDirection: TextDirection.rtl, + child: BusyMaxComboRow( + title: 'Calendar', + values: const [1, 2], + selected: 1, + labelFor: (value) => 'Calendar $value', + onSelected: selections.add, + ), + ), + ), + ); + + final triggerFinder = find.descendant( + of: find.byType(BusyMaxComboRow), + matching: _comboTriggerFinder(), + ); + final trigger = tester.widget(triggerFinder); + expect(trigger.onTap, isNotNull); + expect( + find.descendant( + of: find.byType(BusyMaxComboRow), + matching: find.byWidgetPredicate( + (widget) => widget is ButtonStyleButton && widget is! IconButton, + ), + ), + findsNothing, + ); + final restingSurface = tester.widget( + find.descendant(of: triggerFinder, matching: find.byType(Material)).first, + ); + expect(restingSurface.color, Colors.transparent); + + final selectedRect = tester.getRect(find.text('Calendar 1').first); + final arrowRect = tester.getRect( + find + .descendant( + of: triggerFinder, + matching: find.byIcon(YaruIcons.pan_down), + ) + .hitTestable(), + ); + expect(arrowRect.right, lessThanOrEqualTo(selectedRect.left)); + + await tester.tap(triggerFinder); + await tester.pumpAndSettle(); + + final expandedSemantics = tester.widget( + find.descendant( + of: find.byType(BusyMaxComboRow), + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == 'Calendar' && + widget.properties.expanded == true, + ), + ), + ); + expect(expandedSemantics.properties.onTap, isNotNull); + expect(expandedSemantics.properties.value, 'Calendar 1'); + final firstChoice = _menuItemWithLabel('Calendar 1'); + final secondChoice = _menuItemWithLabel('Calendar 2'); + expect(firstChoice, findsOneWidget); + expect(secondChoice, findsOneWidget); + expect( + tester.getRect(firstChoice).top, + greaterThanOrEqualTo(arrowRect.bottom), + ); + final visibleMenuItems = find + .byWidgetPredicate((widget) => widget is PopupMenuItem) + .hitTestable(); + expect(visibleMenuItems, findsNWidgets(2)); + expect(find.byType(YaruFocusBorder), findsNothing); + final radioItems = tester.widgetList>( + find.byType(YaruRadio), + ); + expect(radioItems.map((item) => item.value), [0, 1]); + expect(radioItems.map((item) => item.groupValue), everyElement(0)); + + await tester.tap(secondChoice); + await tester.pumpAndSettle(); + expect(selections, [2]); + }); + + testWidgets('combo row anchors its native menu to the trailing affordance', ( + tester, + ) async { + MethodCall? showCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (call) async { + if (call.method == 'show') { + showCall = call; + return null; + } + return true; + }); + + await tester.pumpWidget( + _testApp( + SizedBox( + width: 560, + child: BusyMaxComboRow( + title: 'Repeat', + values: const ['Never', 'Daily'], + selected: 'Never', + labelFor: (value) => value, + onSelected: (_) {}, + ), + ), + ), + ); + + final combo = find.byType(BusyMaxComboRow); + final triggerFinder = find.descendant( + of: combo, + matching: _comboTriggerFinder(), + ); + final triggerRect = tester.getRect(triggerFinder); + final arrowRect = tester.getRect( + find.descendant( + of: triggerFinder, + matching: find.byIcon(YaruIcons.pan_down), + ), + ); + + await tester.tap(triggerFinder); + await tester.pumpAndSettle(); + + final arguments = showCall!.arguments! as Map; + final rawAnchor = arguments['anchor']! as Map; + final anchor = Rect.fromLTWH( + (rawAnchor['x']! as num).toDouble(), + (rawAnchor['y']! as num).toDouble(), + (rawAnchor['width']! as num).toDouble(), + (rawAnchor['height']! as num).toDouble(), + ); + expect(anchor.left, closeTo(arrowRect.left, 0.01)); + expect(anchor.right, closeTo(arrowRect.right, 0.01)); + expect(anchor.top, closeTo(arrowRect.top, 0.01)); + expect(anchor.bottom, closeTo(arrowRect.bottom, 0.01)); + expect(anchor.left, greaterThan(triggerRect.left)); + expect(anchor.width, lessThan(triggerRect.width)); + }); + + testWidgets('disposing a combo dismisses only its native menu session', ( + tester, + ) async { + final calls = []; + final nativeSelection = Completer(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (call) async { + calls.add(call); + if (call.method == 'show') { + return await nativeSelection.future; + } + if (call.method == 'dismiss') { + return true; + } + throw MissingPluginException(); + }); + + await tester.pumpWidget( + _testApp( + BusyMaxComboRow( + title: 'Calendar', + values: const ['Personal', 'Work'], + selected: 'Personal', + labelFor: (value) => value, + onSelected: (_) {}, + ), + ), + ); + await tester.tap(_comboTriggerFinder()); + await tester.pump(); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + + final showArguments = + calls.singleWhere((call) => call.method == 'show').arguments! + as Map; + final dismissArguments = + calls.singleWhere((call) => call.method == 'dismiss').arguments! + as Map; + expect(dismissArguments['sessionId'], showArguments['sessionId']); + + nativeSelection.complete(); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets('combo row maps a nullable domain choice through the popup', ( + tester, + ) async { + final selections = []; + await tester.pumpWidget( + _testApp( + BusyMaxComboRow( + title: 'Category', + values: const [null, 'Problem'], + selected: 'Problem', + labelFor: (value) => value ?? 'Select a category', + onSelected: selections.add, + ), + ), + ); + + await tester.tap(_comboTriggerFinder()); + await tester.pumpAndSettle(); + await tester.tap(_menuItemWithLabel('Select a category')); + await tester.pumpAndSettle(); + + expect(selections, [isNull]); + }); + + testWidgets('combo trigger keeps its layout width with long menu content', ( + tester, + ) async { + const selectorWidth = 220.0; + await tester.pumpWidget( + _testApp( + SizedBox( + width: 640, + child: BusyMaxComboRow( + title: 'Calendar', + width: selectorWidth, + values: const [ + 'Personal', + 'A provider-controlled calendar name that is intentionally long', + ], + selected: 'Personal', + labelFor: (value) => value, + onSelected: (_) {}, + ), + ), + ), + ); + + final trigger = _comboTriggerFinder(); + expect(tester.getSize(trigger).width, greaterThan(selectorWidth)); + await tester.tap(trigger); + await tester.pumpAndSettle(); + + expect( + find + .byWidgetPredicate((widget) => widget is PopupMenuItem) + .hitTestable(), + findsNWidgets(2), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('combo row supports keyboard activation and menu navigation', ( + tester, + ) async { + final selections = []; + await tester.pumpWidget( + _testApp( + BusyMaxComboRow( + title: 'Calendar', + values: const ['Personal', 'Work'], + selected: 'Personal', + labelFor: (value) => value, + onSelected: selections.add, + ), + ), + ); + + final triggerFinder = find.descendant( + of: find.byType(BusyMaxComboRow), + matching: _comboTriggerFinder(), + ); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect( + find + .byWidgetPredicate((widget) => widget is PopupMenuItem) + .hitTestable(), + findsNWidgets(2), + ); + expect( + tester.getRect(find.text('Personal').last).top, + greaterThanOrEqualTo(tester.getRect(triggerFinder).bottom), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(selections, ['Work']); + expect( + find + .byWidgetPredicate((widget) => widget is PopupMenuItem) + .hitTestable(), + findsNothing, + ); + }); + + testWidgets('combo row accepts unbounded horizontal constraints', ( + tester, + ) async { + await tester.pumpWidget( + _testApp( + UnconstrainedBox( + child: BusyMaxComboRow( + title: 'Calendar', + values: const ['Personal', 'Work'], + selected: 'Personal', + labelFor: (value) => value, + onSelected: (_) {}, + ), + ), + ), + ); + + expect(_comboTriggerFinder(), findsOneWidget); + expect( + tester.getSize(_comboTriggerFinder()).width, + BusyMaxSizes.comboWidth + BusyMaxSpacing.md * 2, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('combo row exposes validation errors accessibly', (tester) async { + await tester.pumpWidget( + _testApp( + BusyMaxComboRow( + title: 'Category', + errorText: 'Choose a category', + values: const ['None', 'Problem'], + selected: 'None', + labelFor: (value) => value, + onSelected: (_) {}, + ), + ), + ); + + final errorText = tester.widget(find.text('Choose a category')); + final errorContext = tester.element(find.text('Choose a category')); + expect(errorText.style?.color, Theme.of(errorContext).colorScheme.error); + expect( + find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.liveRegion == true, + ), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.validationResult == + ui.SemanticsValidationResult.invalid, + ), + findsOneWidget, + ); + }); + + testWidgets('switch row exposes one merged toggle interaction', ( + tester, + ) async { + final values = []; + await tester.pumpWidget( + _testApp( + BusyMaxSwitchRow( + title: 'Notifications', + value: true, + onChanged: values.add, + ), + ), + ); + final semanticsHandle = tester.ensureSemantics(); + + expect( + find.descendant( + of: find.byType(BusyMaxSwitchRow), + matching: find.byType(MergeSemantics), + ), + findsOneWidget, + ); + expect(find.bySemanticsLabel('Notifications'), findsOneWidget); + + await tester.tap(find.text('Notifications')); + await tester.pump(); + expect(values, [isFalse]); + + await tester.tap(find.byType(YaruSwitch)); + await tester.pump(); + expect(values, [isFalse, isFalse]); + semanticsHandle.dispose(); + }); + + for (final brightness in Brightness.values) { + testWidgets( + 'editor sheets, alerts, and popovers use native $brightness roles', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + final expectedDialog = switch (brightness) { + Brightness.light => const Color(0xFFFAFAFA), + Brightness.dark => const Color(0xFF3E3E3E), + }; + final expectedPopover = switch (brightness) { + Brightness.light => const Color(0xFFFAFAFA), + Brightness.dark => const Color(0xFF3E3E3E), + }; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Stack( + children: [ + BusyMaxModalEditorSurface( + child: const SizedBox(width: 240, height: 120), + ), + Align( + alignment: Alignment.bottomCenter, + child: BusyMaxPopoverSurface( + color: colors.popover, + child: const SizedBox(width: 180, height: 80), + ), + ), + ], + ), + ), + ), + ); + + final modalMaterial = tester.widget( + find.descendant( + of: find.byType(BusyMaxModalEditorSurface), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == colors.window, + ), + ), + ); + final modalDialog = tester.widget( + find.descendant( + of: find.byType(BusyMaxModalEditorSurface), + matching: find.byType(Dialog), + ), + ); + expect(colors.dialog, expectedDialog); + expect(colors.popover, expectedPopover); + expect(modalDialog.backgroundColor, colors.window); + expect(modalDialog.surfaceTintColor, colors.window); + expect(modalDialog.elevation, isNull); + expect(modalDialog.shadowColor, isNull); + expect(modalDialog.shape, isNull); + expect(modalMaterial.shape, theme.dialogTheme.shape); + expect(modalMaterial.shadowColor, theme.dialogTheme.shadowColor); + expect(modalMaterial.elevation, greaterThan(0)); + + final popoverDecorationFinder = find.descendant( + of: find.byType(BusyMaxPopoverSurface), + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is ShapeDecoration && + (widget.decoration as ShapeDecoration).color == + expectedPopover && + ((widget.decoration as ShapeDecoration).shadows?.isNotEmpty ?? + false), + ), + ); + expect(popoverDecorationFinder, findsOneWidget); + expect( + find.descendant( + of: find.byType(BusyMaxPopoverSurface), + matching: find.byType(PhysicalShape), + ), + findsNothing, + ); + final outlineDecorationFinder = find.descendant( + of: find.byType(BusyMaxPopoverSurface), + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.position == DecorationPosition.foreground && + widget.decoration is ShapeDecoration, + ), + ); + expect(outlineDecorationFinder, findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: AlertDialog( + title: Text('Discard changes?'), + content: Text('Unsaved changes will be lost.'), + ), + ), + ), + ); + + final alertDialog = tester.widget( + find.byType(AlertDialog), + ); + final alertMaterial = tester.widget( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == expectedDialog, + ), + ), + ); + expect(alertDialog.backgroundColor, isNull); + expect(alertDialog.surfaceTintColor, isNull); + expect(theme.dialogTheme.backgroundColor, expectedDialog); + expect(alertMaterial.color, expectedDialog); + expect(alertMaterial.shape, theme.dialogTheme.shape); + expect(alertMaterial.shadowColor, theme.dialogTheme.shadowColor); + expect(alertMaterial.elevation, modalMaterial.elevation); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('popover perimeter remains outlined in high contrast', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + highContrast: true, + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: MediaQuery( + data: const MediaQueryData(highContrast: true), + child: BusyMaxPopoverSurface( + color: colors.popover, + child: const SizedBox(width: 180, height: 80), + ), + ), + ), + ); + + final outlineFinder = find.descendant( + of: find.byType(BusyMaxPopoverSurface), + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.position == DecorationPosition.foreground && + widget.decoration is ShapeDecoration, + ), + ); + expect(outlineFinder, findsOneWidget); + final outlineDecoration = + tester.widget(outlineFinder).decoration + as ShapeDecoration; + final outlineShape = outlineDecoration.shape as OutlinedBorder; + expect(outlineShape.side.color, colors.floatingBorder); + expect(outlineShape.side.width, BusyMaxStroke.outline); + }); + + testWidgets('combo row keeps its value inline for large text', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), + home: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(1.4)), + child: Scaffold( + body: SizedBox( + width: 760, + child: BusyMaxComboRow( + title: 'Calendar account with a long label', + values: const ['Personal calendar', 'Work calendar'], + selected: 'Personal calendar', + labelFor: (value) => value, + onSelected: (_) {}, + ), + ), + ), + ), + ), + ); + + final titleRect = tester.getRect( + find.text('Calendar account with a long label'), + ); + final valueRect = tester.getRect(find.text('Personal calendar')); + expect(valueRect.top, lessThan(titleRect.bottom)); + expect(tester.getSize(_comboTriggerFinder()).width, 760); + expect(tester.takeException(), isNull); + }); + + testWidgets('time mode delegates full-width neutral selection to Yaru tabs', ( + tester, + ) async { + final changes = []; + var allDay = true; + await tester.pumpWidget( + _timeModeTestApp( + StatefulBuilder( + builder: (context, setState) { + return BusyMaxTimeModeRow( + allDay: allDay, + onChanged: (value) { + changes.add(value); + setState(() => allDay = value); + }, + ); + }, + ), + ), + ); + + expect(find.text('Time'), findsNothing); + expect(find.text('Use dates only or set specific times.'), findsNothing); + expect(find.byType(YaruListTile), findsNothing); + expect(find.byType(ToggleButtons), findsNothing); + expect(find.byType(BusyMaxModeSwitcher), findsOneWidget); + + final control = tester.widget(find.byType(YaruTabBar)); + expect(control.height, isNull); + expect(control.labelColor, isNull); + expect(control.unselectedLabelColor, isNull); + expect(control.tabController?.index, 0); + expect( + Theme.of(tester.element(find.byType(YaruTabBar))).platform, + TargetPlatform.linux, + ); + + final rowRect = tester.getRect(find.byType(BusyMaxTimeModeRow)); + final controlRect = tester.getRect(find.byType(YaruTabBar)); + expect(controlRect.width, rowRect.width); + final optionWidths = [ + for (final label in ['All day', 'Time slot']) + tester + .getSize( + find + .ancestor( + of: find.text(label), + matching: find.byType(InkWell), + ) + .first, + ) + .width, + ]; + expect(optionWidths, hasLength(2)); + expect(optionWidths.first, optionWidths.last); + + await tester.tap(find.text('All day')); + await tester.pump(); + expect(changes, isEmpty); + + await tester.tap(find.text('Time slot')); + await tester.pump(); + expect(changes, [isFalse]); + expect(control.tabController?.index, 1); + }); + + testWidgets('time mode remains full width when its section is narrow', ( + tester, + ) async { + await tester.pumpWidget( + _timeModeTestApp( + const Center( + child: SizedBox( + width: 420, + child: BusyMaxTimeModeRow(allDay: true, onChanged: _ignoreBool), + ), + ), + ), + ); + + final rowRect = tester.getRect(find.byType(BusyMaxTimeModeRow)); + final controlRect = tester.getRect(find.byType(YaruTabBar)); + expect(controlRect.width, rowRect.width); + expect(controlRect.width, 420); + expect(tester.takeException(), isNull); + }); + + testWidgets('mode switcher follows external selection changes', ( + tester, + ) async { + Widget switcher(bool allDay) { + return BusyMaxTimeModeRow(allDay: allDay, onChanged: _ignoreBool); + } + + await tester.pumpWidget(_timeModeTestApp(switcher(true))); + expect( + tester.widget(find.byType(YaruTabBar)).tabController?.index, + 0, + ); + + await tester.pumpWidget(_timeModeTestApp(switcher(false))); + await tester.pump(); + + expect( + tester.widget(find.byType(YaruTabBar)).tabController?.index, + 1, + ); + }); + + testWidgets('mode switcher restores a rejected external selection', ( + tester, + ) async { + final changes = []; + await tester.pumpWidget( + _timeModeTestApp( + BusyMaxTimeModeRow(allDay: true, onChanged: changes.add), + ), + ); + + await tester.tap(find.text('Time slot')); + await tester.pump(); + + expect(changes, [isFalse]); + expect( + tester.widget(find.byType(YaruTabBar)).tabController?.index, + 0, + ); + }); + + testWidgets('mode switcher safely accepts a different choice count', ( + tester, + ) async { + Widget switcher(List values) { + return BusyMaxModeSwitcher( + values: values, + selected: 1, + labelFor: (value) => 'Mode $value', + onSelected: (_) {}, + ); + } + + await tester.pumpWidget(_timeModeTestApp(switcher([1, 2]))); + await tester.pumpWidget(_timeModeTestApp(switcher([1, 2, 3]))); + + final control = tester.widget(find.byType(YaruTabBar)); + expect(control.tabs, hasLength(3)); + expect(control.tabController?.length, 3); + expect(tester.takeException(), isNull); + }); + + test('mode switcher snapshots and validates domain choices', () { + final values = [1, 2]; + final switcher = BusyMaxModeSwitcher( + values: values, + selected: 1, + labelFor: (value) => '$value', + onSelected: (_) {}, + ); + values.add(3); + + expect(switcher.values, [1, 2]); + expect( + () => BusyMaxModeSwitcher( + values: const [1], + selected: 1, + labelFor: (value) => '$value', + onSelected: (_) {}, + ), + throwsArgumentError, + ); + expect( + () => BusyMaxModeSwitcher( + values: const [1, 1], + selected: 1, + labelFor: (value) => '$value', + onSelected: (_) {}, + ), + throwsArgumentError, + ); + expect( + () => BusyMaxModeSwitcher( + values: const [1, 2], + selected: 3, + labelFor: (value) => '$value', + onSelected: (_) {}, + ), + throwsArgumentError, + ); + }); + + testWidgets('mode switcher supports desktop keyboard selection', ( + tester, + ) async { + final changes = []; + var allDay = true; + await tester.pumpWidget( + _timeModeTestApp( + StatefulBuilder( + builder: (context, setState) { + return BusyMaxTimeModeRow( + allDay: allDay, + onChanged: (value) { + changes.add(value); + setState(() => allDay = value); + }, + ); + }, + ), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(changes, [isFalse]); + }); + + testWidgets('mode switcher announces one selected localized mode', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + var allDay = true; + await tester.pumpWidget( + _timeModeTestApp( + StatefulBuilder( + builder: (context, setState) { + return BusyMaxTimeModeRow( + allDay: allDay, + onChanged: (value) => setState(() => allDay = value), + ); + }, + ), + ), + ); + + var allDayNode = tester.getSemantics(find.text('All day')); + var timeSlotNode = tester.getSemantics(find.text('Time slot')); + expect(allDayNode.role, ui.SemanticsRole.tab); + expect(timeSlotNode.role, ui.SemanticsRole.tab); + expect(allDayNode.flagsCollection.isSelected, ui.Tristate.isTrue); + expect(timeSlotNode.flagsCollection.isSelected, ui.Tristate.isFalse); + + await tester.tap(find.text('Time slot')); + await tester.pump(); + + allDayNode = tester.getSemantics(find.text('All day')); + timeSlotNode = tester.getSemantics(find.text('Time slot')); + expect(allDayNode.flagsCollection.isSelected, ui.Tristate.isFalse); + expect(timeSlotNode.flagsCollection.isSelected, ui.Tristate.isTrue); + semantics.dispose(); + }); + + testWidgets('editor header actions are natural width with native loading', ( + tester, + ) async { + Widget header({required bool saving}) { + return BusyMaxEditorHeader( + title: 'Edit event', + cancelLabel: 'Cancel', + saveLabel: 'Save', + onCancel: () {}, + onSave: () {}, + saving: saving, + ); + } + + await tester.pumpWidget(_linuxTestApp(header(saving: false))); + + final cancel = find.byType(FilledButton); + final save = find.byType(ElevatedButton); + final slotWidth = + tester.getSize(find.byType(BusyMaxEditorHeader)).width / 3; + expect(tester.getSize(cancel).width, lessThan(slotWidth)); + expect(tester.getSize(save).width, lessThan(slotWidth)); + expect(tester.getSize(cancel).height, kYaruButtonHeight); + expect(tester.getSize(save).height, kYaruButtonHeight); + final headerBottom = tester + .getBottomRight(find.byType(BusyMaxEditorHeader)) + .dy; + final actionBottom = tester.getBottomRight(save).dy; + expect(headerBottom - actionBottom, BusyMaxSpacing.headerInset); + final cancelButton = tester.widget(cancel); + final saveButton = tester.widget(save); + final actionTextStyle = Theme.of(tester.element(save)).textTheme.titleSmall; + expect(saveButton.style?.textStyle?.resolve(const {}), actionTextStyle); + for (final style in [cancelButton.style, saveButton.style]) { + expect(style?.minimumSize, isNull); + expect(style?.fixedSize, isNull); + expect(style?.maximumSize, isNull); + expect(style?.padding, isNull); + expect(style?.tapTargetSize, isNull); + expect(style?.visualDensity, isNull); + } + + await tester.pumpWidget(_linuxTestApp(header(saving: true))); + await tester.pump(); + + expect(tester.getSize(save).width, lessThan(slotWidth)); + expect(tester.getSize(save).height, kYaruButtonHeight); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Save'), findsNothing); + }); + + testWidgets('custom dialogs announce their title as route semantics', ( + tester, + ) async { + Semantics routeSemantics(String label) { + return tester.widget( + find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.label == label, + ), + ); + } + + await tester.pumpWidget( + _testApp( + BusyMaxModalEditorScaffold( + title: 'Edit event', + cancelLabel: 'Cancel', + saveLabel: 'Save', + onCancel: () {}, + onSave: null, + children: const [Text('Editor content')], + ), + ), + ); + final editorSemantics = routeSemantics('Edit event'); + expect(editorSemantics.properties.scopesRoute, isTrue); + expect(editorSemantics.properties.namesRoute, isTrue); + expect(editorSemantics.explicitChildNodes, isTrue); + + await tester.pumpWidget( + _testApp( + const BusyMaxDialogShell( + title: 'Confirm action', + children: [Text('Dialog content')], + ), + ), + ); + final dialogSemantics = routeSemantics('Confirm action'); + expect(dialogSemantics.properties.scopesRoute, isTrue); + expect(dialogSemantics.properties.namesRoute, isTrue); + expect(dialogSemantics.explicitChildNodes, isTrue); + }); + + testWidgets('modal editor shows Yaru undershoot below its fixed header', ( + tester, + ) async { + await tester.pumpWidget( + _testApp( + SizedBox( + height: 260, + child: BusyMaxModalEditorScaffold( + title: 'Edit event', + cancelLabel: 'Cancel', + saveLabel: 'Save', + onCancel: () {}, + onSave: null, + children: [ + for (var index = 0; index < 8; index++) + SizedBox(height: 64, child: Text('Editor row $index')), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final undershoot = find.byType(YaruScrollViewUndershoot); + final shadow = find.descendant( + of: undershoot, + matching: find.byType(AnimatedOpacity), + ); + final scrollView = find.descendant( + of: undershoot, + matching: find.byType(SingleChildScrollView), + ); + final titleTop = tester.getTopLeft(find.text('Edit event')).dy; + + expect(undershoot, findsOneWidget); + expect(shadow, findsOneWidget); + expect(tester.widget(shadow).opacity, 0); + + await tester.drag(scrollView, const Offset(0, -120)); + await tester.pumpAndSettle(); + + expect(tester.widget(shadow).opacity, 1); + expect(tester.getTopLeft(find.text('Edit event')).dy, titleTop); + }); + + testWidgets('dialog actions wrap at narrow localized text widths', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFFE95420), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(1.4)), + child: SizedBox( + width: 320, + height: 520, + child: Builder( + builder: (context) => BusyMaxDialogShell( + title: 'Unsaved changes', + actions: [ + BusyMaxPushButton.standard( + onPressed: () {}, + child: const Text('Keep editing'), + ), + BusyMaxPushButton.destructive( + context: context, + onPressed: () {}, + child: const Text('Discard changes'), + ), + BusyMaxPushButton.suggested( + onPressed: () {}, + child: const Text('Save changes'), + ), + ], + children: const [Text('Choose how to continue.')], + ), + ), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(OverflowBar), findsOneWidget); + final actionRows = { + for (final label in ['Keep editing', 'Discard changes', 'Save changes']) + tester.getCenter(find.text(label)).dy, + }; + expect(actionRows.length, greaterThan(1)); + }); +} + +ShapeDecoration _nativeCardDecoration(WidgetTester tester, Finder surface) { + final decoratedBox = tester.widget( + find.descendant( + of: surface, + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is ShapeDecoration && + ((widget.decoration as ShapeDecoration).shadows?.isNotEmpty ?? + false), + ), + ), + ); + return decoratedBox.decoration as ShapeDecoration; +} + +void _ignoreBool(bool value) {} + +Finder _comboTriggerFinder() { + return find.byWidgetPredicate( + (widget) => widget is YaruListTile && widget.focusNode != null, + ); +} + +Finder _menuItemWithLabel(String label) { + return find + .ancestor( + of: find.text(label), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), + ) + .hitTestable(); +} + +Widget _testApp(Widget child) { + return MaterialApp( + theme: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), + home: Scaffold( + body: Center(child: SizedBox(width: 480, child: child)), + ), + ); +} + +Widget _timeModeTestApp(Widget child) { + return localizedTestApp( + child: Theme( + data: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ).copyWith(platform: TargetPlatform.linux), + child: Scaffold(body: child), + ), + ); +} + +Widget _linuxTestApp(Widget child) { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + try { + return _testApp(child); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } +} diff --git a/test/app/busymax_menu_button_test.dart b/test/app/busymax_menu_button_test.dart index 978b0b3..f0a53f4 100644 --- a/test/app/busymax_menu_button_test.dart +++ b/test/app/busymax_menu_button_test.dart @@ -1,15 +1,243 @@ +import 'dart:async'; +import 'dart:ui' as ui; + import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; import '../test_localized_app.dart'; void main() { - testWidgets('BusyMaxMenuButton opens, closes, and reopens with entries', ( + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('busymax_test/menu_button'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + testWidgets('menu button uses the centralized themed fallback', ( tester, ) async { String? selected; + final controller = BusyMaxMenuController(); + final boundaryKey = GlobalKey(); + final baseTheme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: YaruColors.orange, + ); + const inheritedHover = Color(0x1A2A7FFF); + final theme = baseTheme.copyWith(hoverColor: inheritedHover); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); + + await tester.pumpWidget( + RepaintBoundary( + key: boundaryKey, + child: localizedTestApp( + child: Theme( + data: theme, + child: Scaffold( + body: Center( + child: BusyMaxMenuButton( + tooltip: 'Options', + controller: controller, + nativeMenuService: const NativeMenuService(channel: channel), + onSelected: (value) => selected = value, + entries: const [ + BusyMaxMenuEntry( + value: 'refresh', + label: 'Refresh calendar', + icon: YaruIcons.refresh, + shortcut: 'Ctrl+R', + ), + BusyMaxMenuEntry( + value: 'open', + label: 'Open in provider', + icon: Icons.open_in_browser_outlined, + ), + ], + ), + ), + ), + ), + ), + ), + ); + + final triggerFinder = find.ancestor( + of: find.byTooltip('Options'), + matching: find.byType(YaruIconButton), + ); + var trigger = tester.widget(triggerFinder); + final colors = theme.extension()!; + expect(Theme.of(tester.element(triggerFinder)).hoverColor, inheritedHover); + expect(trigger.isSelected, isFalse); + expect(trigger.style, isNull); + final yaruStyle = trigger.defaultStyleOf(tester.element(triggerFinder)); + expect(yaruStyle.backgroundColor?.resolve({}), isNull); + expect(yaruStyle.overlayColor?.resolve({WidgetState.hovered}), isNotNull); + expect(yaruStyle.overlayColor?.resolve({WidgetState.pressed}), isNotNull); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + + expect(find.text('Refresh calendar'), findsOneWidget); + expect(find.text('Ctrl+R'), findsOneWidget); + expect(find.text('Open in provider'), findsOneWidget); + trigger = tester.widget(triggerFinder); + expect(Theme.of(tester.element(triggerFinder)).hoverColor, inheritedHover); + expect(trigger.isSelected, isTrue); + expect( + trigger + .defaultStyleOf(tester.element(triggerFinder)) + .backgroundColor + ?.resolve({WidgetState.selected}), + isNotNull, + ); + expect(find.byType(MenuAnchor), findsNothing); + expect(find.byType(MenuItemButton), findsNothing); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), + ); + expect( + tester + .widgetList(find.byType(Material)) + .where((material) => material.color == colors.popover), + isNotEmpty, + ); + final firstFallbackItem = find + .ancestor( + of: find.text('Refresh calendar'), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), + ) + .first; + final firstFallbackInkWell = find.descendant( + of: firstFallbackItem, + matching: find.byType(InkWell), + ); + expect(firstFallbackInkWell, findsOneWidget); + expect( + Theme.of(tester.element(firstFallbackInkWell)).hoverColor, + colors.controlHover, + ); + expect(colors.controlHover, isNot(inheritedHover)); + + final firstItemRect = tester.getRect(firstFallbackItem); + final hoverProbe = Offset( + firstItemRect.right - 12, + firstItemRect.center.dy, + ); + final idlePixel = await _capturePixel(tester, boundaryKey, hoverProbe); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(firstFallbackItem)); + await tester.pumpAndSettle(); + expect(firstFallbackInkWell, findsOneWidget); + final hoveredPixel = await _capturePixel(tester, boundaryKey, hoverProbe); + expect(hoveredPixel, isNot(idlePixel)); + expect( + hoveredPixel, + _colorCloseTo(Color.alphaBlend(colors.controlHover, colors.popover)), + ); + await mouse.moveTo(Offset.zero); + await tester.pumpAndSettle(); + final restoredPixel = await _capturePixel(tester, boundaryKey, hoverProbe); + expect(restoredPixel, idlePixel); + + controller.close(); + await tester.pumpAndSettle(); + + trigger = tester.widget(triggerFinder); + expect(trigger.isSelected, isFalse); + expect(Theme.of(tester.element(triggerFinder)).hoverColor, inheritedHover); + expect(find.text('Refresh calendar'), findsNothing); + expect(selected, isNull); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + + expect(find.text('Refresh calendar'), findsOneWidget); + expect(find.text('Open in provider'), findsOneWidget); + + await tester.tap(find.text('Open in provider')); + await tester.pumpAndSettle(); + + expect(selected, 'open'); + expect(find.text('Open in provider'), findsNothing); + }); + + testWidgets('menu button can keep a neutral trigger while open', ( + tester, + ) async { + final controller = BusyMaxMenuController(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: BusyMaxMenuButton( + tooltip: 'Options', + controller: controller, + highlightWhenOpen: false, + nativeMenuService: const NativeMenuService(channel: channel), + entries: const [ + BusyMaxMenuEntry(value: 'refresh', label: 'Refresh'), + ], + onSelected: (_) {}, + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + + final trigger = tester.widget( + find.ancestor( + of: find.byTooltip('Options'), + matching: find.byType(YaruIconButton), + ), + ); + expect(controller.isOpen, isTrue); + expect(trigger.isSelected, isFalse); + + controller.close(); + await tester.pumpAndSettle(); + }); + + testWidgets('menu button maps a native selected index to its domain value', ( + tester, + ) async { + final calls = []; + String? selected; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return switch (call.method) { + 'show' => 1, + 'dismiss' => true, + _ => throw MissingPluginException(), + }; + }); await tester.pumpWidget( localizedTestApp( @@ -17,46 +245,392 @@ void main() { body: Center( child: BusyMaxMenuButton( tooltip: 'Options', - onSelected: (value) => selected = value, + nativeMenuService: const NativeMenuService(channel: channel), entries: const [ BusyMaxMenuEntry( value: 'refresh', label: 'Refresh calendar', icon: YaruIcons.refresh, + selected: true, + shortcut: 'Ctrl+R', ), - BusyMaxMenuEntry( - value: 'open', - label: 'Open in provider', - icon: Icons.open_in_browser_outlined, - ), + BusyMaxMenuEntry(value: 'open', label: 'Open in provider'), ], + onSelected: (value) => selected = value, ), ), ), ), ); + final triggerRect = tester.getRect(find.byType(YaruIconButton)); await tester.tap(find.byTooltip('Options')); + await tester.pump(); + + expect(selected, 'open'); + expect(find.text('Refresh calendar'), findsNothing); + expect(find.text('Open in provider'), findsNothing); + expect(calls, hasLength(1)); + expect(calls.single.method, 'show'); + final arguments = calls.single.arguments! as Map; + final rawAnchor = arguments['anchor']! as Map; + final anchor = Rect.fromLTWH( + (rawAnchor['x']! as num).toDouble(), + (rawAnchor['y']! as num).toDouble(), + (rawAnchor['width']! as num).toDouble(), + (rawAnchor['height']! as num).toDouble(), + ); + expect(anchor, triggerRect); + expect(arguments['entries'], [ + { + 'label': 'Refresh calendar', + 'icon': 'view-refresh-symbolic', + 'enabled': true, + 'selected': true, + 'shortcut': 'Ctrl+R', + }, + {'label': 'Open in provider', 'enabled': true, 'selected': false}, + ]); + }); + + testWidgets('controller dismissal carries the owned native session', ( + tester, + ) async { + final calls = []; + final nativeSelection = Completer(); + final controller = BusyMaxMenuController(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (call.method == 'show') { + return await nativeSelection.future; + } + if (call.method == 'dismiss') { + return true; + } + throw MissingPluginException(); + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: BusyMaxMenuButton( + tooltip: 'Options', + controller: controller, + nativeMenuService: const NativeMenuService(channel: channel), + entries: const [ + BusyMaxMenuEntry(value: 'refresh', label: 'Refresh'), + ], + onSelected: (_) {}, + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Options')); + await tester.pump(); + controller.close(); + await tester.pump(); + + final showArguments = + calls.singleWhere((call) => call.method == 'show').arguments! + as Map; + final dismissArguments = + calls.singleWhere((call) => call.method == 'dismiss').arguments! + as Map; + expect(dismissArguments['sessionId'], showArguments['sessionId']); + + nativeSelection.complete(); await tester.pumpAndSettle(); + expect(controller.isOpen, isFalse); + }); - expect(find.text('Refresh calendar'), findsOneWidget); - expect(find.text('Open in provider'), findsOneWidget); + testWidgets('a dismissed native session cannot keep the trigger stuck open', ( + tester, + ) async { + final nativeSelections = >[]; + final calls = []; + final selections = []; + final controller = BusyMaxMenuController(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (call.method == 'show') { + final selection = Completer(); + nativeSelections.add(selection); + return selection.future; + } + if (call.method == 'dismiss') { + return true; + } + throw MissingPluginException(); + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: BusyMaxMenuButton( + tooltip: 'Options', + controller: controller, + nativeMenuService: const NativeMenuService(channel: channel), + entries: const [ + BusyMaxMenuEntry(value: 'refresh', label: 'Refresh'), + ], + onSelected: selections.add, + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Options')); + await tester.pump(); + expect(controller.isOpen, isTrue); + expect(nativeSelections, hasLength(1)); + + controller.close(); + expect(controller.isOpen, isFalse); + await tester.pump(); await tester.tap(find.byTooltip('Options')); + await tester.pump(); + expect(controller.isOpen, isTrue); + expect(nativeSelections, hasLength(2)); + + // A late result from the dismissed presentation must neither select an + // entry nor retire the replacement session. + nativeSelections.first.complete(0); + await tester.pump(); + expect(selections, isEmpty); + expect(controller.isOpen, isTrue); + + nativeSelections.last.complete(0); await tester.pumpAndSettle(); + expect(selections, ['refresh']); + expect(controller.isOpen, isFalse); + expect(calls.where((call) => call.method == 'show'), hasLength(2)); + expect(calls.where((call) => call.method == 'dismiss'), hasLength(1)); + }); - expect(find.text('Refresh calendar'), findsNothing); + testWidgets('an open menu keeps its entry and callback snapshot', ( + tester, + ) async { + final nativeSelection = Completer(); + final originalSelections = []; + final replacementSelections = []; + late StateSetter rebuild; + var replacement = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'show') { + return await nativeSelection.future; + } + if (call.method == 'dismiss') { + return true; + } + throw MissingPluginException(); + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return BusyMaxMenuButton( + tooltip: 'Options', + nativeMenuService: const NativeMenuService(channel: channel), + entries: replacement + ? const [ + BusyMaxMenuEntry(value: 'second', label: 'Second'), + BusyMaxMenuEntry(value: 'first', label: 'First'), + ] + : const [ + BusyMaxMenuEntry(value: 'first', label: 'First'), + BusyMaxMenuEntry(value: 'second', label: 'Second'), + ], + onSelected: replacement + ? replacementSelections.add + : originalSelections.add, + ); + }, + ), + ), + ), + ); await tester.tap(find.byTooltip('Options')); + await tester.pump(); + rebuild(() => replacement = true); + await tester.pump(); + nativeSelection.complete(1); await tester.pumpAndSettle(); - expect(find.text('Refresh calendar'), findsOneWidget); - expect(find.text('Open in provider'), findsOneWidget); + expect(originalSelections, ['second']); + expect(replacementSelections, isEmpty); + }); - await tester.tap(find.text('Open in provider')); + testWidgets('keyed menu state follows its owner through a row reorder', ( + tester, + ) async { + final nativeSelection = Completer(); + final selections = []; + late StateSetter rebuild; + var reversed = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'show') { + return nativeSelection.future; + } + if (call.method == 'dismiss') { + return true; + } + throw MissingPluginException(); + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + final owners = reversed + ? const ['second', 'first'] + : const ['first', 'second']; + return Column( + children: [ + for (final owner in owners) + BusyMaxMenuButton( + key: ValueKey(owner), + tooltip: 'Options $owner', + nativeMenuService: const NativeMenuService( + channel: channel, + ), + entries: [BusyMaxMenuEntry(value: owner, label: owner)], + onSelected: selections.add, + ), + ], + ); + }, + ), + ), + ), + ); + + YaruIconButton triggerFor(String owner) { + return tester.widget( + find.ancestor( + of: find.byTooltip('Options $owner'), + matching: find.byType(YaruIconButton), + ), + ); + } + + await tester.tap(find.byTooltip('Options first')); + await tester.pump(); + expect(triggerFor('first').isSelected, isTrue); + expect(triggerFor('second').isSelected, isFalse); + + rebuild(() => reversed = true); + await tester.pump(); + expect(triggerFor('first').isSelected, isTrue); + expect(triggerFor('second').isSelected, isFalse); + + nativeSelection.complete(0); await tester.pumpAndSettle(); + expect(selections, ['first']); + }); - expect(selected, 'open'); - expect(find.text('Open in provider'), findsNothing); + testWidgets('fallback dismissal removes its menu, not a newer route', ( + tester, + ) async { + late BuildContext hostContext; + final controller = BusyMaxMenuController(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return BusyMaxMenuButton( + tooltip: 'Options', + controller: controller, + nativeMenuService: const NativeMenuService(channel: channel), + entries: const [ + BusyMaxMenuEntry(value: 'refresh', label: 'Refresh'), + ], + onSelected: (_) {}, + ); + }, + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + unawaited( + showDialog( + context: hostContext, + builder: (_) => const AlertDialog(title: Text('Unrelated dialog')), + ), + ); + await tester.pumpAndSettle(); + + controller.close(); + await tester.pumpAndSettle(); + + expect(find.text('Unrelated dialog'), findsOneWidget); + expect(find.text('Refresh'), findsNothing); + + Navigator.of(hostContext).pop(); + await tester.pumpAndSettle(); }); } + +Future _capturePixel( + WidgetTester tester, + GlobalKey boundaryKey, + Offset globalPosition, +) async { + final boundary = + boundaryKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final localPosition = boundary.globalToLocal(globalPosition); + final image = (await tester.binding.runAsync( + () => boundary.toImage(pixelRatio: 1), + ))!; + try { + final data = (await tester.binding.runAsync( + () => image.toByteData(format: ui.ImageByteFormat.rawStraightRgba), + ))!; + final bytes = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + final x = localPosition.dx.floor().clamp(0, image.width - 1).toInt(); + final y = localPosition.dy.floor().clamp(0, image.height - 1).toInt(); + final offset = (y * image.width + x) * 4; + return Color.fromARGB( + bytes[offset + 3], + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + ); + } finally { + image.dispose(); + } +} + +Matcher _colorCloseTo(Color expected) => predicate( + (actual) => + (actual.r - expected.r).abs() <= 1 / 255 && + (actual.g - expected.g).abs() <= 1 / 255 && + (actual.b - expected.b).abs() <= 1 / 255 && + (actual.a - expected.a).abs() <= 1 / 255, + 'a color within one 8-bit channel step of $expected', +); diff --git a/test/app/busymax_search_field_test.dart b/test/app/busymax_search_field_test.dart new file mode 100644 index 0000000..c57f300 --- /dev/null +++ b/test/app/busymax_search_field_test.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +void main() { + testWidgets('BusyMax search delegates visuals and interaction to Yaru', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + final changes = []; + var clearCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 420, + child: BusyMaxSearchField( + controller: controller, + hintText: 'Search', + onChanged: changes.add, + onClear: () => clearCount += 1, + ), + ), + ), + ), + ), + ); + + expect(find.byType(YaruSearchField), findsOneWidget); + final context = tester.element(find.byType(BusyMaxSearchField)); + final field = tester.widget(find.byType(YaruSearchField)); + expect(field.style, YaruSearchFieldStyle.filled); + expect(field.height, kYaruTitleBarItemHeight); + expect(field.radius, const Radius.circular(kYaruTitleBarItemHeight)); + expect(tester.getSize(find.byType(BusyMaxSearchField)).width, 420); + expect( + tester.getSize(find.byType(BusyMaxSearchField)).height, + kYaruTitleBarItemHeight, + ); + expect( + tester.getSize(find.byType(YaruSearchField)).height, + kYaruTitleBarItemHeight, + ); + expect( + tester.getSize(find.byType(TextField)).height, + kYaruTitleBarItemHeight, + ); + expect( + field.clearIconSemanticLabel, + MaterialLocalizations.of(context).clearButtonTooltip, + ); + + await tester.enterText(find.byType(TextField), 'planning'); + await tester.pump(); + expect(changes, contains('planning')); + expect( + tester.getSize(find.byType(BusyMaxSearchField)).height, + kYaruTitleBarItemHeight, + ); + expect( + tester.getSize(find.byType(YaruSearchField)).height, + kYaruTitleBarItemHeight, + ); + expect( + tester.getSize(find.byType(TextField)).height, + kYaruTitleBarItemHeight, + ); + + await tester.tap(find.byIcon(YaruIcons.edit_clear)); + await tester.pump(); + expect(controller.text, isEmpty); + expect(clearCount, 1); + }); + + test('search consumers use only the shared Yaru adapter', () { + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final schedule = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + + expect(design, contains('class BusyMaxSearchField')); + expect(RegExp(r'YaruSearchField\(').allMatches(design), hasLength(1)); + expect(schedule, contains('BusyMaxSearchField(')); + expect(schedule, isNot(contains('class _ScheduleSearchField'))); + expect(schedule, isNot(contains('YaruSearchField('))); + }); +} diff --git a/test/app/high_contrast_theme_test.dart b/test/app/high_contrast_theme_test.dart new file mode 100644 index 0000000..9c9ef42 --- /dev/null +++ b/test/app/high_contrast_theme_test.dart @@ -0,0 +1,175 @@ +import 'package:busymax/src/app/app_theme.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_surface_colors.dart'; +import 'package:busymax/src/platform/gtk_font_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/theme.dart'; + +const _testAccent = Color(0xFF3584E4); + +void main() { + test('high-contrast themes use Yaru high-contrast component semantics', () { + final light = buildBusyMaxTheme( + brightness: Brightness.light, + accentColor: _testAccent, + highContrast: true, + ); + final dark = buildBusyMaxTheme( + brightness: Brightness.dark, + accentColor: _testAccent, + highContrast: true, + ); + + expect(light.colorScheme.primary, Colors.black); + expect(light.colorScheme.isHighContrast, isTrue); + expect(dark.colorScheme.primary, Colors.white); + expect(dark.colorScheme.isHighContrast, isTrue); + + final lightSurfaces = light.extension()!; + final darkSurfaces = dark.extension()!; + for (final surface in [ + lightSurfaces.window, + lightSurfaces.view, + lightSurfaces.sidebar, + lightSurfaces.dialog, + lightSurfaces.popover, + ]) { + expect(surface, Colors.white); + } + for (final surface in [ + darkSurfaces.window, + darkSurfaces.view, + darkSurfaces.sidebar, + darkSurfaces.dialog, + darkSurfaces.popover, + ]) { + expect(surface, Colors.black); + } + + for (final theme in [light, dark]) { + final surfaces = theme.extension()!; + expect(surfaces.mutedForeground, surfaces.foreground); + expect(surfaces.disabledForeground, isNot(surfaces.foreground)); + expect(surfaces.border, surfaces.foreground); + expect(surfaces.divider, surfaces.foreground); + expect(surfaces.cardShade, surfaces.foreground); + expect(surfaces.dialogOutline, surfaces.foreground); + expect(surfaces.floatingBorder, surfaces.foreground); + expect(surfaces.sidebarBorder, surfaces.foreground); + expect(theme.colorScheme.outline, surfaces.foreground); + expect(theme.colorScheme.outlineVariant, surfaces.foreground); + expect( + _contrastRatio(theme.colorScheme.error, surfaces.view), + greaterThanOrEqualTo(4.5), + ); + + final outlinedSide = theme.outlinedButtonTheme.style!.side!.resolve({}); + expect(outlinedSide, isNot(BorderSide.none)); + expect(outlinedSide!.color, surfaces.border); + + final elevatedShape = + theme.elevatedButtonTheme.style!.shape!.resolve({}) + as RoundedRectangleBorder; + expect(elevatedShape.side, isNot(BorderSide.none)); + expect(elevatedShape.side.color, surfaces.border); + + final popupShape = theme.popupMenuTheme.shape! as OutlineInputBorder; + expect(popupShape.borderSide.color, surfaces.border); + final dialogShape = theme.dialogTheme.shape! as RoundedRectangleBorder; + expect(dialogShape.side.color, surfaces.border); + expect(theme.menuTheme.style?.side?.resolve({})?.color, surfaces.border); + expect( + theme.dropdownMenuTheme.menuStyle?.side?.resolve({})?.color, + surfaces.border, + ); + + final tooltipDecoration = theme.tooltipTheme.decoration! as BoxDecoration; + final tooltipBorder = tooltipDecoration.border! as Border; + expect(tooltipDecoration.color, BusyMaxTooltipStyle.background); + expect(tooltipDecoration.borderRadius, BusyMaxTooltipStyle.borderRadius); + expect(tooltipBorder.top.color, BusyMaxTooltipStyle.border); + expect( + theme.tooltipTheme.textStyle?.color, + BusyMaxTooltipStyle.foreground, + ); + expect(theme.tooltipTheme.padding, BusyMaxTooltipStyle.padding); + expect(theme.tooltipTheme.constraints, BusyMaxTooltipStyle.constraints); + } + }); + + test('high contrast does not retain mixed-luminance GTK surfaces', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Colors.white, + view: Colors.white, + sidebar: Colors.black, + dialog: Colors.black, + popover: Colors.black, + ); + final theme = buildBusyMaxTheme( + brightness: Brightness.light, + accentColor: _testAccent, + gtkThemeColors: gtkColors, + highContrast: true, + ); + final surfaces = theme.extension()!; + + expect(surfaces.sidebar, Colors.white); + expect(surfaces.dialog, Colors.white); + expect(surfaces.popover, Colors.white); + expect(surfaces.foreground, Colors.black); + }); + + testWidgets('calendar grids retain the high-contrast outline', ( + tester, + ) async { + for (final brightness in Brightness.values) { + final theme = buildBusyMaxTheme( + brightness: brightness, + accentColor: _testAccent, + highContrast: true, + ); + Color? gridColor; + + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: theme, + child: Builder( + builder: (context) { + gridColor = busyMaxCalendarGridColor(context); + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(gridColor, theme.colorScheme.outlineVariant); + } + }); + + test('standard themes retain the requested system accent', () { + const accent = Color(0xFF3584E4); + final theme = buildBusyMaxTheme( + brightness: Brightness.light, + accentColor: accent, + ); + + expect(theme.colorScheme.primary, accent); + expect(theme.colorScheme.isHighContrast, isFalse); + }); +} + +double _contrastRatio(Color first, Color second) { + final firstLuminance = first.computeLuminance(); + final secondLuminance = second.computeLuminance(); + final lighter = firstLuminance > secondLuminance + ? firstLuminance + : secondLuminance; + final darker = firstLuminance > secondLuminance + ? secondLuminance + : firstLuminance; + return (lighter + 0.05) / (darker + 0.05); +} diff --git a/test/app/keyboard_shortcuts_dialog_test.dart b/test/app/keyboard_shortcuts_dialog_test.dart index f18feb4..ab1c95d 100644 --- a/test/app/keyboard_shortcuts_dialog_test.dart +++ b/test/app/keyboard_shortcuts_dialog_test.dart @@ -1,8 +1,12 @@ import 'dart:io'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_dialog_identity.dart'; import 'package:busymax/src/app/busymax_keyboard_shortcuts_dialog.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; import '../test_localized_app.dart'; @@ -20,35 +24,204 @@ void main() { expect(find.text('View'), findsOneWidget); expect(find.text('Create and Edit'), findsOneWidget); expect(find.text('Task editing'), findsOneWidget); - expect(find.text('Compact agenda'), findsOneWidget); - expect(find.text('Ctrl+/'), findsOneWidget); + expect(find.text('Compact agenda'), findsNothing); + expect(find.text('Ctrl+Alt+K'), findsOneWidget); + expect(find.text('Ctrl+Alt+S'), findsOneWidget); + expect(find.text('Ctrl+F'), findsOneWidget); + expect(find.text('F9'), findsOneWidget); + expect(find.text('Ctrl+N'), findsNothing); expect(find.text('Shift+Right'), findsOneWidget); expect(find.text('Shift+Left'), findsOneWidget); expect(find.text('T'), findsOneWidget); expect(find.text('Shift+T'), findsOneWidget); expect(find.text('E'), findsOneWidget); - expect(find.text('1 / D'), findsOneWidget); - expect(find.text('2 / W'), findsOneWidget); - expect(find.text('3 / M'), findsOneWidget); - expect(find.text('4 / Y'), findsOneWidget); - expect(find.text('0 / A'), findsOneWidget); + expect(find.text('1'), findsOneWidget); + expect(find.text('2'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + expect(find.text('4'), findsOneWidget); + expect(find.text('5'), findsOneWidget); + expect(find.text('0'), findsNothing); expect(find.text('Ctrl+S'), findsOneWidget); expect(find.text('Backspace / Delete'), findsOneWidget); - expect(find.text('Ctrl+R'), findsOneWidget); - expect(find.text('Esc'), findsNWidgets(2)); + expect(find.text('Ctrl+R'), findsNothing); + expect(find.text('Esc'), findsOneWidget); expect(find.byIcon(Icons.close), findsWidgets); + expect(find.byType(YaruDialogTitleBar), findsOneWidget); + expect(find.byType(YaruWindowControl), findsOneWidget); expect(find.text('Close'), findsNothing); + + final identity = find.byType(BusyMaxDialogIdentity); + final title = tester.widget( + find.descendant(of: identity, matching: find.text('Keyboard Shortcuts')), + ); + final hero = tester.widget( + find.descendant( + of: identity, + matching: find.byIcon(YaruIcons.keyboard_shortcuts), + ), + ); + expect(identity, findsOneWidget); + expect(hero.size, BusyMaxDialogIdentity.visualExtent); + expect(title.style?.fontWeight, BusyMaxDialogIdentity.titleWeight); + + final titleBar = tester.widget( + find.byType(YaruDialogTitleBar), + ); + final closeButton = tester.widget( + find.byType(YaruWindowControl), + ); + expect(titleBar.isActive, isTrue); + expect(titleBar.border, BorderSide.none); + expect(closeButton.type, YaruWindowControlType.close); + expect( + tester.getSize(find.byType(YaruWindowControl)), + const Size.square(kYaruWindowControlSize), + ); + + final badgeEnds = [ + 'Ctrl+Alt+K', + 'Ctrl+Alt+S', + 'Ctrl+F', + 'F9', + ].map((label) => tester.getTopRight(find.text(label)).dx).toList(); + expect(badgeEnds.every((end) => end == badgeEnds.first), isTrue); }); + for (final textScale in [1.0, 2.0]) { + testWidgets('keyboard shortcuts remain scrollable in a short window at ' + '${textScale}x text', (tester) async { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = const Size(480, 320); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + localizedTestApp( + textScaler: TextScaler.linear(textScale), + child: const BusyMaxKeyboardShortcutsDialog(), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect( + tester.widget(find.byType(Dialog)).clipBehavior, + Clip.antiAlias, + ); + final scrollView = find.byType(SingleChildScrollView); + final closeButton = find.byType(YaruWindowControl); + expect(scrollView, findsOneWidget); + expect(closeButton.hitTestable(), findsOneWidget); + final closePosition = tester.getTopLeft(closeButton); + + await tester.scrollUntilVisible( + find.text('Agenda view'), + 400, + scrollable: find.byType(Scrollable), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Agenda view').hitTestable(), findsOneWidget); + expect(closeButton.hitTestable(), findsOneWidget); + expect(tester.getTopLeft(closeButton), closePosition); + }); + } + + for (final brightness in Brightness.values) { + testWidgets( + 'keyboard shortcut groups resolve the contextual $brightness dialog card', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFFE95420), + ); + final colors = theme.extension()!; + final expectedGroupedColor = Color.alphaBlend( + colors.groupedSurface, + colors.dialog, + ); + final expectedDialogSide = BorderSide(color: colors.dialogOutline); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: const BusyMaxKeyboardShortcutsDialog(), + ), + ); + + final groupedMaterials = tester + .widgetList( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byType(Material), + ), + ) + .where( + (material) => + material.color?.toARGB32() == expectedGroupedColor.toARGB32(), + ) + .toList(); + final groupedCards = tester + .widgetList( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byType(Card), + ), + ) + .toList(); + + expect(groupedMaterials, hasLength(5)); + expect( + groupedMaterials.every( + (material) => + material.color?.toARGB32() == expectedGroupedColor.toARGB32(), + ), + isTrue, + ); + expect( + groupedMaterials.every( + (material) => material.elevation == theme.cardTheme.elevation, + ), + isTrue, + ); + expect(groupedCards, hasLength(5)); + final dialog = tester.widget(find.byType(Dialog)); + final dialogShape = + (dialog.shape ?? theme.dialogTheme.shape)! + as RoundedRectangleBorder; + expect(dialogShape.side, expectedDialogSide); + expect(dialogShape.side.color, isNot(colors.floatingBorder)); + if (brightness == Brightness.dark) { + expect( + expectedGroupedColor.toARGB32(), + isNot(colors.card.toARGB32()), + ); + expect( + expectedGroupedColor.computeLuminance(), + greaterThan(colors.dialog.computeLuminance()), + ); + } + }, + ); + } + test('keyboard shortcuts are available from native headerbar menu', () { final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); final service = File( 'lib/src/platform/linux_header_bar_service.dart', ).readAsStringSync(); + final shortcuts = File( + 'lib/src/app/busymax_shortcuts.dart', + ).readAsStringSync(); final native = File('linux/runner/my_application.cc').readAsStringSync(); expect(app, contains('keyboardShortcuts: l10n.keyboardShortcuts')); - expect(app, contains('LogicalKeyboardKey.slash')); + expect(app, contains('BusyMaxShortcutActivators.keyboardShortcuts')); + expect(shortcuts, contains('LogicalKeyboardKey.keyK')); + expect(shortcuts, contains('LogicalKeyboardKey.keyS')); + expect(shortcuts, contains('LogicalKeyboardKey.f9')); expect(service, contains('keyboardShortcuts')); expect(native, contains('"Keyboard Shortcuts"')); expect(native, contains('"keyboardShortcuts"')); diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart new file mode 100644 index 0000000..0069f9e --- /dev/null +++ b/test/app/localization_audit_test.dart @@ -0,0 +1,517 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/l10n/generated/app_localizations_ar.dart'; +import 'package:busymax/l10n/generated/app_localizations_fa.dart'; +import 'package:busymax/src/l10n/app_locale.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('localized UI surfaces do not hardcode user-facing text', () { + final failures = []; + + for (final file in _productionUiDartFiles()) { + final source = file.readAsStringSync(); + for (final pattern in _userFacingLiteralPatterns) { + for (final match in pattern.allMatches(source)) { + final literal = match.group(1)!; + if (_isTechnicalLiteral(literal)) { + continue; + } + failures.add( + '${file.path}:${_lineForOffset(source, match.start)}: $literal', + ); + } + } + } + + expect(failures, isEmpty, reason: failures.join('\n')); + }); + + test('translated ARB catalogs match the English template', () { + final templateFile = File('lib/l10n/app_en.arb'); + final templateArb = _decodeArb(templateFile); + final templateMessages = _messages(templateArb); + final failures = []; + + for (final file in _translatedArbFiles()) { + final path = file.path; + final messages = _messages(_decodeArb(file)); + final templateKeys = templateMessages.keys.toSet(); + final translatedKeys = messages.keys.toSet(); + + for (final key + in templateKeys.difference(translatedKeys).toList()..sort()) { + failures.add('$path: missing message $key'); + } + for (final key + in translatedKeys.difference(templateKeys).toList()..sort()) { + failures.add('$path: unexpected message $key'); + } + for (final key in templateKeys.intersection(translatedKeys)) { + final translation = messages[key]!; + if (translation.trim().isEmpty) { + failures.add('$path: $key has an empty translation'); + } + for (final placeholder in _declaredPlaceholders(templateArb, key)) { + if (!translation.contains('{$placeholder')) { + failures.add('$path: $key does not use {$placeholder}'); + } + } + } + } + + expect(failures, isEmpty, reason: failures.join('\n')); + }); + + test('discard-changes action is distinct from cancel in every locale', () { + for (final locale in AppLocalizations.supportedLocales) { + final localizations = lookupAppLocalizations(locale); + expect( + localizations.discardChangesAction, + isNot(localizations.cancel), + reason: + '${locale.toLanguageTag()} must not translate Discard as Cancel', + ); + } + + expect( + lookupAppLocalizations(const Locale('ru')).discardChangesAction, + 'Не сохранять', + ); + expect( + lookupAppLocalizations(const Locale('ru')).discardChanges, + 'Не сохранять изменения?', + ); + expect( + lookupAppLocalizations(const Locale('ru')).discardChangesConfirmation, + 'Несохранённые изменения этой задачи будут потеряны.', + ); + expect( + lookupAppLocalizations(const Locale('vi')).discardChangesAction, + 'Không lưu', + ); + }); + + test('Linux package metadata matches every translated catalog', () { + final failures = _metadataTranslationFailures().toList(); + + expect(failures, isEmpty, reason: failures.join('\n')); + }); + + test('Finnish is generated and exposed as a supported locale', () { + const locale = Locale('fi'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'Asetukset'); + expect(localizations.today, 'Tänään'); + }); + + test('Estonian is generated and exposed as a supported locale', () { + const locale = Locale('et'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'Seaded'); + expect(localizations.today, 'Täna'); + }); + + test('Russian is generated and exposed as a supported locale', () { + const locale = Locale('ru'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'Настройки'); + expect(localizations.today, 'Сегодня'); + expect(localizations.viewAgenda, 'Расписание'); + expect(localizations.currentLocale, 'Язык приложения'); + }); + + test('package metadata uses reviewed product wording in every locale', () { + final desktop = File( + 'linux/io.busystack.busymax.desktop', + ).readAsStringSync(); + final metainfo = File( + 'linux/io.busystack.busymax.metainfo.xml', + ).readAsStringSync(); + + const summaries = { + 'ar': 'مدير التقويم والمهام', + 'de': 'Kalender- und Aufgabenverwaltung', + 'es': 'Gestor de calendarios y tareas', + 'et': 'Kalendri- ja ülesannete haldur', + 'fa': 'مدیر تقویم و کارها', + 'fi': 'Kalenteri- ja tehtäväsovellus', + 'fr': 'Gestionnaire de calendriers et de tâches', + 'hi': 'कैलेंडर और कार्य प्रबंधक', + 'it': 'Gestore di calendari e attività', + 'ja': 'カレンダー・タスク管理アプリ', + 'ko': '캘린더와 할 일 관리', + 'pt': 'Gestor de calendário e tarefas', + 'ru': 'Календарь и планировщик задач', + 'vi': 'Trình quản lý lịch và công việc', + 'zh': '日历与任务管理工具', + 'zh_Hans': '日历与任务管理工具', + 'zh_Hant': '行事曆與待辦事項管理工具', + }; + + for (final entry in summaries.entries) { + expect( + desktop, + contains('Comment[${entry.key}]=${entry.value}'), + reason: 'desktop ${entry.key}', + ); + final appStreamLocale = entry.key.replaceAll('_', '-'); + expect( + metainfo, + contains( + '${entry.value}', + ), + reason: 'AppStream ${entry.key}', + ); + } + expect( + '$desktop\n$metainfo', + isNot(contains('Менеджер календаря и задач')), + ); + }); + + test('Portuguese is generated and exposed as a supported locale', () { + const locale = Locale('pt'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'Definições'); + expect(localizations.today, 'Hoje'); + }); + + test('Hindi is generated and exposed as a supported locale', () { + const locale = Locale('hi'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'सेटिंग्स'); + expect(localizations.today, 'आज'); + }); + + test('Italian is generated and exposed as a supported locale', () { + const locale = Locale('it'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'Impostazioni'); + expect(localizations.today, 'Oggi'); + }); + + test('Japanese is generated and exposed as a supported locale', () { + const locale = Locale('ja'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, '設定'); + expect(localizations.today, '今日'); + }); + + test('Korean is generated and exposed as a supported locale', () { + const locale = Locale('ko'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, '설정'); + expect(localizations.today, '오늘'); + }); + + test('Vietnamese is generated and exposed as a supported locale', () { + const locale = Locale('vi'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'Cài đặt'); + expect(localizations.today, 'Hôm nay'); + }); + + test('Arabic is generated and exposed as a supported locale', () { + const locale = Locale('ar'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'الإعدادات'); + expect(localizations.today, 'اليوم'); + }); + + testWidgets('Arabic locale renders right to left', (tester) async { + TextDirection? direction; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ar'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + direction = Directionality.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(direction, TextDirection.rtl); + }); + + test('Persian is generated and exposed as a supported locale', () { + const locale = Locale('fa'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'تنظیمات'); + expect(localizations.today, 'امروز'); + expect( + localizations.dueTodayNotificationBody(0), + 'امروز هیچ کاری سررسید ندارد.', + ); + }); + + testWidgets('Persian locale renders right to left', (tester) async { + TextDirection? direction; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('fa'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + direction = Directionality.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(direction, TextDirection.rtl); + }); + + test('RTL translations isolate dynamic content', () { + const fsi = '\u2068'; + const pdi = '\u2069'; + final localizations = [ + AppLocalizationsAr(), + AppLocalizationsFa(), + ]; + + for (final l10n in localizations) { + expect( + l10n.exportedFile('exports/schedule-v2.ics'), + contains('${fsi}exports/schedule-v2.ics$pdi'), + ); + expect(l10n.feedbackSuccess('BM-12345'), contains('${fsi}BM-12345$pdi')); + expect( + l10n.dateTimeDisplay('2026-07-29', '14:30'), + allOf(contains('${fsi}2026-07-29$pdi'), contains('${fsi}14:30$pdi')), + ); + } + }); + + test('Persian dynamic numbers use Persian digits', () { + const fsi = '\u2068'; + const pdi = '\u2069'; + final fa = AppLocalizationsFa(); + + expect(fa.moreItems(12), contains('$fsi۱۲$pdi')); + expect(fa.pendingOpAttempts(42), contains('$fsi۴۲$pdi')); + expect(fa.weekNumberTooltip(27), contains('$fsi۲۷$pdi')); + + // package:intl intentionally uses Latin digits for the generic ar locale. + expect(AppLocalizationsAr().moreItems(12), contains('${fsi}12$pdi')); + }); + + test('both Chinese scripts are generated and supported', () { + const simplified = Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hans', + ); + const traditional = Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hant', + ); + + expect(AppLocalizations.supportedLocales, contains(simplified)); + expect(AppLocalizations.supportedLocales, contains(traditional)); + expect(lookupAppLocalizations(simplified).settings, '设置'); + expect(lookupAppLocalizations(traditional).settings, '設定'); + }); + + test('Chinese regions resolve to the appropriate script', () { + const simplified = Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hans', + ); + const traditional = Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hant', + ); + + expect( + resolveBusyMaxLocale( + const Locale('zh', 'CN'), + AppLocalizations.supportedLocales, + ), + simplified, + ); + expect( + resolveBusyMaxLocale( + const Locale('zh', 'TW'), + AppLocalizations.supportedLocales, + ), + traditional, + ); + }); + + test('locale resolution considers every system preference', () { + expect( + resolveBusyMaxLocales(const [ + Locale('eo'), + Locale('de', 'DE'), + ], AppLocalizations.supportedLocales), + const Locale('de'), + ); + }); + + test('unsupported locale lists deliberately fall back to English', () { + expect( + resolveBusyMaxLocales(const [ + Locale('eo'), + Locale('kl'), + ], AppLocalizations.supportedLocales), + const Locale('en'), + ); + }); + + test('every selectable locale has a generated catalog', () { + final generated = AppLocalizations.supportedLocales.toSet() + ..remove(const Locale('zh')); + final selectable = busyMaxLocaleOptions + .map((option) => option.locale) + .toSet(); + + expect(selectable, generated); + }); +} + +Iterable _productionUiDartFiles() sync* { + for (final entity in Directory('lib').listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) { + continue; + } + if (entity.path.contains('/l10n/generated/')) { + continue; + } + if (!entity.path.contains('/presentation/') && + !entity.path.contains('/src/app/')) { + continue; + } + yield entity; + } +} + +Iterable _translatedArbFiles() sync* { + for (final entity in Directory('lib/l10n').listSync()) { + if (entity is File && + entity.path.endsWith('.arb') && + !entity.path.endsWith('app_en.arb')) { + yield entity; + } + } +} + +Iterable _metadataTranslationFailures() sync* { + final targetLocales = [ + for (final file in _translatedArbFiles()) + RegExp(r'app_([A-Za-z_]+)\.arb$').firstMatch(file.path)!.group(1)!, + ]..sort(); + final desktop = File('linux/io.busystack.busymax.desktop').readAsStringSync(); + final metainfo = File( + 'linux/io.busystack.busymax.metainfo.xml', + ).readAsStringSync(); + final snap = File('snap/snapcraft.yaml').readAsStringSync(); + + if (!snap.contains('Snap Store listing translations are managed outside')) { + yield 'snap/snapcraft.yaml: missing external translation note'; + } + for (final locale in targetLocales) { + final xmlLocale = locale.replaceAll('_', '-'); + if (!desktop.contains('Name[$locale]=')) { + yield 'linux/io.busystack.busymax.desktop: missing Name[$locale]'; + } + if (!desktop.contains('Comment[$locale]=')) { + yield 'linux/io.busystack.busymax.desktop: missing Comment[$locale]'; + } + if (!metainfo.contains('')) { + yield 'linux/io.busystack.busymax.metainfo.xml: missing name for ' + '$xmlLocale'; + } + if (!metainfo.contains('')) { + yield 'linux/io.busystack.busymax.metainfo.xml: missing summary for ' + '$xmlLocale'; + } + if (!metainfo.contains('

')) { + yield 'linux/io.busystack.busymax.metainfo.xml: missing description for ' + '$xmlLocale'; + } + } +} + +final _userFacingLiteralPatterns = [ + RegExp(r"\bText\(\s*'([^']*[A-Za-z][^']*)'"), + RegExp(r"\bSelectableText\(\s*'([^']*[A-Za-z][^']*)'"), + RegExp( + r"\b(?:title|subtitle|tooltip|label|message|description|semanticLabel|labelText|hintText|helperText):\s*'([^']*[A-Za-z][^']*)'", + ), +]; + +bool _isTechnicalLiteral(String literal) { + final interpolationStripped = literal.replaceAll( + RegExp(r'\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*'), + '', + ); + return literal == 'BusyMax' || + literal == 'iCalendar' || + literal == 'Ubuntu' || + literal.startsWith(r'$') || + !RegExp(r'[A-Za-z]{3,}').hasMatch(interpolationStripped); +} + +Map _decodeArb(File file) { + return (jsonDecode(file.readAsStringSync()) as Map).cast(); +} + +Map _messages(Map arb) { + return { + for (final entry in arb.entries) + if (!entry.key.startsWith('@') && entry.value is String) + entry.key: entry.value! as String, + }; +} + +Iterable _declaredPlaceholders( + Map templateArb, + String key, +) sync* { + final metadata = templateArb['@$key']; + if (metadata is! Map) { + return; + } + final placeholders = metadata['placeholders']; + if (placeholders is! Map) { + return; + } + yield* placeholders.keys.cast(); +} + +int _lineForOffset(String source, int offset) { + return '\n'.allMatches(source.substring(0, offset)).length + 1; +} diff --git a/test/app/localized_formatters_test.dart b/test/app/localized_formatters_test.dart new file mode 100644 index 0000000..5d161f5 --- /dev/null +++ b/test/app/localized_formatters_test.dart @@ -0,0 +1,53 @@ +import 'package:busymax/src/l10n/localized_formatters.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; + +void main() { + test('month headings follow standalone CLDR casing by locale', () async { + const expectedJuly = { + 'ar': 'يوليو', + 'de': 'Juli', + 'en': 'July', + 'es': 'Julio', + 'et': 'juuli', + 'fa': 'ژوئیه', + 'fi': 'heinäkuu', + 'fr': 'Juillet', + 'hi': 'जुलाई', + 'it': 'Luglio', + 'ja': '7月', + 'ko': '7월', + 'pt': 'julho', + 'ru': 'Июль', + 'vi': 'Tháng 7', + 'zh': '七月', + }; + + for (final entry in expectedJuly.entries) { + await initializeDateFormatting(entry.key); + expect( + localizedMonthHeading(entry.key, DateTime(2026, 7)), + entry.value, + reason: entry.key, + ); + } + }); + + test('schedule ranges localize each complete endpoint', () async { + await initializeDateFormatting('de'); + final range = ScheduleRange( + start: DateTime(2026, 7, 27), + end: DateTime(2026, 8, 3), + ); + + expect( + localizedScheduleRangeLabel('en', range), + 'Jul 27, 2026 – Aug 2, 2026', + ); + expect( + localizedScheduleRangeLabel('de', range), + '27. Juli 2026 – 2. Aug. 2026', + ); + }); +} diff --git a/test/app/modal_barrier_test.dart b/test/app/modal_barrier_test.dart new file mode 100644 index 0000000..b2f9c84 --- /dev/null +++ b/test/app/modal_barrier_test.dart @@ -0,0 +1,175 @@ +import 'dart:io'; + +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/platform/gtk_font_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('native dark startup fallback matches the Dart semantic shade', () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + final match = RegExp( + r'kDefaultModalBarrierColor\[\] = ' + r'"rgba\(0,0,0,([0-9.]+)\)"', + ).firstMatch(source); + + expect(match, isNotNull); + final nativeAlpha = double.parse(match!.group(1)!); + final dartAlpha = busyMaxFallbackSurfaceColors(Brightness.dark).shade.a; + expect(nativeAlpha, closeTo(dartAlpha, 0.0001)); + expect(source, contains('modal_barrier_color_for_depth(')); + expect(source, contains('std::pow(1.0 - barrier.alpha')); + expect( + source, + contains( + 'g_autofree gchar* modal_barrier_color = ' + 'modal_barrier_color_for_depth(', + ), + ); + expect(source, contains('self->header_bar_modal_barrier_color')); + expect(source, contains('self->header_bar_modal_barrier_shade_depth')); + expect(source, contains('kDefaultModalBarrierColor')); + }); + + test('native modal blocking and visual shade depth remain independent', () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + + expect(source, contains('set_header_bar_modal_barrier_state')); + expect(source, contains('set_header_bar_modal_barrier_visible')); + expect( + source, + contains( + 'set_header_bar_modal_barrier_state(self, visible, visible ? 1 : 0);', + ), + ); + expect( + source, + contains( + 'self->header_bar_modal_barrier_shade_depth = ' + 'effective_shade_depth;', + ), + ); + final setterStart = source.indexOf( + 'static void set_header_bar_modal_barrier_state(', + ); + final setterEnd = source.indexOf( + 'static void set_header_bar_modal_barrier_visible(', + setterStart, + ); + final setter = source.substring(setterStart, setterEnd); + expect( + setter, + contains('gtk_widget_set_visible(self->titlebar_modal_barrier, visible)'), + ); + expect(setter, contains('if (shade_changed) {')); + expect(setter, contains('refresh_header_bar_css(self);')); + expect( + source, + contains( + 'set_header_bar_modal_barrier_state(\n' + ' self, fl_lookup_bool_arg(args, "visible", FALSE),', + ), + ); + expect( + source, + contains('1.0 - std::pow(1.0 - barrier.alpha, effective_depth)'), + ); + }); + + for (final (brightness, expectedAlpha) in [ + (Brightness.light, 0.07), + (Brightness.dark, 0.25), + ]) { + testWidgets( + '$brightness modal barrier follows the native semantic shade role', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + late Color barrier; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (context) { + barrier = busyMaxModalBarrierColor(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + final shade = theme.extension()!.shade; + expect(barrier.a, closeTo(expectedAlpha, 0.0001)); + expect(barrier.r, shade.r); + expect(barrier.g, shade.g); + expect(barrier.b, shade.b); + }, + ); + } + + testWidgets('high-contrast shade remains a translucent modal layer', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + highContrast: true, + ); + late Color barrier; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (context) { + barrier = busyMaxModalBarrierColor(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(barrier, theme.extension()!.shade); + expect(barrier.a, 0.50); + expect(barrier.a, lessThan(1)); + }); + + testWidgets( + 'a GTK3 palette without semantic shade keeps restrained light dimming', + (tester) async { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFFAFAFA), + popover: Color(0xFFFAFAFA), + ); + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + gtkThemeColors: gtkColors, + ); + late Color barrier; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (context) { + barrier = busyMaxModalBarrierColor(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect( + theme.extension()!.shade, + busyMaxFallbackSurfaceColors(Brightness.light).shade, + ); + expect(barrier.a, closeTo(0.07, 0.0001)); + }, + ); +} diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 985d579..a1de802 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -29,7 +29,135 @@ void main() { }); test( - 'Task Details, Settings, and Agenda use BusyMax Yaru row patterns', + 'main window delegates four-corner clipping and window states to Handy', + () { + final source = File( + 'linux/runner/my_application.cc', + ).readAsStringSync(); + final linuxCmake = File('linux/CMakeLists.txt').readAsStringSync(); + final runnerCmake = File( + 'linux/runner/CMakeLists.txt', + ).readAsStringSync(); + final workflow = File( + '.github/workflows/flutter-linux.yml', + ).readAsStringSync(); + final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); + final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); + final activateStart = source.indexOf( + 'static void my_application_activate(GApplication* application)', + ); + final activateEnd = source.indexOf( + 'static void my_application_startup(GApplication* application)', + activateStart, + ); + + expect(activateStart, isNonNegative); + expect(activateEnd, greaterThan(activateStart)); + final activateBody = source.substring(activateStart, activateEnd); + + expect(source, contains('#include ')); + expect(source, contains('hdy_init()')); + expect(activateBody, contains('hdy_application_window_new(')); + expect(activateBody, isNot(contains('gtk_application_window_new('))); + expect(source, contains('hdy_window_handle_new()')); + expect(activateBody, isNot(contains('gtk_window_set_titlebar('))); + expect( + linuxCmake, + contains( + 'pkg_check_modules(HANDY REQUIRED IMPORTED_TARGET libhandy-1)', + ), + ); + expect( + runnerCmake, + contains( + 'target_link_libraries(\${BINARY_NAME} PRIVATE PkgConfig::HANDY)', + ), + ); + expect(workflow, contains('libhandy-1-dev')); + expect(snapcraft, contains('- libhandy-1-0')); + + // Handy owns the theme radius, state transitions, input region, and + // child crop. BusyMax must not reintroduce a second window-shaping + // implementation in GTK or Flutter. + expect(source, isNot(contains('gdk_window_shape_combine_region'))); + expect(source, isNot(contains('create_rounded_window_region'))); + expect(source, isNot(contains('configure_rounded_window_shape'))); + expect(source, isNot(contains('CAIRO_OPERATOR_CLEAR'))); + expect(source, isNot(contains('kNativeWindowRadius'))); + expect(activateBody, isNot(contains('"unified"'))); + expect(app, isNot(contains('_BusyMaxWindowCornerClip'))); + }, + ); + + test('Yaru GTK3 compatibility replaces legacy decoration rings', () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + final themeGateStart = source.indexOf( + 'static gboolean current_gtk_theme_uses_legacy_yaru_shadow()', + ); + final refreshStart = source.indexOf( + 'static void refresh_header_bar_css(MyApplication* self)', + ); + final refreshEnd = source.indexOf( + 'static void set_css_color_field(', + refreshStart, + ); + + expect(themeGateStart, isNonNegative); + expect(refreshStart, greaterThan(themeGateStart)); + expect(refreshEnd, greaterThan(refreshStart)); + + final themeGate = source.substring(themeGateStart, refreshStart); + final refresh = source.substring(refreshStart, refreshEnd); + final compatibilityStart = refresh.indexOf( + 'g_autofree gchar* yaru_window_decoration_css =', + ); + final compatibilityEnd = refresh.indexOf( + 'GtkWidget* header_bar', + compatibilityStart, + ); + + expect(themeGate, contains('gtk_settings_get_default()')); + expect(themeGate, contains('"gtk-theme-name"')); + expect(themeGate, contains('g_ascii_strdown(theme_name, -1)')); + expect(themeGate, contains('g_strcmp0(normalized_theme, "yaru")')); + expect( + themeGate, + contains('g_str_has_prefix(normalized_theme, "yaru-")'), + ); + expect(themeGate, contains('strstr(normalized_theme, "highcontrast")')); + expect(compatibilityStart, isNonNegative); + expect(compatibilityEnd, greaterThan(compatibilityStart)); + + final compatibility = refresh.substring( + compatibilityStart, + compatibilityEnd, + ); + + expect(refresh, contains('!self->header_bar_high_contrast')); + expect(refresh, contains('current_gtk_theme_uses_legacy_yaru_shadow()')); + expect( + compatibility, + contains('box-shadow: 0 3px 9px 1px rgba(0,0,0,0.5);'), + ); + expect(compatibility, contains('box-shadow: 0 3px 9px 1px transparent,')); + expect(compatibility, contains('0 2px 6px 2px rgba(0,0,0,0.2);')); + expect(compatibility, contains('box-shadow: 0 0 0 20px transparent;')); + expect(compatibility, contains('not(.solid-csd)')); + expect(compatibility, contains('not(.maximized)')); + expect(compatibility, contains('not(.fullscreen)')); + expect(compatibility, contains('.tiled-top')); + expect(compatibility, contains('.tiled-right')); + expect(compatibility, contains('.tiled-bottom')); + expect(compatibility, contains('.tiled-left')); + expect(compatibility, contains('0 0 0 1px rgba(0,0,0,0.05);')); + expect(compatibility, isNot(contains('rgba(0,0,0,0.65)'))); + expect(compatibility, isNot(contains('rgba(0,0,0,0.75)'))); + expect(compatibility, isNot(contains('border-radius'))); + expect(compatibility, isNot(contains('gdk_window_shape_combine_region'))); + }); + + test( + 'Task Details, Settings, and main Agenda use BusyMax Yaru row patterns', () { final taskDetails = File( 'lib/src/features/tasks/presentation/task_details_editor.dart', @@ -53,35 +181,46 @@ void main() { final scheduleAgenda = File( 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', ).readAsStringSync(); - final compactAgenda = File( - 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', - ).readAsStringSync(); expect(design, contains('class BusyMaxClamp')); + expect(design, contains('class BusyMaxEditorScrollBody')); + expect(design, contains('YaruScrollViewUndershoot.builder(')); + expect(design, contains('endUndershoot: false')); expect(design, contains('class BusyMaxGroupedList')); expect(design, contains('class BusyMaxActionRow')); expect(design, contains('class BusyMaxComboRow')); - expect(design, contains('class _BusyMaxRowTile')); + expect(design, contains('class BusyMaxGroupedSurface')); expect(design, contains('class BusyMaxSwitchRow')); expect(design, contains('class BusyMaxDialogShell')); expect(design, contains('class BusyMaxModalEditorSurface')); expect(design, contains('Color busyMaxModalBarrierColor')); - expect(design, contains('abstract final class BusyMaxElevation')); - expect(design, contains('elevation: BusyMaxElevation.surface')); - expect( - design, - contains('shadowColor: BusyMaxShadow.floatingColor(context)'), - ); + expect(design, contains('decoration: ShapeDecoration(')); + expect(design, contains('BusyMaxShadow.nativePopoverShadowsFor(')); + expect(design, contains('ShapeBorderClipper(shape: shape)')); + expect(design, isNot(contains('_BusyMaxPopoverShadowPainter'))); + expect(design, isNot(contains('return PhysicalShape('))); + expect(design, isNot(contains('lightSurfaceShadowMinimum'))); expect(design, contains('final bool filled;')); expect(design, contains('BusyMaxSurfaceColors.of(context)')); expect(design, contains('surfaceColors.card')); + expect(design, contains('CardTheme.of(context)')); expect(design, contains('surfaceColors.control')); - expect(design, contains('color: surfaceColors.control')); - expect(design, contains('highlightColor: surfaceColors.controlActive')); - expect(design, isNot(contains('YaruTileList(children: children)'))); - expect(design, isNot(contains('YaruListTile.square'))); + expect(design, contains('YaruListTile.square(')); + expect(design, isNot(contains('class _BusyMaxRowTile'))); + final calendarRowStart = design.indexOf( + 'class BusyMaxCalendarValueRow', + ); + final calendarRowEnd = design.indexOf( + 'class BusyMaxCalendarNotesCard', + calendarRowStart, + ); + expect(calendarRowStart, isNonNegative); + expect(calendarRowEnd, greaterThan(calendarRowStart)); + final calendarRow = design.substring(calendarRowStart, calendarRowEnd); + expect(calendarRow, contains('required this.entry')); + expect(calendarRow, isNot(contains('TextField('))); - expect(taskDetails, contains('BusyMaxClamp')); + expect(taskDetails, contains('BusyMaxEditorScrollBody')); expect(taskDetails, contains('BusyMaxGroupedList')); expect(taskDetails, contains('BusyMaxActionRow')); expect(taskDetails, contains('BusyMaxComboRow')); @@ -108,8 +247,12 @@ void main() { expect(settings, isNot(contains('SettingsPage.appearance'))); expect(settings, isNot(contains('SettingsPage.localization'))); expect(settings, isNot(contains('l10n.themeFamily'))); - expect(settings, contains('setBackVisible(true)')); - expect(settings, contains('setSidebarVisible(true)')); + expect(settings, contains('.claimSession()')); + expect(settings, contains('_headerBarSession.updateState(')); + expect(settings, contains('BusyMaxHeaderBarState(')); + expect(settings, contains('backVisible: true')); + expect(settings, contains('canShowSidebar: showSidebar')); + expect(settings, contains('sidebarVisible: showSidebar')); expect(newTaskDialog, contains('showBusyMaxModalEditorDialog')); expect(newTaskDialog, contains('TaskDetailsEditor')); expect(newTaskDialog, isNot(contains('BusyMaxDialogShell'))); @@ -118,24 +261,30 @@ void main() { expect(scheduleAgenda, contains('BusyMaxActionRow')); expect(scheduleAgenda, isNot(contains('scheduleAgendaRowBackground'))); expect(scheduleAgenda, isNot(contains('surfaceColor:'))); - expect( - scheduleAgenda, - isNot(contains('ScheduleProjection.colorForItem')), - ); + expect(scheduleAgenda, contains('ScheduleProjection.colorForItem')); + expect(scheduleAgenda, contains('leading: _AgendaItemMarker')); expect(scheduleAgenda, isNot(contains('class _AgendaDayHeader'))); expect(scheduleAgenda, isNot(contains('class _AgendaPlainHeader'))); - expect(compactAgenda, contains('BusyMaxGroupedList')); - expect(compactAgenda, contains('BusyMaxActionRow')); - expect(compactAgenda, isNot(contains('scheduleAgendaRowBackground'))); + expect(dateTimeFields, contains('MiniCalendarGrid(')); + expect(dateTimeFields, isNot(contains('ScheduleItem'))); expect( - compactAgenda, - isNot(contains('ScheduleProjection.colorForItem')), + dateTimeFields, + contains('onDaySelected: (date) => _setSelectedDate'), ); - - expect(dateTimeFields, contains('YaruDateTimeEntry')); - expect(dateTimeFields, contains('_BusyMaxTimeTextEntry')); - expect(dateTimeFields, contains('parseTimeInput')); + expect( + 'busyMaxGroupedTextFieldDecoration'.allMatches(dateTimeFields).length, + greaterThanOrEqualTo(2), + ); + expect(dateTimeFields, contains('parseDesktopTimeInput')); + expect(dateTimeFields, isNot(contains('_withoutFloatingEntryLabel'))); + expect(dateTimeFields, isNot(contains('_BusyMaxTimeTextEntry'))); + expect(dateTimeFields, isNot(contains('YaruDateTimeEntry'))); + expect(dateTimeFields, isNot(contains('YaruTimeEntry('))); + expect(dateTimeFields, isNot(contains('YaruTimeEntryController'))); + expect(dateTimeFields, isNot(contains("'Enter date'"))); + expect(dateTimeFields, isNot(contains("'Enter time'"))); + expect(dateTimeFields, isNot(contains('fontSize: 0'))); expect(dateTimeFields, isNot(contains('showDatePicker'))); expect(dateTimeFields, isNot(contains('showTimePicker'))); }, @@ -169,6 +318,26 @@ void main() { expect(source, isNot(contains("label: 'Quit BusyMax'"))); }); + test('main calendar window starts at the intended desktop size', () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + + expect(source, contains('kMainWindowDefaultWidth = 1280')); + expect(source, contains('kMainWindowDefaultHeight = 720')); + expect(source, contains('gtk_window_set_default_size')); + }); + + test('startup defers native presentation until the app frame is ready', () { + final source = File('lib/main.dart').readAsStringSync(); + final deferFrame = source.indexOf('binding.deferFirstFrame();'); + final firstRunApp = source.indexOf('runApp('); + final firstAllowFrame = source.indexOf('binding.allowFirstFrame();'); + + expect(deferFrame, isNonNegative); + expect(firstRunApp, greaterThan(deferFrame)); + expect(firstAllowFrame, greaterThan(firstRunApp)); + expect('binding.allowFirstFrame();'.allMatches(source), hasLength(1)); + }); + test('snap uses portal-backed secret storage without keyring plug', () { final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); final bootstrap = File( @@ -190,149 +359,34 @@ void main() { expect(portalStore, contains('Hkdf(hmac: Hmac.sha256()')); }); - test('compact agenda uses a separate desktop window', () { + test('tray Agenda action reuses the main application window', () { final pubspec = File('pubspec.yaml').readAsStringSync(); final runner = File('linux/runner/my_application.cc').readAsStringSync(); final linuxMain = File('linux/runner/main.cc').readAsStringSync(); - final tray = File( - 'lib/src/platform/busymax_tray_service.dart', - ).readAsStringSync(); - final router = File('lib/src/app/app_router.dart').readAsStringSync(); - final main = File('lib/main.dart').readAsStringSync(); - final compactApp = File( - 'lib/src/features/schedule/presentation/compact_agenda_app.dart', - ).readAsStringSync(); - final compactPanel = File( - 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', + final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); + final commands = File( + 'lib/src/schedule/schedule_commands.dart', ).readAsStringSync(); - final compactWindowService = File( - 'lib/src/platform/compact_agenda_window_service.dart', + final workspace = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); - expect(pubspec, contains('desktop_multi_window:')); - expect(pubspec, contains('window_manager:')); expect(linuxMain, contains('gdk_set_allowed_backends("wayland,x11")')); - expect( - runner, - contains('desktop_multi_window_plugin_set_window_created_callback'), - ); - expect(runner, contains('configure_compact_agenda_subwindow')); - expect(runner, contains('install_compact_agenda_window_css')); - expect(runner, contains('io.busystack.busymax/compact_agenda_window')); - expect(runner, contains('kCompactAgendaPanelWidth = 420')); - expect(runner, contains('kCompactAgendaWindowShadowMargin = 32')); - expect( - runner, - contains('gtk_window_resize(window, kCompactAgendaWindowWidth'), - ); - expect(runner, contains('move_compact_agenda_window_if_supported')); - expect(runner, contains('GDK_IS_X11_DISPLAY(display)')); - expect(runner, contains('gtk_window_move(window, x, y)')); - expect(runner, contains('"skipped-non-x11"')); - expect( - runner, - contains('gtk_window_set_gravity(window, GDK_GRAVITY_NORTH_EAST)'), - ); - expect(runner, contains('BusyMax compact agenda positioning: phase=%s')); - expect(runner, contains('apply_compact_agenda_geometry')); - expect( - runner, - contains( - 'gtk_window_set_type_hint(window, GDK_WINDOW_TYPE_HINT_UTILITY)', - ), - ); - expect( - runner, - contains('gtk_window_set_skip_taskbar_hint(window, TRUE)'), - ); - expect(runner, contains('gtk_window_set_skip_pager_hint(window, TRUE)')); - expect(runner, contains('gtk_window_set_keep_above(window, TRUE)')); - expect(runner, contains('register_compact_gtk_settings_channel')); - expect( - runner, - contains('register_native_date_time_picker_for_subwindow'), - ); - expect(runner, contains('window#busymax-compact-agenda-window')); - expect(runner, contains('gtk_window_get_titlebar(window)')); - expect(runner, contains('gtk_widget_hide(titlebar)')); - expect(runner, contains('gtk_window_set_decorated(window, FALSE)')); - expect( - runner, - contains('gtk_widget_set_size_request(GTK_WIDGET(window)'), - ); - expect(runner, contains('gtk_widget_set_size_request(GTK_WIDGET(view)')); - expect( - runner, - isNot(contains('gtk_window_set_titlebar(window, nullptr)')), - ); - expect(tray, contains('return _onOpenAgenda();')); - expect(tray, isNot(contains('BusyMaxTrayAgendaMenu'))); - expect(tray, isNot(contains('BusyMaxTrayAgendaEntry'))); - expect(tray, isNot(contains('onOpenAgendaEntry'))); - expect(tray, contains('id: _busyMaxTrayAgendaMenuId')); - expect(router, isNot(contains('/tray-agenda'))); - expect(compactApp, isNot(contains('linux_header_bar_service.dart'))); - expect(compactApp, contains('gtk_font_service.dart')); - expect(compactApp, contains('gtkFontSettingsProvider')); - expect(compactApp, contains('gtkThemeColorsProvider')); - expect(compactApp, isNot(contains('syncSchedulerProvider'))); - expect(compactApp, isNot(contains('notificationSchedulerProvider'))); - expect(compactApp, isNot(contains('dueTodayNotificationProvider'))); - expect(compactApp, contains('const _compactAgendaPanelWidth = 420.0')); - expect(compactApp, contains('const _compactAgendaPanelHeight = 680.0')); - expect(compactApp, contains('BusyMaxShadow.windowMargin')); - expect( - compactApp, - contains('io.busystack.busymax/compact_agenda_window'), - ); - expect( - compactApp, - contains('_compactAgendaWindowChannel.invokeMethod'), - ); - expect(compactApp, contains('unawaited(_destroyWindow());')); - expect(compactApp, contains('Future _clearWindowMethodHandler()')); - expect(compactApp, contains('Compact agenda positioning: event=')); - expect( - compactApp, - contains('await windowManager.setSize(_compactAgendaWindowSize)'), - ); - expect(compactApp, contains('await windowManager.setBounds(')); - expect(compactApp, isNot(contains('windowManager.setPosition('))); - expect( - compactApp, - contains('final shownNatively = await _showNativeWindow(position);'), - ); - expect(compactApp, isNot(contains('void onWindowBlur()'))); - expect(compactApp, isNot(contains('_hideAfterBlurDelay'))); - expect(compactPanel, contains('ClipRRect')); - expect(compactPanel, contains('BusyMaxRadius.window')); - expect(compactPanel, contains('BusyMaxShadow.windowShadowsFor')); - expect(compactWindowService, contains('getPrimaryDisplay()')); - expect(compactWindowService, contains('getCursorScreenPoint()')); - expect(compactWindowService, contains('getAllDisplays()')); - expect(compactWindowService, contains('_compactAgendaWindowFrameWidth')); - expect(compactWindowService, contains('_compactAgendaWindowFrameHeight')); - expect(compactWindowService, contains('_clampWindowPositionToWorkArea')); - expect(compactWindowService, contains('raw_x=')); - expect(compactWindowService, contains('final_x=')); - expect( - compactWindowService, - contains( - 'workarea.right -\n' - ' _compactAgendaWindowFrameWidth -\n' - ' _compactAgendaPanelScreenGap', - ), - ); - expect( - compactWindowService, - isNot(contains('panelTop - _compactAgendaWindowShadowMargin')), - ); - expect(compactWindowService, isNot(contains('controller.show()'))); - expect(main, isNot(contains('waitUntilReadyToShow'))); - expect(main, isNot(contains('await windowManager.show();'))); + expect(pubspec, isNot(contains('desktop_multi_window:'))); + expect(pubspec, isNot(contains('screen_retriever:'))); + expect(pubspec, isNot(contains('window_manager:'))); + expect(runner, isNot(contains('desktop_multi_window'))); + expect(runner, isNot(contains('compact_agenda'))); + expect(app, contains('Future _openMainAgenda(')); + expect(app, contains('await windowService.showWindow();')); + expect(app, contains('ScheduleWorkspaceCommandKind.agenda')); + expect(app, contains("ref.read(appRouterProvider).go('/schedule')")); + expect(commands, contains('agenda,')); + expect(workspace, contains('case ScheduleWorkspaceCommandKind.agenda:')); + expect(workspace, contains('_setMode(ScheduleViewMode.agenda);')); }); - test('native headerbar keeps sidebar top branded and aligned', () { + test('native headerbar keeps sidebar branded with GTK-owned centering', () { final source = File('linux/runner/my_application.cc').readAsStringSync(); final signIn = File( 'lib/src/features/auth/presentation/sign_in_screen.dart', @@ -340,9 +394,18 @@ void main() { final schedule = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); + final titlebarHandleStart = source.indexOf( + 'static GtkWidget* create_busymax_titlebar_handle', + ); + final titlebarHandleEnd = source.indexOf( + 'static gboolean show_header_create_menu', + titlebarHandleStart, + ); + expect(titlebarHandleStart, isNonNegative); + expect(titlebarHandleEnd, greaterThan(titlebarHandleStart)); final headerBarSource = source.substring( - 0, - source.indexOf('static void install_compact_agenda_window_css'), + titlebarHandleStart, + titlebarHandleEnd, ); expect(source, isNot(contains('GtkWidget* brand_box'))); @@ -354,11 +417,15 @@ void main() { ); expect(source, isNot(contains('create_header_logo()'))); expect(source, contains('header_sidebar_brand_box')); - expect(source, contains('header_title_balance_spacer')); + expect(source, isNot(contains('header_title_balance_spacer'))); expect(source, contains('header_title_box')); - expect(source, contains('kHeaderWindowControlsBalanceWidth')); + expect(source, isNot(contains('kHeaderWindowControlsBalanceWidth'))); expect(source, contains('kHeaderOnboardingContentWidth')); expect(source, contains('kHeaderOnboardingSideWidth')); + expect(source, contains('header_onboarding_content_width')); + expect(source, contains('"contentWidth"')); + expect(source, contains('recenter_onboarding_header_controls_cb')); + expect(source, contains('header_bar_size_allocate_cb')); expect(source, contains('onboarding_back_slot')); expect(source, contains('onboarding_back_button')); expect(source, contains('onboarding_continue_slot')); @@ -373,16 +440,11 @@ void main() { expect(signIn, contains('generation != _headerBarUpdateGeneration')); expect(signIn, contains('_headerBarUpdateGeneration++;')); expect(signIn, contains('force: true')); - expect(schedule, contains('if (service.isAvailable)')); - expect(schedule, contains('service.setOnboardingControls(')); + expect(schedule, contains('if (_headerBarSession.isAvailable)')); + expect(schedule, contains('_headerBarSession.setOnboardingControls(')); expect(schedule, contains('force: true')); - expect( - source, - contains( - 'gtk_header_bar_pack_start(header_bar, self->header_title_balance_spacer)', - ), - ); - expect(source, contains('update_header_title_balance_spacer')); + expect(source, contains('gtk_header_bar_set_custom_title(header_bar')); + expect(source, isNot(contains('update_header_title_balance_spacer'))); expect(source, contains('update_header_title_box_geometry')); expect(source, contains('self->header_onboarding_controls_visible')); expect( @@ -403,14 +465,88 @@ void main() { 'gtk_label_set_xalign(GTK_LABEL(self->header_title_label), 0.5)', ), ); + final headerTitleStart = source.indexOf( + 'track_widget_pointer(&self->header_title_label', + ); + final searchEntryStart = source.indexOf( + 'track_widget_pointer(&self->search_entry', + headerTitleStart, + ); + expect(headerTitleStart, isNonNegative); + expect(searchEntryStart, greaterThan(headerTitleStart)); + expect( + source.substring(headerTitleStart, searchEntryStart), + contains('GTK_STYLE_CLASS_TITLE'), + ); expect(source, isNot(contains('header_brand_logo'))); expect(source, contains('header_brand_label')); expect(source, contains('settings_menu_button')); expect(source, contains('settings_menu')); - expect(source, contains('create_header_settings_item(self, "settings"')); expect( source, - contains('create_header_settings_item(self, "aboutBusyMax"'), + contains( + 'gtk_box_pack_start(GTK_BOX(end_box), self->search_button, ' + 'FALSE, FALSE, 0)', + ), + ); + expect( + source, + contains( + 'gtk_box_pack_start(GTK_BOX(end_box), self->settings_menu_button, ' + 'FALSE, FALSE,', + ), + ); + expect( + source, + contains( + 'set_widget_visible(self->settings_menu_button,\n' + ' schedule_controls_visible || ' + 'self->header_back_visible);', + ), + ); + expect( + headerBarSource, + isNot( + contains( + 'gtk_box_pack_start(GTK_BOX(self->header_sidebar_brand_box),\n' + ' self->search_button', + ), + ), + ); + expect( + headerBarSource, + isNot( + contains( + 'gtk_box_pack_end(GTK_BOX(self->header_sidebar_brand_box),\n' + ' self->settings_menu_button', + ), + ), + ); + expect( + source, + contains( + 'append_header_action_item(menu, self->header_settings_label,', + ), + ); + expect( + source, + contains( + 'append_header_action_item(menu, self->header_report_issue_label,', + ), + ); + expect( + source.indexOf('self->header_report_issue_label'), + lessThan(source.indexOf('self->header_about_label')), + ); + expect( + source, + contains( + 'create_header_bridge_action(self, "report-issue", "reportIssue")', + ), + ); + expect( + source, + contains('create_header_bridge_action(self, "about", "aboutBusyMax")'), ); expect(source, isNot(contains('settingsAccounts'))); expect(source, isNot(contains('settingsDiagnostics'))); @@ -430,7 +566,12 @@ void main() { 'gtk_box_pack_start(GTK_BOX(self->titlebar_box), GTK_WIDGET(header_bar)', ), ); - expect(source, contains('gtk_window_set_titlebar(window, titlebar)')); + expect(source, contains('hdy_window_handle_new()')); + expect( + source, + contains('gtk_container_add(GTK_CONTAINER(self->titlebar_handle),'), + ); + expect(source, isNot(contains('gtk_window_set_titlebar(window,'))); expect( source, isNot( @@ -453,13 +594,6 @@ void main() { expect(source, contains('header_bar_sidebar_visible')); expect(source, contains('header_sidebar_effective_width')); expect(source, contains('update_header_sidebar_brand_geometry')); - expect(source, contains('const gint header_bar_left_radius')); - expect( - source, - contains( - 'header_sidebar_effective_width(self) > 0 ? 0 : kHeaderWindowRadius', - ), - ); expect(source, contains('kHeaderMainContentStartInset')); expect( source, @@ -482,89 +616,75 @@ void main() { ), ); expect(source, contains('padding-left: 0;')); - expect(source, contains('kHeaderWindowRadius')); - expect(source, contains('border-top-left-radius: %dpx;')); - expect(source, contains('border-top-right-radius: %dpx;')); - final mainWindowCssStart = source.indexOf('"window#busymax-window,"'); - final mainWindowDecorationCssStart = source.indexOf( - '"window#busymax-window decoration,"', - mainWindowCssStart, - ); - final mainWindowCss = source.substring( - mainWindowCssStart, - mainWindowDecorationCssStart, - ); - expect(mainWindowCss, contains('"background-color: %s;"')); + expect(source, contains('"window#busymax-window,"')); + expect(source, contains('"background-color: %s;"')); expect( source, - contains('self->main_window_transparent_backing ? "transparent"'), + contains('gtk_widget_set_name(GTK_WIDGET(window), "busymax-window")'), ); + expect(source, contains('set_main_flutter_view_background(self)')); expect( source, - contains( - 'g_signal_connect(window, "draw", G_CALLBACK(clear_transparent_window_cb)', - ), + isNot(contains('window#busymax-window decoration')), + reason: 'The compatibility rule must remain direct-child scoped.', ); - expect(source, contains('clear_transparent_window_cb')); - expect(source, contains('CAIRO_OPERATOR_CLEAR')); + expect(source, isNot(contains('main_window_transparent_backing'))); + expect(source, isNot(contains('clear_transparent_window_cb'))); + expect(source, isNot(contains('CAIRO_OPERATOR_CLEAR'))); expect( source, - contains('gtk_widget_set_name(GTK_WIDGET(window), "busymax-window")'), + isNot( + contains('gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE)'), + ), ); - expect(source, contains('window#busymax-window decoration,')); - expect(source, contains('window#busymax-window decoration:backdrop')); - expect(source, contains('"box-shadow: 0 3px 18px 2px %s;"')); - expect(source, contains('window_css_background_color, shade_color')); - expect(source, contains('"outline: none;"')); - expect( - source, - contains('gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE)'), - ); - expect(source, contains('configure_rounded_window_shape')); - expect(source, contains('create_rounded_window_region')); - expect(source, contains('gdk_window_shape_combine_region')); - expect(source, contains('rounded_window_size_allocate_cb')); - expect(source, contains('"size-allocate"')); - expect(source, contains('rounded_window_state_event_cb')); - expect(source, contains('"window-state-event"')); - expect(source, contains('GDK_WINDOW_STATE_MAXIMIZED')); - expect(source, contains('GDK_WINDOW_STATE_FULLSCREEN')); - expect(source, contains('GDK_WINDOW_STATE_TILED')); - expect(source, contains('GDK_WINDOW_STATE_RIGHT_TILED')); - expect(source, contains('gtk_widget_queue_draw(widget)')); + expect(source, isNot(contains('configure_rounded_window_shape'))); + expect(source, isNot(contains('create_rounded_window_region'))); + expect(source, isNot(contains('gdk_window_shape_combine_region'))); + expect(source, isNot(contains('rounded_window_size_allocate_cb'))); + expect(source, isNot(contains('rounded_window_state_event_cb'))); + expect(source, isNot(contains('kNativeWindowRadius'))); + expect(source, isNot(contains('border-top-left-radius: %dpx;'))); + expect(source, isNot(contains('border-top-right-radius: %dpx;'))); expect(source, isNot(contains('kHeaderSidebarEdgeCompensation'))); expect(source, isNot(contains('-kHeaderSidebarEdgeCompensation'))); expect(source, isNot(contains('linear-gradient(to right'))); expect(source, isNot(contains('GtkWidget* sidebar_toggle_button;'))); expect(source, isNot(contains('self->sidebar_toggle_button'))); - expect(source, contains('constexpr gint kHeaderButtonRadius = 8;')); - expect(source, contains('border-radius: %dpx;')); - expect(source, contains('tooltip.background')); - expect(source, contains('tooltip > box')); - expect(source, contains('tooltip label')); - expect(source, contains('margin: 0;')); - expect(source, contains('min-height: 0;')); - expect(source, contains('"box-shadow: 0 5px 18px 2px %s;"')); - expect(source, contains('kHeaderTooltipVerticalPadding')); - expect(source, contains('kHeaderTooltipHorizontalPadding')); - expect( - source, - contains('constexpr gint kHeaderTooltipVerticalPadding = 5;'), - ); - expect( - source, - contains('constexpr gint kHeaderTooltipHorizontalPadding = 8;'), - ); - expect(source, contains('padding: %dpx %dpx;')); - expect(source, contains('border-color: transparent;')); - expect(source, contains('border-width: 0;')); - expect(source, contains('outline-style: none;')); - expect(source, contains('busymax-brand-action-button')); - expect(source, contains('button.busymax-brand-action-button,')); - expect(source, contains('button.busymax-brand-action-button:hover')); - expect(source, contains('busymax-sidebar-toggle')); - expect(source, contains('busymax-header-button')); - expect(source, contains('busymax-header-icon-button')); + expect(source, isNot(contains('kHeaderButtonRadius'))); + expect(source, contains('kDefaultTooltipBackground')); + expect(source, contains('kDefaultTooltipForeground')); + expect(source, contains('kDefaultTooltipBorder')); + expect(source, contains('kDefaultTooltipRadius')); + expect(source, contains('kTooltipBorderWidth')); + expect(source, contains('kGtkTooltipContainerInset')); + expect(source, contains('"tooltip.background {"')); + expect(source, contains('"tooltip decoration,"')); + expect(source, contains('"tooltip.csd decoration {"')); + expect(source, contains('"tooltip box,"')); + expect(source, contains('"tooltip.background box {"')); + expect(source, contains('"tooltip label,"')); + expect(source, contains('"tooltip.background label {"')); + expect(source, contains('"min-width: 0;"')); + expect(source, contains('tooltip_label_horizontal_padding')); + expect(source, contains('tooltip_label_vertical_padding')); + expect(source, contains('tooltip_label_minimum_height')); + expect(source, contains('self->header_bar_tooltip_minimum_height -')); + expect(source, contains('kGtkTooltipContainerInset * 2')); + expect(source, contains('tooltip_label_vertical_padding * 2')); + expect(source, isNot(contains('kHeaderTooltipVerticalPadding'))); + expect(source, isNot(contains('kHeaderTooltipHorizontalPadding'))); + expect(source, isNot(contains('kYaruGtk3TooltipVerticalPadding'))); + expect(source, isNot(contains('kYaruGtk3TooltipHorizontalPadding'))); + expect(source, isNot(contains('kYaruGtk3TooltipRadius'))); + expect(source, isNot(contains('"padding: %dpx %dpx;"'))); + expect(source, contains('kHeaderControlStyleClass')); + expect(source, isNot(contains('busymax-header-menu-control'))); + expect(source, isNot(contains('"border-width: 0;"'))); + expect(source, isNot(contains('"outline-style: none;"'))); + expect(source, isNot(contains('busymax-brand-action-button'))); + expect(source, isNot(contains('busymax-sidebar-toggle'))); + expect(source, isNot(contains('busymax-header-button'))); + expect(source, isNot(contains('busymax-header-icon-button'))); expect( source, contains( @@ -582,24 +702,40 @@ void main() { ); expect( source, - contains( - '.busymax-titlebar button.busymax-header-view-mode-button:hover', + isNot( + contains( + '.busymax-titlebar button.busymax-header-view-mode-button:hover', + ), ), ); - expect(source, contains('transition: none;')); - expect(source, contains('gtk_popover_new(nullptr)')); - expect(source, contains('gtk_popover_set_position')); + expect(source, isNot(contains('transition: none;'))); + expect(source, isNot(contains('gtk_popover_set_position'))); expect(source, contains('GTK_POS_BOTTOM')); - expect(source, contains('create_header_popover_box')); - expect(source, contains('gtk_popover_popdown')); + expect(source, isNot(contains('gtk_popover_popdown'))); expect(source, contains('gtk_menu_button_new()')); - expect(source, contains('gtk_menu_button_set_use_popover')); - expect(source, contains('gtk_menu_button_set_popover')); + expect( + source, + contains( + 'gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), FALSE)', + ), + ); + expect(source, contains('gtk_menu_button_set_menu_model')); + expect(source, contains('gtk_menu_button_get_popup')); + expect(source, contains('GTK_IS_MENU(menu)')); + expect(source, contains('gtk_menu_shell_deactivate')); expect(source, contains('close_header_menu_button')); expect(source, contains('gtk_toggle_button_set_active')); + expect(source, contains('g_menu_new()')); + expect(source, contains('g_menu_append_item(menu, item)')); + expect( + source, + contains('g_menu_item_set_action_and_target(item, "header.view-mode"'), + ); + expect(source, contains('g_simple_action_new_stateful')); + expect(source, contains('g_action_map_add_action')); + expect(source, contains('gtk_widget_insert_action_group')); expect(source, isNot(contains('popdown_header_popover'))); expect(source, isNot(contains('gtk_popover_set_relative_to'))); - expect(source, isNot(contains('gtk_popover_popup'))); expect(source, isNot(contains('"busymax-popover-open"'))); expect(source, isNot(contains('header_popover_is_open'))); expect(source, isNot(contains('set_header_popover_open'))); @@ -608,20 +744,27 @@ void main() { expect(source, isNot(contains('popup_header_menu'))); expect(source, isNot(contains('gtk_widget_get_mapped(popup)'))); expect(source, isNot(contains('gtk_widget_get_visible(popup)'))); - expect(source, contains('"busymax-header-popover"')); - expect(source, contains('header_bar_popover_background_color')); - expect(source, contains('"popoverBackgroundColor"')); - expect(source, contains('"busymax-header-popover-row"')); - expect(source, contains('kHeaderPopoverRowSpacing')); - expect( - source, - contains( - 'gtk_box_new(GTK_ORIENTATION_VERTICAL, kHeaderPopoverRowSpacing)', - ), - ); - expect(source, contains('button.busymax-header-popover-row:focus')); - expect(source, contains('"object-select-symbolic"')); - expect(source, contains('gtk_widget_set_opacity(check_widget')); + expect(source, isNot(contains('"busymax-header-popover"'))); + expect(source, isNot(contains('"busymax-native-popover"'))); + expect(source, isNot(contains('"busymax-header-menu-depth"'))); + expect(source, isNot(contains('header_bar_popover_background_color'))); + expect(source, isNot(contains('header_bar_popover_shadow_color'))); + expect(source, isNot(contains('header_bar_menu_hover_color'))); + expect(source, isNot(contains('header_bar_floating_border_color'))); + expect(source, isNot(contains('"popoverBackgroundColor"'))); + expect(source, isNot(contains('"menuHoverColor"'))); + expect(source, isNot(contains('"popoverShadowColor"'))); + expect(source, contains('"dialogBackgroundColor"')); + expect(source, contains('"dialogOutlineColor"')); + expect(source, isNot(contains('"floatingBorderColor"'))); + expect(source, isNot(contains('"busymax-header-popover-row"'))); + expect(source, isNot(contains('kHeaderPopoverRowSpacing'))); + expect(source, isNot(contains('busymax-keyboard-focus'))); + expect(source, isNot(contains('gtk_window_get_focus_visible'))); + expect(source, isNot(contains('configure_header_popover_row'))); + expect(source, isNot(contains('gtk_widget_grab_focus(first_item)'))); + expect(headerBarSource, isNot(contains('"object-select-symbolic"'))); + expect(source, isNot(contains('gtk_widget_set_opacity(check_widget'))); expect(source, isNot(contains('gtk_model_button_new()'))); expect(source, isNot(contains('gtk_check_menu_item_new'))); expect(source, isNot(contains('GTK_BUTTON_ROLE_CHECK'))); @@ -630,9 +773,12 @@ void main() { expect(source, isNot(contains('g_object_set(item, "active"'))); expect( source, - contains('gtk_box_pack_start(GTK_BOX(view_mode_menu_box)'), + isNot(contains('gtk_box_pack_start(GTK_BOX(view_mode_menu_box)')), + ); + expect( + source, + isNot(contains('gtk_box_pack_start(GTK_BOX(settings_menu_box)')), ); - expect(source, contains('gtk_box_pack_start(GTK_BOX(settings_menu_box)')); expect(source, isNot(contains('.busymax-header-menu,'))); expect(source, isNot(contains('menu.background.busymax-header-menu'))); expect(source, isNot(contains('menuitem.busymax-header-view-mode-item'))); @@ -655,27 +801,139 @@ void main() { expect(source, contains('padding-left: 0;')); expect(source, contains('kHeaderButtonHeight')); expect(source, contains('kHeaderButtonSpacing')); - expect(source, contains('kHeaderMenuPadding = kHeaderButtonSpacing')); + expect(source, isNot(contains('kHeaderMenuPadding'))); expect(source, isNot(contains('padding: 4px;'))); expect(source, contains('setLocalizedLabels')); expect(source, contains('setSidebarWidth')); + expect(source, contains('setTextDirection')); expect(source, contains('setTheme')); + expect(source, contains('kHeaderBarStateSchemaVersion = 3')); + expect(source, contains('fl_lookup_int_arg(args, "schemaVersion"')); + expect( + source, + contains('schema_version != kHeaderBarStateSchemaVersion'), + ); + expect(source, contains('args, "canCreateEvent"')); + expect(source, contains('args, "canCreateTask"')); + expect(source, contains('args, "searchQuery"')); + expect(source, contains('args, "showSidebarPanel"')); + expect(source, contains('args, "hideSidebarPanel"')); + expect(source, contains('args, "sidebarShortcut"')); + expect(source, contains('update_header_sidebar_presentation')); + expect(source, isNot(contains('args, "sidebarOverlaysContent"'))); + expect(source, isNot(contains('args, "compactWindow"'))); + expect(source, contains('gtk_search_entry_new()')); + expect(source, contains('PANGO_ELLIPSIZE_NONE')); + expect(source, contains('update_header_title_fit')); + expect(source, contains('gtk_stack_add_named')); + expect( + source, + contains( + 'effective_active ? self->search_entry : self->header_title_label', + ), + ); + expect( + source, + contains( + 'gtk_stack_set_visible_child(GTK_STACK(self->header_title_stack),', + ), + ); + expect(source, contains('if (effective_active) {')); + expect(source, contains('focus_header_search_entry(self);')); + expect(source, contains('"search-changed"')); + expect(source, contains('"searchFocusChanged"')); + expect(source, contains('"searchCleared"')); + expect(source, contains('"stop-search"')); + expect(source, contains('"searchEscapePressed"')); + expect(source, contains('"icon-release"')); + expect(source, contains('cache_header_search_query')); + expect(source, contains('focus_header_search_entry')); + expect(source, contains('strcmp(method, "focusSearch") == 0')); + expect(source, contains('gtk_entry_set_placeholder_text')); + expect(source, contains('clear_widget_pointer(&self->search_entry)')); + expect(source, contains('g_clear_pointer(&self->header_search_query')); + expect(source, contains('"busymax-header-search-entry"')); + expect(source, contains('kHeaderSearchEntryStyleClass')); + expect(source, contains('set_header_create_capabilities')); + expect(source, contains('strcmp(method, "showCreateMenu") == 0')); expect(source, contains('setModalBarrierVisible')); + expect(source, contains('setModalBarrierDepth')); + expect(source, contains('setModalBarrierState')); + expect(source, contains('modal_barrier_color_for_depth')); + expect( + source, + contains('1.0 - std::pow(1.0 - barrier.alpha, effective_depth)'), + ); expect(source, contains('busymax-modal-barrier')); - expect(source, contains('gtk_widget_set_sensitive(self->titlebar_box')); + expect( + source, + isNot(contains('gtk_widget_set_sensitive(self->titlebar_handle')), + ); + expect(source, contains('GtkWidget* titlebar_overlay;')); + expect(source, contains('GtkWidget* titlebar_modal_barrier;')); + expect(source, contains('gtk_overlay_new()')); + expect(source, contains('gtk_event_box_new()')); + expect(source, contains('gtk_overlay_add_overlay')); + expect(source, contains('gtk_overlay_set_overlay_pass_through')); + expect( + source, + contains( + 'gtk_widget_set_halign(self->titlebar_modal_barrier, GTK_ALIGN_FILL)', + ), + ); + expect( + source, + contains( + 'gtk_widget_set_valign(self->titlebar_modal_barrier, GTK_ALIGN_FILL)', + ), + ); + expect( + source, + isNot( + contains( + 'gtk_widget_set_hexpand(self->titlebar_modal_barrier, TRUE)', + ), + ), + ); + expect( + source, + isNot( + contains( + 'gtk_widget_set_vexpand(self->titlebar_modal_barrier, TRUE)', + ), + ), + ); + expect(source, contains('consume_header_bar_modal_input_cb')); + expect(source, contains('case GDK_BUTTON_PRESS:')); + expect(source, contains('return TRUE;')); + expect(source, contains('gtk_widget_set_no_show_all')); + expect( + source, + contains( + 'gtk_widget_set_visible(self->titlebar_modal_barrier, visible)', + ), + ); expect(source, isNot(contains('setBackgroundColor'))); expect(source, isNot(contains('setSidebarBackgroundColor'))); expect(source, contains('is_css_rgba_color')); expect(source, contains('is_css_color_token')); expect(source, contains('header_bar_foreground_color')); - expect(source, contains('header_bar_muted_foreground_color')); - expect(source, contains('header_bar_disabled_foreground_color')); - expect(source, contains('header_bar_control_hover_color')); - expect(source, contains('header_bar_popover_background_color')); - expect(source, contains('header_bar_border_color')); - expect(source, contains('header_bar_shade_color')); - expect(source, contains('header_bar_accent_color')); - expect(source, contains('header_bar_accent_foreground_color')); + expect(source, isNot(contains('header_bar_muted_foreground_color'))); + expect(source, isNot(contains('header_bar_disabled_foreground_color'))); + expect(source, isNot(contains('header_bar_control_hover_color'))); + expect(source, isNot(contains('header_bar_popover_background_color'))); + expect(source, isNot(contains('header_bar_floating_border_color'))); + expect(source, isNot(contains('header_bar_border_color'))); + expect(source, contains('header_bar_sidebar_border_color')); + expect(source, contains('border-right: 1px solid %s;')); + expect(source, contains('"rgba(16,16,16,0.35)"')); + expect(source, isNot(contains('"rgba(255,255,255,0.10)"'))); + expect(source, isNot(contains('modal_sidebar_border_css_color'))); + expect(source, isNot(contains('composite_rgba'))); + expect(source, isNot(contains('header_bar_shade_color'))); + expect(source, contains('header_bar_modal_barrier_color')); + expect(source, isNot(contains('header_bar_accent_color'))); + expect(source, isNot(contains('header_bar_accent_foreground_color'))); expect(source, isNot(contains('kHeaderMenuFallbackBackgroundColor'))); expect(source, isNot(contains('kHeaderMenuFallbackForegroundColor'))); expect(source, contains('GTK_STYLE_CLASS_FLAT')); @@ -687,12 +945,20 @@ void main() { isNot(contains('menuitem.busymax-header-view-mode-item:checked')), ); expect(source, isNot(contains('busymax-header-view-mode-item-active'))); - expect(source, contains('create_header_popup_window')); - expect(source, contains('busymax-header-primary-button')); - expect(source, contains('fl_lookup_string_arg(args, "accentColor")')); + expect(source, isNot(contains('create_header_popup_window'))); + expect(source, contains('gtk_menu_button_set_menu_model')); + expect(source, isNot(contains('GTK_STYLE_CLASS_SUGGESTED_ACTION'))); + expect(source, contains('kHeaderOnboardingTextButtonStyleClass')); + expect(source, contains('"padding: 0;"')); + expect(source, contains('"background-color: transparent;"')); + expect(source, isNot(contains('busymax-header-primary-button'))); + expect( + source, + isNot(contains('fl_lookup_string_arg(args, "accentColor")')), + ); expect( source, - contains('fl_lookup_string_arg(args, "accentForegroundColor")'), + isNot(contains('fl_lookup_string_arg(args, "accentForegroundColor")')), ); expect(source, isNot(contains('gtk_menu_new()'))); expect(source, isNot(contains('gtk_menu_popup_at_widget'))); @@ -708,14 +974,15 @@ void main() { ); expect(source, isNot(contains('create_header_popup_box'))); expect(source, isNot(contains('draw_header_popup_background_cb'))); - expect(source, isNot(contains('gtk_event_box_new()'))); + expect('gtk_event_box_new()'.allMatches(source).length, 1); expect(source, isNot(contains('gtk_widget_set_app_paintable(popup'))); expect(headerBarSource, isNot(contains('gtk_window_move'))); expect(source, isNot(contains('override_header_menu_colors'))); expect(source, isNot(contains('GTK_STYLE_PROVIDER_PRIORITY_USER'))); expect(source, isNot(contains('add_header_menu_provider_to_widget'))); expect(source, isNot(contains('gtk_widget_get_toplevel(menu)'))); - expect(source, contains('busymax-application')); + expect(source, isNot(contains('busymax-application'))); + expect(source, contains('gtk_widget_insert_action_group')); expect(source, isNot(contains('gtk_widget_override_background_color'))); expect( source, @@ -737,27 +1004,108 @@ void main() { expect(source, contains('notify::gtk-theme-name')); expect(source, contains('notify::gtk-application-prefer-dark-theme')); expect(source, contains('get_gtk_theme_colors')); + expect(source, contains('apply_gtk_theme_to_bootstrap_chrome')); expect(source, contains('lookup_context_color')); expect(source, contains('theme_bg_color')); - expect(source, contains('theme_base_color')); - expect(source, contains('GTK_STYLE_CLASS_SIDEBAR')); - expect(source, contains('GTK_STYLE_CLASS_VIEW')); - expect(source, contains('gtk_style_context_get_property')); - expect(source, contains('"background-color"')); - expect(source, isNot(contains('gtk_style_context_get_background_color'))); - expect(source, contains('parse_gtk_font_name')); - expect(source, contains('gtk-font-name')); - expect(source, contains('pango_font_description_from_string')); - expect(source, contains('pango_font_description_get_family')); - expect(source, contains('PANGO_SCALE')); - expect(source, contains('register_gtk_settings_channel')); - expect(source, isNot(contains('GtkWidget* day_button;'))); - expect(source, isNot(contains('GtkWidget* week_button;'))); - expect(source, isNot(contains('GtkWidget* month_button;'))); - expect(source, isNot(contains('GtkWidget* year_button;'))); - expect(source, isNot(contains('GtkWidget* agenda_button;'))); - expect(source, isNot(contains('create_header_text_button("Today"'))); - expect(source, isNot(contains('create_header_toggle_text_button("Day"'))); + expect( + source, + isNot( + contains('lookup_context_color(window_context, "theme_base_color"'), + ), + ); + expect( + source, + contains( + 'lookup_context_color(window_context, "view_bg_color", &view_color);', + ), + ); + expect( + source, + contains( + 'lookup_context_color(window_context, "shade_color", &shade_color);', + ), + ); + expect( + source, + isNot(contains('lookup_context_color(window_context, "wm_shadow"')), + ); + expect(source, isNot(contains('shade_color = border_color'))); + expect(source, isNot(contains('gtk_text_view_new()'))); + expect(source, isNot(contains('sample_widget_background(view'))); + expect(source, contains('"window_bg_color"')); + expect(source, contains('"sidebar_bg_color"')); + expect(source, contains('"secondary_sidebar_bg_color"')); + expect(source, contains('"headerbar_bg_color"')); + expect(source, contains('"card_bg_color"')); + expect(source, contains('"dialog_bg_color"')); + expect(source, contains('"popover_bg_color"')); + expect( + source, + isNot(contains('GtkWidget* popover = gtk_popover_new(nullptr);')), + ); + expect(source, isNot(contains('GtkWidget* sidebar = gtk_box_new('))); + + final activateStart = source.indexOf( + 'static void my_application_activate(', + ); + final windowCreation = source.indexOf( + 'hdy_application_window_new()', + activateStart, + ); + final bootstrapTheme = source.indexOf( + 'apply_gtk_theme_to_bootstrap_chrome(self);', + activateStart, + ); + expect(activateStart, isNonNegative); + expect(bootstrapTheme, greaterThan(activateStart)); + expect(windowCreation, greaterThan(bootstrapTheme)); + + final themeNotifyStart = source.indexOf( + 'static void gtk_theme_colors_notify_cb(', + ); + final themeNotifyEnd = source.indexOf( + 'static void connect_gtk_theme_colors_signals(', + themeNotifyStart, + ); + final themeNotify = source.substring(themeNotifyStart, themeNotifyEnd); + expect(themeNotify, contains('if (!self->header_bar_theme_received)')); + expect( + themeNotify, + contains('apply_gtk_theme_to_bootstrap_chrome(self);'), + ); + expect( + source, + isNot(contains('GtkWidget* header = gtk_header_bar_new()')), + ); + expect( + source, + isNot(contains('GtkWidget* card = gtk_frame_new(nullptr)')), + ); + expect(source, isNot(contains('sample_widget_background(sidebar'))); + expect(source, isNot(contains('sample_widget_background(header'))); + expect(source, isNot(contains('sample_widget_background(card'))); + expect(source, isNot(contains('sample_widget_background(dialog'))); + expect(source, isNot(contains('sample_widget_background(popover'))); + expect( + source, + contains('Optional modern surface roles must remain semantic.'), + ); + expect(source, contains('gtk_style_context_get_property')); + expect(source, contains('"background-color"')); + expect(source, isNot(contains('gtk_style_context_get_background_color'))); + expect(source, contains('parse_gtk_font_name')); + expect(source, contains('gtk-font-name')); + expect(source, contains('pango_font_description_from_string')); + expect(source, contains('pango_font_description_get_family')); + expect(source, contains('PANGO_SCALE')); + expect(source, contains('register_gtk_settings_channel')); + expect(source, isNot(contains('GtkWidget* day_button;'))); + expect(source, isNot(contains('GtkWidget* week_button;'))); + expect(source, isNot(contains('GtkWidget* month_button;'))); + expect(source, isNot(contains('GtkWidget* year_button;'))); + expect(source, isNot(contains('GtkWidget* agenda_button;'))); + expect(source, isNot(contains('create_header_text_button("Today"'))); + expect(source, isNot(contains('create_header_toggle_text_button("Day"'))); expect( source, isNot(contains('create_header_toggle_text_button("Week"')), @@ -774,54 +1122,717 @@ void main() { source, isNot(contains('create_header_toggle_text_button("Agenda"')), ); - expect(source, contains('create_header_view_mode_item(self, "year"')); + expect( + source, + contains( + 'append_header_view_mode_item(menu, self->header_year_label, "year",', + ), + ); + expect(source, contains('GtkWidget* view_mode_icon;')); + expect(source, isNot(contains('GtkWidget* view_mode_label;'))); + expect( + source, + contains( + 'gtk_image_set_from_icon_name(GTK_IMAGE(self->view_mode_icon),', + ), + ); + expect(source, contains('header_view_mode_icon_name("week")')); + expect(source, contains('return "view-continuous-symbolic";')); + expect(source, contains('return "calendar-week-symbolic";')); + expect(source, contains('return "calendar-month-symbolic";')); + expect(source, contains('return "view-app-grid-symbolic";')); + expect(source, contains('return "view-list-symbolic";')); + expect(source, contains('"pan-down-symbolic"')); expect(source, contains('return "viewModeYear"')); expect(source, contains('list-add-symbolic')); expect( source, contains( - 'connect_header_bar_action(self, self->create_button, "create")', + 'append_header_action_item(menu, self->header_create_event_label,', + ), + ); + expect( + source, + contains( + 'append_header_action_item(menu, self->header_create_task_label,', ), ); + expect(source, contains('kMenuAccelAttribute')); + expect(source, isNot(contains('kMenuShortcutAttribute'))); + expect(source, isNot(contains('kMenuIconAttribute'))); + expect(source, contains('g_menu_item_set_icon(item, icon)')); + expect(source, contains('set_menu_item_accelerator(item, shortcut)')); + expect(source, contains('gtk_accelerator_from_shortcut_label')); + expect(source, isNot(contains('decorate_model_menu_shortcuts'))); + expect(source, contains('gtk_menu_button_set_menu_model')); + expect(source, contains('g_simple_action_set_enabled')); + expect(source, isNot(contains('self->create_button, "create"'))); expect(source, contains('open-menu-symbolic')); - expect(source, contains('create_header_settings_item')); + expect(source, isNot(contains('create_header_popover_action_item'))); + expect(source, isNot(contains('button.busymax-header-popover-row'))); expect( source, - contains('button.busymax-header-button.busymax-sidebar-toggle:checked'), + isNot( + contains( + 'button.busymax-header-button.busymax-sidebar-toggle:checked', + ), + ), ); expect(source, isNot(contains('"newItem"'))); expect(source, isNot(contains('"openMenu"'))); }); - test('native headerbar colors use semantic Dart payload fields', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); - final barrierBackgroundStart = source.indexOf( - '".busymax-titlebar.busymax-modal-barrier,"', + test('Linux content menus use native GTK popup menus on mapped host', () { + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final service = File( + 'lib/src/platform/native_menu_service.dart', + ).readAsStringSync(); + final start = runner.indexOf('constexpr char kNativeMenuActionNamespace'); + final end = runner.indexOf('static void respond_success', start); + + expect(start, isNonNegative); + expect(end, greaterThan(start)); + final nativeMenu = runner.substring(start, end); + final disposeStart = nativeMenu.indexOf( + 'static void native_menu_session_dispose', + ); + final disposeEnd = nativeMenu.indexOf( + 'static gboolean native_menu_cleanup_idle_cb', + disposeStart, + ); + expect(disposeStart, isNonNegative); + expect(disposeEnd, greaterThan(disposeStart)); + final dispose = nativeMenu.substring(disposeStart, disposeEnd); + + expect(runner, contains('"busymax/native_menus"')); + expect(nativeMenu, isNot(contains('struct NativeMenuHostWidgets'))); + expect(nativeMenu, isNot(contains('gtk_event_box_set_above_child('))); + expect(nativeMenu, isNot(contains('input_layer'))); + expect(nativeMenu, isNot(contains('menu_layer'))); + expect(nativeMenu, contains('GMenu* model;')); + expect(nativeMenu, contains('GSimpleActionGroup* action_group;')); + expect(nativeMenu, contains('GtkWidget* menu;')); + expect(nativeMenu, contains('g_simple_action_new_stateful(')); + expect(nativeMenu, contains('g_menu_item_set_action_and_target_value(')); + expect(nativeMenu, contains('g_menu_item_set_icon(item, icon)')); + expect(nativeMenu, contains('set_menu_item_accelerator(item, shortcut)')); + expect(nativeMenu, contains('gtk_menu_new_from_model(')); + expect( + nativeMenu, + contains( + 'gtk_menu_attach_to_widget(GTK_MENU(session->menu), data->view', + ), + ); + expect(nativeMenu, contains('GTK_IS_MENU(session->menu)')); + expect( + nativeMenu, + contains( + 'gtk_widget_insert_action_group(\n' + ' data->view, kNativeMenuActionNamespace', + ), + ); + expect( + nativeMenu, + contains( + 'gtk_widget_translate_coordinates(\n' + ' data->view, toplevel, anchor.x, anchor.y', + ), + ); + expect(nativeMenu, contains('gtk_menu_popup_at_rect(')); + expect(nativeMenu, contains('rect_window, &window_anchor')); + expect(nativeMenu, contains('GDK_GRAVITY_SOUTH_WEST')); + expect(nativeMenu, contains('GDK_GRAVITY_NORTH_WEST')); + expect(nativeMenu, contains('GDK_ANCHOR_FLIP_Y')); + expect(nativeMenu, contains('GDK_ANCHOR_SLIDE')); + expect(nativeMenu, contains('GDK_ANCHOR_RESIZE')); + expect(nativeMenu, contains('native_menu_deactivate_cb')); + expect( + nativeMenu, + contains('"deactivate", G_CALLBACK(native_menu_deactivate_cb)'), + ); + expect(nativeMenu, contains('gtk_menu_shell_deactivate(')); + expect(nativeMenu, contains('native_menu_action_activated_cb')); + expect(nativeMenu, contains('native_menu_selection_activated_cb')); + expect(nativeMenu, isNot(contains('gtk_button_new()'))); + expect(nativeMenu, isNot(contains('gtk_radio_button_new('))); + expect(nativeMenu, isNot(contains('gtk_toggle_button_new()'))); + expect(nativeMenu, isNot(contains('gtk_drawing_area_new()'))); + expect(nativeMenu, isNot(contains('gtk_cell_renderer_render('))); + expect(nativeMenu, isNot(contains('native_menu_item_clicked_cb'))); + expect(nativeMenu, isNot(contains('"object-select-symbolic"'))); + expect(nativeMenu, isNot(contains('"radio-symbolic"'))); + expect(nativeMenu, isNot(contains('"radio-checked-symbolic"'))); + expect( + nativeMenu, + contains( + 'gtk_menu_shell_select_first(GTK_MENU_SHELL(session->menu), TRUE)', + ), + ); + expect( + nativeMenu, + contains('gtk_menu_shell_deselect(GTK_MENU_SHELL(session->menu))'), + ); + expect(nativeMenu, isNot(contains('gtk_overlay_add_overlay('))); + expect(nativeMenu, isNot(contains('gtk_fixed_move('))); + expect(nativeMenu, isNot(contains('gtk_menu_button_new()'))); + expect(nativeMenu, isNot(contains('gtk_menu_button_set_menu_model('))); + expect(nativeMenu, isNot(contains('gtk_menu_button_get_popup('))); + expect(nativeMenu, isNot(contains('gtk_popover_new_from_model('))); + expect(nativeMenu, isNot(contains('gtk_menu_button_set_popover('))); + expect(nativeMenu, isNot(contains('gtk_menu_button_get_popover('))); + expect(nativeMenu, isNot(contains('gtk_popover_'))); + expect(nativeMenu, isNot(contains('GTK_IS_MODEL_BUTTON'))); + expect(nativeMenu, isNot(contains('style_native_popover'))); + expect(nativeMenu, isNot(contains('add_model_button_presentation'))); + expect(nativeMenu, isNot(contains('ensure_native_menu_hover_tracking'))); + expect(nativeMenu, isNot(contains('GTK_STATE_FLAG_PRELIGHT'))); + expect(nativeMenu, isNot(contains('gtk_widget_set_state_flags'))); + expect(nativeMenu, isNot(contains('gdk_display_flush'))); + expect(nativeMenu, isNot(contains('wl_display_'))); + expect(nativeMenu, contains('g_object_ref(G_OBJECT(method_call))')); + expect(nativeMenu, isNot(contains('gtk_popover_bind_model('))); + expect( + dispose, + contains('gtk_menu_shell_deactivate(GTK_MENU_SHELL(session->menu))'), + ); + expect(dispose, contains('gtk_menu_detach(GTK_MENU(session->menu))')); + expect(dispose, contains('kNativeMenuActionNamespace, nullptr')); + expect(dispose, isNot(contains('gtk_widget_destroy(session->menu)'))); + expect(dispose, contains('g_clear_object(&session->menu)')); + final deactivateIndex = dispose.indexOf('gtk_menu_shell_deactivate('); + final detachIndex = dispose.indexOf('gtk_menu_detach('); + final respondIndex = dispose.indexOf('native_menu_session_respond('); + final freeIndex = dispose.indexOf('g_free(session)'); + expect(deactivateIndex, isNonNegative); + expect(detachIndex, isNonNegative); + expect(respondIndex, isNonNegative); + expect(freeIndex, isNonNegative); + expect(deactivateIndex, lessThan(detachIndex)); + expect(detachIndex, lessThan(respondIndex)); + expect(respondIndex, lessThan(freeIndex)); + expect( + 'native_menu_session_respond('.allMatches(nativeMenu), + hasLength(2), + ); + expect( + nativeMenu, + contains('g_idle_add_full(\n G_PRIORITY_DEFAULT_IDLE'), + ); + expect(nativeMenu, isNot(contains('"unmap"'))); + expect(nativeMenu, isNot(contains('gtk_dialog_run('))); + expect(nativeMenu, isNot(contains('gtk_menu_new('))); + expect(nativeMenu, isNot(contains('gtk_widget_override'))); + expect( + service, + contains("const nativeMenuChannelName = 'busymax/native_menus'"), + ); + expect(service, contains("invokeMethod('show'")); + expect(service, contains("invokeMethod('dismiss'")); + expect(service, contains('on MissingPluginException')); + expect(service, contains('on PlatformException')); + }); + + test('confirmations use the shared app dialog surface', () { + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final dialogs = File( + 'lib/src/app/busymax_dialogs.dart', + ).readAsStringSync(); + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final confirmStart = design.indexOf('class BusyMaxConfirmDialog'); + final confirmBody = design.substring(confirmStart); + + expect(runner, contains('"busymax/native_dialogs"')); + expect(runner, isNot(contains('gtk_message_dialog_new('))); + expect(runner, isNot(contains('handle_native_confirmation'))); + expect(runner, isNot(contains('strcmp(method, "confirm")'))); + expect(runner, contains('register_native_dialogs(self, view, window)')); + expect(runner, isNot(contains('register_native_dialogs_for_subwindow'))); + expect(runner, contains('g_object_add_weak_pointer')); + expect(runner, contains('native_dialog_handler_data_free')); + expect( + runner, + isNot(contains('native_dialog_method_call_cb, g_object_ref(window)')), + ); + expect(dialogs, isNot(contains('NativeDialogService'))); + expect(confirmBody, contains('return BusyMaxDialogShell(')); + expect(confirmBody, isNot(contains('return AlertDialog('))); + }); + + test('timezone selection uses native GTK and Handy controls on Linux', () { + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final service = File( + 'lib/src/platform/native_dialog_service.dart', + ).readAsStringSync(); + final selector = File( + 'lib/src/features/tasks/presentation/time_zone_selection_dialog.dart', + ).readAsStringSync(); + + expect(runner, contains('strcmp(method, "selectTimeZone") == 0')); + expect(runner, contains('gtk_search_entry_new()')); + expect(runner, contains('hdy_window_new()')); + expect(runner, contains('hdy_header_bar_new()')); + expect(runner, contains('hdy_preferences_group_new()')); + expect(runner, contains('hdy_action_row_new()')); + expect( + runner, + contains( + 'hdy_header_bar_set_decoration_layout(HDY_HEADER_BAR(header_bar), ' + '":close")', + ), + ); + final selectorStart = runner.indexOf( + 'static void handle_native_time_zone_selection', + ); + final selectorEnd = runner.indexOf( + 'struct NativeDialogHandlerData', + selectorStart, + ); + expect(selectorStart, isNonNegative); + expect(selectorEnd, greaterThan(selectorStart)); + final nativeSelector = runner.substring(selectorStart, selectorEnd); + expect(nativeSelector, isNot(contains('gtk_dialog_new_with_buttons('))); + expect(nativeSelector, isNot(contains('gtk_dialog_run('))); + expect(nativeSelector, contains('g_main_loop_run(loop)')); + expect( + nativeSelector, + contains( + 'gtk_window_present_with_time(GTK_WINDOW(window), GDK_CURRENT_TIME)', + ), + ); + expect( + nativeSelector, + contains('gtk_window_set_skip_taskbar_hint(GTK_WINDOW(window), TRUE)'), + ); + expect( + nativeSelector, + contains('gtk_window_set_skip_pager_hint(GTK_WINDOW(window), TRUE)'), + ); + expect( + nativeSelector, + isNot(contains('gtk_window_set_application(GTK_WINDOW(window)')), + ); + expect( + nativeSelector, + contains( + 'G_CALLBACK(native_time_zone_parent_is_active_notify_cb), window', + ), + ); + expect( + nativeSelector, + contains( + 'application->header_focus_transient_window = GTK_WINDOW(window)', + ), + ); + expect( + nativeSelector, + contains( + 'window, "notify::is-active",\n' + ' G_CALLBACK(header_focus_window_is_active_notify_cb), ' + 'application', + ), + ); + expect( + runner, + contains('static void native_time_zone_parent_is_active_notify_cb('), + ); + final headerFocusStart = runner.indexOf( + 'static gboolean refresh_header_bar_focus_state_cb(', + ); + final headerFocusEnd = runner.indexOf( + 'static void set_header_bar_modal_barrier_visible(', + headerFocusStart, + ); + expect(headerFocusStart, isNonNegative); + expect(headerFocusEnd, greaterThan(headerFocusStart)); + final headerFocus = runner.substring(headerFocusStart, headerFocusEnd); + expect( + headerFocus, + isNot(contains('self->header_bar_modal_barrier_visible ||')), + ); + expect(headerFocus, contains('gtk_window_is_active(self->main_window)')); + expect( + headerFocus, + contains('gtk_window_is_active(self->header_focus_transient_window)'), + ); + expect(headerFocus, contains('kHeaderApplicationActiveStyleClass')); + expect(headerFocus, contains('kHeaderApplicationBackdropStyleClass')); + expect(headerFocus, contains('g_idle_add_full(')); + expect(headerFocus, contains('gtk_widget_reset_style(')); + final activationCallbackStart = runner.indexOf( + 'static void native_time_zone_parent_is_active_notify_cb(', + ); + final activationCallbackEnd = runner.indexOf( + 'static void rebuild_native_time_zone_results(', + activationCallbackStart, + ); + final activationCallback = runner.substring( + activationCallbackStart, + activationCallbackEnd, + ); + expect(activationCallback, contains('g_idle_add_full(')); + expect( + activationCallback, + contains('native_time_zone_present_after_parent_activation_cb'), ); - final barrierBackgroundEnd = source.indexOf( - '".busymax-titlebar button.busymax-header-button,"', - barrierBackgroundStart, + expect( + activationCallback, + isNot(contains('gtk_window_present_with_time(')), + ); + final deferredActivationStart = runner.indexOf( + 'static gboolean ' + 'native_time_zone_present_after_parent_activation_cb(', + ); + final deferredActivationEnd = activationCallbackStart; + final deferredActivation = runner.substring( + deferredActivationStart, + deferredActivationEnd, + ); + expect(deferredActivation, contains('!gtk_window_is_active(parent)')); + expect(deferredActivation, contains('gtk_window_is_active(window)')); + expect( + deferredActivation, + contains('gtk_window_present_with_time(window, GDK_CURRENT_TIME)'), + ); + expect(runner, contains('kNativeTimeZoneDialogContentHeight')); + expect(runner, contains('kNativeTimeZoneDialogStyleClass')); + expect(runner, contains('kNativeTimeZoneGroupStyleClass')); + expect(runner, contains('kNativeTimeZoneRowStyleClass')); + expect(runner, contains('native_time_zone_option_match_rank')); + expect(runner, contains('compare_native_time_zone_options')); + expect(runner, contains('g_ptr_array_sort_with_data')); + expect(runner, contains('parse_native_grouped_list_style')); + expect(runner, contains('create_native_grouped_list_provider')); + expect( + runner, + contains('gtk_scrolled_window_set_propagate_natural_width'), + ); + expect(runner, contains('gtk_scrolled_window_set_max_content_width')); + expect( + runner, + contains('hdy_action_row_set_title_lines(HDY_ACTION_ROW(row), 1)'), + ); + expect( + runner, + contains('hdy_action_row_set_subtitle_lines(HDY_ACTION_ROW(row), 1)'), + ); + expect( + nativeSelector, + contains( + 'gtk_widget_set_margin_start(\n' + ' results, grouped_list_style.section_horizontal_padding)', + ), + ); + expect( + nativeSelector, + contains( + 'gtk_widget_set_margin_end(\n' + ' results, grouped_list_style.section_horizontal_padding)', + ), + ); + expect(service, contains("invokeMethod('selectTimeZone'")); + expect(service, contains('class NativeGroupedListStyle')); + expect( + service, + contains("'groupedListStyle': groupedListStyle.toMessage()"), + ); + expect(selector, contains('NativeDialogService().selectTimeZone(')); + expect(selector, contains('BusyMaxGroupedList(')); + expect(selector, contains('parentRole: BusyMaxSurfaceRole.dialog')); + expect(selector, contains('dividerColor: surfaceColors.cardShade')); + expect(selector, contains('busyMaxRowHoverColor(context)')); + expect(selector, contains('radius: BusyMaxRadius.md.round()')); + expect( + selector, + contains('sectionTopSpacing: BusyMaxSpacing.lg.round()'), + ); + expect( + selector, + contains('sectionHorizontalPadding: BusyMaxSpacing.xs.round()'), ); - final barrierStart = source.indexOf( - '".busymax-titlebar.busymax-modal-barrier label,"', + expect( + selector, + contains('titleBottomSpacing: BusyMaxSpacing.sm.round()'), ); - final barrierEnd = source.indexOf( - '".busymax-titlebar button.busymax-header-icon-button {"', - barrierStart, + }); + + test('application activation presents only the application window', () { + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final restoreStart = runner.indexOf('static void restore_main_window'); + final restoreEnd = runner.indexOf( + 'static void window_method_call_cb', + restoreStart, + ); + + expect(restoreStart, isNonNegative); + expect(restoreEnd, greaterThan(restoreStart)); + + final restore = runner.substring(restoreStart, restoreEnd); + expect( + restore, + contains( + 'gtk_window_present_with_time(self->main_window, GDK_CURRENT_TIME)', + ), + ); + expect(restore, isNot(contains('gtk_application_get_windows'))); + expect(restore, isNot(contains('gtk_window_present(self->main_window)'))); + }); + + test( + 'text prompts reuse the shared Yaru grouped form without native reinvention', + () { + final runner = File( + 'linux/runner/my_application.cc', + ).readAsStringSync(); + final service = File( + 'lib/src/platform/native_dialog_service.dart', + ).readAsStringSync(); + final dialogs = File( + 'lib/src/app/busymax_dialogs.dart', + ).readAsStringSync(); + final design = File( + 'lib/src/app/busymax_design.dart', + ).readAsStringSync(); + final nativeDialogsStart = runner.indexOf('static void respond_bool('); + final nativeDialogsEnd = runner.indexOf( + 'struct NativeTimeZoneOption', + nativeDialogsStart, + ); + final promptStart = design.indexOf('class BusyMaxPromptDialog'); + final promptEnd = design.indexOf( + 'class BusyMaxConfirmDialog', + promptStart, + ); + + expect(nativeDialogsStart, isNonNegative); + expect(nativeDialogsEnd, greaterThan(nativeDialogsStart)); + final nativeDialogs = runner.substring( + nativeDialogsStart, + nativeDialogsEnd, + ); + expect(nativeDialogs, isNot(contains('handle_native_prompt'))); + expect(nativeDialogs, isNot(contains('respond_native_prompt'))); + expect(nativeDialogs, isNot(contains('gtk_entry_new()'))); + expect(nativeDialogs, isNot(contains('gtk_dialog_new_with_buttons('))); + expect(nativeDialogs, isNot(contains('gtk_message_dialog_new('))); + expect(runner, isNot(contains('strcmp(method, "confirm")'))); + expect(runner, isNot(contains('strcmp(method, "prompt")'))); + + expect(service, isNot(contains('NativeTextPromptResult'))); + expect(service, isNot(contains('Future'))); + expect(service, isNot(contains('invokeMapMethod'))); + expect(service, isNot(contains("'prompt'"))); + expect(service, contains('on MissingPluginException')); + expect(service, contains('on PlatformException')); + expect(dialogs, isNot(contains('nativeDialogService.prompt('))); + expect(dialogs, contains('showBusyMaxModalDialog(')); + expect(dialogs, contains('BusyMaxPromptDialog(')); + + expect(promptStart, isNonNegative); + expect(promptEnd, greaterThan(promptStart)); + final prompt = design.substring(promptStart, promptEnd); + expect(prompt, contains('BusyMaxDialogShell(')); + expect(prompt, contains('header: BusyMaxEditorHeader(')); + expect(prompt, isNot(contains('actions: ['))); + expect(prompt, isNot(contains('BusyMaxDialogTitleBar('))); + expect(prompt, contains('BusyMaxGroupedList(')); + expect(prompt, contains('filled: true')); + expect(prompt, contains('YaruListTile.square(')); + expect(prompt, contains('busyMaxGroupedTextFieldDecoration(')); + expect(prompt, contains('TextEditingController(')); + expect(prompt, contains('_canSubmit ? _submit : null')); + expect(prompt, contains('onFieldSubmitted: (_) => _submit()')); + expect( + design, + contains('header ?? BusyMaxDialogTitleBar(title: Text(title))'), + ); + expect(prompt, isNot(contains('maxWidth:'))); + expect(prompt, isNot(contains('InputDecoration('))); + expect(prompt, isNot(contains('AlertDialog('))); + }, + ); + + test('modal editors use the semantic window role with themed geometry', () { + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final start = design.indexOf('class BusyMaxModalEditorSurface'); + final end = design.indexOf('class BusyMaxInlineBadge', start); + expect(start, isNonNegative); + expect(end, greaterThan(start)); + final surface = design.substring(start, end); + + expect(surface, contains('return BusyMaxSurfaceScope(')); + expect(surface, contains('role: BusyMaxSurfaceRole.window')); + expect(surface, contains('child: Dialog(')); + expect(surface, contains('Theme.of(context).scaffoldBackgroundColor')); + expect(surface, contains('backgroundColor: editorSurface')); + expect(surface, contains('surfaceTintColor: editorSurface')); + expect(surface, isNot(contains('floatingBorder'))); + expect(surface, isNot(contains('BorderSide('))); + expect(surface, isNot(contains('BusyMaxElevation'))); + expect(surface, isNot(contains('Color(0x'))); + }); + + test('dialog shells preserve the shared themed shape and perimeter', () { + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final start = design.indexOf('class BusyMaxDialogShell'); + final end = design.indexOf('class BusyMaxConfirmDialog', start); + expect(start, isNonNegative); + expect(end, greaterThan(start)); + final shell = design.substring(start, end); + + expect(shell, contains('child: Dialog(')); + expect(shell, contains('clipBehavior: Clip.antiAlias')); + expect(shell, isNot(contains('shape: RoundedRectangleBorder('))); + expect(shell, isNot(contains('ClipRRect('))); + expect(shell, isNot(contains('BorderSide('))); + }); + + test('native headerbar CSS uses scoped semantic surfaces and states', () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + final refreshHeaderCssStart = source.indexOf( + 'static void refresh_header_bar_css', + ); + final headerCssStart = source.indexOf( + 'g_autofree gchar* css = g_strdup_printf(', + refreshHeaderCssStart, + ); + final headerCssEnd = source.indexOf( + 'g_autoptr(GError) error = nullptr;', + headerCssStart, + ); + expect(headerCssStart, isNonNegative); + expect(headerCssEnd, isNonNegative); + final headerCss = source.substring(headerCssStart, headerCssEnd); + final nativeDialogCssStart = source.indexOf( + 'g_autofree gchar* native_dialog_css =', + ); + final nativeDialogCssEnd = source.indexOf( + 'g_autofree gchar* native_time_zone_dialog_css =', + nativeDialogCssStart, + ); + final nativeTimeZoneDialogCssStart = nativeDialogCssEnd; + final nativeTimeZoneDialogCssEnd = source.indexOf( + 'g_autofree gchar* modal_barrier_color', + nativeTimeZoneDialogCssStart, + ); + expect(nativeDialogCssStart, isNonNegative); + expect(nativeDialogCssEnd, isNonNegative); + expect(nativeTimeZoneDialogCssStart, isNonNegative); + expect(nativeTimeZoneDialogCssEnd, isNonNegative); + final nativeDialogCss = source.substring( + nativeDialogCssStart, + nativeDialogCssEnd, + ); + final nativeTimeZoneDialogCss = source.substring( + nativeTimeZoneDialogCssStart, + nativeTimeZoneDialogCssEnd, + ); + final nativeGroupedListCssStart = source.indexOf( + 'static GtkCssProvider* create_native_grouped_list_provider', + ); + final nativeGroupedListCssEnd = source.indexOf( + 'static void handle_native_time_zone_selection', + nativeGroupedListCssStart, + ); + expect(nativeGroupedListCssStart, isNonNegative); + expect(nativeGroupedListCssEnd, greaterThan(nativeGroupedListCssStart)); + final nativeGroupedListCss = source.substring( + nativeGroupedListCssStart, + nativeGroupedListCssEnd, + ); + final nativeSearchGeometryCssStart = source.indexOf( + 'g_autofree gchar* native_search_geometry_css =', + ); + final nativeSearchGeometryCssEnd = source.indexOf( + 'const gchar* tooltip_background =', + nativeSearchGeometryCssStart, + ); + expect(nativeSearchGeometryCssStart, isNonNegative); + expect( + nativeSearchGeometryCssEnd, + greaterThan(nativeSearchGeometryCssStart), + ); + final nativeSearchGeometryCss = source.substring( + nativeSearchGeometryCssStart, + nativeSearchGeometryCssEnd, + ); + final tooltipCssStart = nativeSearchGeometryCssEnd; + final tooltipCssEnd = source.indexOf( + 'g_autofree gchar* header_focus_css =', + tooltipCssStart, + ); + expect(tooltipCssStart, isNonNegative); + expect(tooltipCssEnd, greaterThan(tooltipCssStart)); + final tooltipCss = source.substring(tooltipCssStart, tooltipCssEnd); + final yaruDecorationCssStart = source.indexOf( + 'g_autofree gchar* yaru_window_decoration_css =', + ); + final yaruDecorationCssEnd = source.indexOf( + 'GtkWidget* header_bar =', + yaruDecorationCssStart, + ); + expect(yaruDecorationCssStart, isNonNegative); + expect(yaruDecorationCssEnd, isNonNegative); + final yaruDecorationCss = source.substring( + yaruDecorationCssStart, + yaruDecorationCssEnd, ); expect(source, contains('"busymax-header-title"')); expect(source, contains('".busymax-titlebar .busymax-header-title {"')); - expect(source, contains('"border: 1px solid %s;"')); - expect(source, contains('"box-shadow: 0 6px 18px %s;"')); - expect(source, contains('muted_foreground_color')); + expect(headerCss, contains('"window#busymax-window,"')); + expect( + headerCss, + contains('".busymax-titlebar .busymax-header-brand {"'), + ); + expect(headerCss, contains('"border-right: 1px solid %s;"')); + expect(headerCss, isNot(contains('"border-right-color: %s;"'))); + expect( + headerCss, + contains( + '".busymax-titlebar.%s "\n' + ' ".busymax-header-control,"', + ), + ); + expect( + headerCss, + contains( + '".busymax-titlebar .%s,"\n' + ' ".busymax-titlebar .%s:backdrop {"', + ), + ); + expect(headerCss, contains('"color: alpha(%s, %.2f);"')); + expect(source, contains('kHeaderBackdropForegroundOpacity = 0.50')); + expect(source, contains('kHeaderDisabledForegroundOpacity = 0.38')); expect( source, - contains('kDefaultHeaderBarBackgroundColor[] = "#1D1D20"'), + contains( + 'kHeaderDisabledBackdropForegroundOpacity =\n' + ' kHeaderDisabledForegroundOpacity * ' + 'kHeaderBackdropForegroundOpacity', + ), ); + expect( + headerCss, + contains('".busymax-header-control:disabled:backdrop,"'), + ); + expect( + headerCss, + contains('"headerbar button.titlebutton:disabled:backdrop {"'), + ); + expect(source, contains('modal_barrier_color_for_depth(')); + expect(source, contains('header_bar_modal_barrier_shade_depth')); + expect(source, contains('self->header_bar_modal_barrier_color')); + expect(source, contains('kDefaultModalBarrierColor')); + expect(headerCss, isNot(contains('linear-gradient(%s, %s)'))); + expect(headerCss, isNot(contains('".busymax-titlebar,"'))); + expect(source, contains('kDefaultWindowBackgroundColor[] = "#2C2C2C"')); expect( source, - contains('kDefaultHeaderBarSidebarBackgroundColor[] = "#2E2E32"'), + contains('kDefaultHeaderBarBackgroundColor[] = "#272727"'), + ); + expect( + source, + contains('kDefaultHeaderBarSidebarBackgroundColor[] = "#393939"'), ); expect(source, contains('set_flutter_view_background_color')); expect(source, contains('header_bar_window_background_color')); @@ -834,28 +1845,288 @@ void main() { source, isNot(contains('gdk_rgba_parse(&background_color, "#00000000")')), ); - expect(barrierBackgroundStart, isNonNegative); - expect(barrierBackgroundEnd, isNonNegative); - final barrierBackgroundCss = source.substring( - barrierBackgroundStart, - barrierBackgroundEnd, + expect(source, contains('"backgroundColor"')); + expect(source, contains('"sidebarBackgroundColor"')); + expect(source, contains('"sidebarBorderColor"')); + expect(source, contains('"foregroundColor"')); + expect(source, isNot(contains('"popoverBackgroundColor"'))); + expect(source, isNot(contains('"menuHoverColor"'))); + expect(source, isNot(contains('"popoverShadowColor"'))); + expect(source, contains('"dialogBackgroundColor"')); + expect(source, isNot(contains('"floatingBorderColor"'))); + expect(source, contains('"highContrast"')); + expect(source, isNot(contains('"shadeColor"'))); + expect(source, contains('"modalBarrierColor"')); + expect( + source, + isNot(contains('fl_lookup_string_arg(args, "controlColor")')), + ); + expect( + source, + isNot(contains('fl_lookup_string_arg(args, "controlHoverColor")')), + ); + expect( + source, + isNot(contains('fl_lookup_string_arg(args, "controlActiveColor")')), + ); + expect( + source, + isNot(contains('fl_lookup_string_arg(args, "popoverBackgroundColor")')), + ); + expect( + source, + isNot(contains('fl_lookup_string_arg(args, "menuHoverColor")')), ); - expect(barrierBackgroundCss, contains('"background-color: %s;"')); expect( - barrierBackgroundCss, - contains('"headerbar.busymax-flat-headerbar:backdrop {"'), + source, + isNot(contains('fl_lookup_string_arg(args, "popoverShadowColor")')), + ); + expect( + source, + contains('fl_lookup_string_arg(args, "dialogBackgroundColor")'), + ); + expect( + source, + contains('fl_lookup_string_arg(args, "dialogOutlineColor")'), + ); + expect( + source, + isNot(contains('fl_lookup_string_arg(args, "floatingBorderColor")')), ); expect( source, + contains('fl_lookup_optional_bool_arg(args, "highContrast"'), + ); + expect(source, contains('"background-color: %s;"')); + expect(source, isNot(contains('native_popover_css'))); + expect(source, isNot(contains('kNativePopoverStyleClass'))); + expect(source, isNot(contains('kHeaderMenuDepthStyleClass'))); + expect(source, isNot(contains('modelbutton:hover'))); + expect( + nativeSearchGeometryCss, + contains('use_legacy_yaru_compatibility'), + ); + expect(nativeSearchGeometryCss, contains('"entry.search.%s {"')); + expect(nativeSearchGeometryCss, contains('"border-radius: 9px;"')); + expect(nativeSearchGeometryCss, contains('kHeaderSearchEntryStyleClass')); + expect(nativeSearchGeometryCss, isNot(contains('background'))); + expect(nativeSearchGeometryCss, isNot(contains('border-color'))); + expect(nativeSearchGeometryCss, isNot(contains('"border:'))); + expect(nativeSearchGeometryCss, isNot(contains('box-shadow'))); + expect(nativeSearchGeometryCss, isNot(contains('padding'))); + expect(nativeSearchGeometryCss, isNot(contains('min-height'))); + expect(nativeSearchGeometryCss, isNot(contains('#'))); + expect(nativeSearchGeometryCss, isNot(contains('rgba('))); + expect(source, isNot(contains('kNativeMenuContentPadding'))); + expect( + source, + isNot(contains('g_autofree gchar* native_menu_geometry_css')), + ); + expect( + source, + isNot(contains('g_autofree gchar* native_menu_state_css')), + ); + expect( + source, + isNot(contains('g_autofree gchar* header_menu_shadow_css')), + ); + expect(source, isNot(contains('kNativeMenuRadioLtrStyleClass'))); + expect(source, isNot(contains('kNativeMenuRadioRtlStyleClass'))); + expect(tooltipCss, contains('"tooltip.background {"')); + expect(tooltipCss, contains('"tooltip decoration,"')); + expect(tooltipCss, contains('"tooltip.csd decoration {"')); + expect(tooltipCss, contains('"tooltip box,"')); + expect(tooltipCss, contains('"tooltip.background box {"')); + expect(tooltipCss, contains('"tooltip label,"')); + expect(tooltipCss, contains('"tooltip.background label {"')); + expect(tooltipCss, contains('"min-width: 0;"')); + expect(tooltipCss, contains('tooltip_label_horizontal_padding')); + expect(tooltipCss, contains('tooltip_label_vertical_padding')); + expect(tooltipCss, contains('tooltip_label_minimum_height')); + expect(tooltipCss, contains('"padding: %.2fpx %.2fpx;"')); + expect( + '"padding: %.2fpx %.2fpx;"'.allMatches(tooltipCss).length, + 1, + reason: 'Only the label contributes tooltip content padding', + ); + expect(tooltipCss, contains('"font-size: %.2fpx;"')); + expect( + '"border-radius: %.2fpx;"'.allMatches(tooltipCss).length, + 2, + reason: 'Only the painted tooltip and native window clip are rounded', + ); + expect(source, contains('"busymax-native-dialog"')); + expect(source, contains('style_native_dialog(GtkWidget* dialog)')); + expect( + 'style_native_dialog(dialog);'.allMatches(source).length, + 2, + reason: 'native date and time pickers share dialog styling', + ); + expect(source, contains('style_native_dialog(window);')); + expect( + nativeDialogCss, + contains('g_autofree gchar* native_dialog_css ='), + ); + expect( + nativeDialogCss, contains( - 'sidebar_background_color, modal_barrier_color, modal_barrier_color', + '".%s headerbar,"\n' + ' ".%s headerbar:backdrop {"', ), ); - expect(barrierStart, isNonNegative); - expect(barrierEnd, isNonNegative); - final barrierCss = source.substring(barrierStart, barrierEnd); - expect(barrierCss, contains('"color: %s;"')); - expect(barrierCss, isNot(contains('rgba(255,255,255,0.38)'))); + expect( + 'dialog_background_color'.allMatches(nativeDialogCss).length, + 3, + reason: 'legacy native pickers share one dialog surface token', + ); + expect(nativeDialogCss, isNot(contains('window_background_color'))); + expect(nativeDialogCss, contains('"border-bottom-width: 0;"')); + expect(nativeDialogCss, contains('"border-bottom-style: none;"')); + expect(nativeDialogCss, contains('"border-bottom-color: transparent;"')); + expect(nativeDialogCss, contains('"box-shadow: inset 0 0 0 1px %s;"')); + expect( + yaruDecorationCss, + contains( + '"messagedialog.%s.csd:not(.solid-csd):"\n' + ' "not(.maximized):not(.fullscreen) > decoration {"', + ), + ); + expect(source, contains('current_gtk_theme_uses_legacy_yaru_shadow()')); + expect(yaruDecorationCss, contains('use_legacy_yaru_compatibility')); + expect(source, contains('!self->header_bar_high_contrast')); + expect( + yaruDecorationCss, + contains('"box-shadow: 0 0 14px 2px rgba(0,0,6,0.03),"'), + ); + expect(yaruDecorationCss, contains('"0 0 5px 2px rgba(0,0,6,0.10),"')); + expect(yaruDecorationCss, contains('"0 0 0 1px rgba(0,0,0,0.05);"')); + expect(yaruDecorationCss, contains('kNativeDialogStyleClass')); + expect(yaruDecorationCss, isNot(contains('"border:'))); + expect( + nativeDialogCss, + contains( + 'kNativeDialogStyleClass, kNativeDialogStyleClass,\n' + ' dialog_background_color', + ), + ); + expect(nativeDialogCss, isNot(contains('"border:'))); + expect('border-radius: %dpx;'.allMatches(nativeDialogCss).length, 1); + expect( + nativeTimeZoneDialogCss, + contains('g_autofree gchar* native_time_zone_dialog_css ='), + ); + expect( + nativeTimeZoneDialogCss, + contains('kNativeTimeZoneDialogStyleClass'), + ); + expect( + nativeTimeZoneDialogCss, + contains( + '"window.%s.%s.csd:not(.solid-csd):not(.maximized):' + 'not(.fullscreen),"', + ), + ); + expect( + nativeTimeZoneDialogCss, + contains('"not(.fullscreen) > decoration:backdrop,"'), + ); + expect( + nativeTimeZoneDialogCss, + contains('"not(.fullscreen) > decoration-overlay:backdrop {"'), + ); + expect( + nativeTimeZoneDialogCss, + contains('"border-radius: 0 0 %dpx %dpx;"'), + ); + expect(nativeTimeZoneDialogCss, contains('"box-shadow: none;"')); + expect('"border: none;"'.allMatches(nativeTimeZoneDialogCss).length, 2); + expect(nativeTimeZoneDialogCss, isNot(contains('shade('))); + expect( + nativeTimeZoneDialogCss, + isNot(contains('kNativeTimeZoneGroupStyleClass')), + ); + expect( + nativeTimeZoneDialogCss, + isNot(contains('kNativeTimeZoneRowStyleClass')), + ); + expect(nativeGroupedListCss, contains('style->surface_color')); + expect(nativeGroupedListCss, contains('style->divider_color')); + expect(nativeGroupedListCss, contains('style->section_header_color')); + expect(nativeGroupedListCss, contains('style->primary_text_color')); + expect(nativeGroupedListCss, contains('style->secondary_text_color')); + expect(nativeGroupedListCss, contains('style->hover_color')); + expect(nativeGroupedListCss, contains('style->shadow_color')); + expect(nativeGroupedListCss, contains('style->outline_color')); + expect(nativeGroupedListCss, contains('style->section_top_spacing')); + expect( + nativeGroupedListCss, + isNot(contains('style->section_horizontal_padding')), + ); + expect(nativeGroupedListCss, contains('style->title_bottom_spacing')); + expect(nativeGroupedListCss, contains('"window.%s row.%s,"')); + expect( + nativeGroupedListCss, + contains('"window.%s row.%s:hover:not(:disabled) {"'), + ); + expect(nativeGroupedListCss, isNot(contains('"window.%s .%s row,"'))); + expect(source, isNot(contains('kNativeTimeZonePrimaryTextOpacity'))); + expect(source, isNot(contains('kNativeTimeZoneSecondaryTextOpacity'))); + expect( + source, + contains('constexpr gint kNativeDialogCornerRadius = 14;'), + ); + expect(source, isNot(contains('"busymax-native-dialog-cancel"'))); + expect(source, isNot(contains('"busymax-native-dialog-destructive"'))); + expect(source, isNot(contains('"busymax-native-dialog-actions"'))); + expect(source, isNot(contains('style_native_popover'))); + expect(source, isNot(contains('activate_native_menu_host('))); + expect( + source, + contains( + 'gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), FALSE)', + ), + ); + expect(source, contains('gtk_menu_button_get_popup')); + expect(source, contains('GTK_IS_MENU(menu)')); + expect(source, isNot(contains('gtk_popover_'))); + expect(headerCss, contains('headerbar button.titlebutton')); + expect(source, contains('kHeaderControlStyleClass')); + expect(source, isNot(contains('kHeaderMenuControlStyleClass'))); + expect(source, contains('style_header_control(button)')); + expect( + source, + contains('style_header_control(self->settings_menu_button)'), + ); + expect(source, contains('style_header_control(self->view_mode_button)')); + expect(source, contains('style_header_control(self->create_button)')); + expect( + headerCss, + contains( + '".busymax-header-control:not(.suggested-action):not(:disabled) {"', + ), + ); + expect(headerCss, isNot(contains('busymax-header-menu-control'))); + expect(headerCss, contains('"background-color: transparent;"')); + expect(headerCss, contains('"background-image: none;"')); + expect(headerCss, contains('alpha(currentColor, 0.07)')); + expect(headerCss, contains('alpha(currentColor, 0.16)')); + expect(headerCss, contains('alpha(currentColor, 0.10)')); + expect(headerCss, contains('alpha(currentColor, 0.13)')); + expect(headerCss, contains('alpha(currentColor, 0.19)')); + expect(headerCss, isNot(contains('alpha(currentColor, 0.15)'))); + expect(headerCss, isNot(contains('alpha(currentColor, 0.30)'))); + expect(headerCss, isNot(contains('#151515'))); + expect(headerCss, isNot(contains('popover.busymax'))); + expect(headerCss, contains('tooltip_css')); + expect(headerCss, contains(':hover')); + expect(headerCss, contains(':active')); + expect(headerCss, contains(':checked')); + expect(headerCss, isNot(contains(':focus'))); + expect(headerCss, contains(':disabled')); + expect(headerCss, isNot(contains('box-shadow: inset'))); + expect(headerCss, isNot(contains('transition: none'))); + expect(headerCss, isNot(contains('text-shadow: none'))); + expect(headerCss, isNot(contains('-gtk-icon-shadow: none'))); }); test('native GTK theme sampling does not export fake disabled colors', () { @@ -911,9 +2182,7 @@ void main() { 'lib/src/platform/gtk_font_service.dart', ).readAsStringSync(); final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); - final compactApp = File( - 'lib/src/features/schedule/presentation/compact_agenda_app.dart', - ).readAsStringSync(); + final main = File('lib/main.dart').readAsStringSync(); expect(source, contains('kGtkThemeColorsEventChannel')); expect(source, contains('io.busystack.busymax/gtk_theme_colors')); @@ -926,49 +2195,102 @@ void main() { expect(source, contains('notify::gtk-theme-name')); expect(source, contains('notify::gtk-application-prefer-dark-theme')); expect(source, contains('send_gtk_theme_colors_event')); + expect(source, contains('connect_gtk_theme_colors_signals')); expect(source, contains('disconnect_gtk_theme_colors_signals')); + final notifyStart = source.indexOf( + 'static void gtk_theme_colors_notify_cb(', + ); + final listenStart = source.indexOf( + 'static FlMethodErrorResponse* gtk_theme_colors_listen_cb(', + ); + final cancelStart = source.indexOf( + 'static FlMethodErrorResponse* gtk_theme_colors_cancel_cb(', + ); + final registerStart = source.indexOf( + 'static void register_gtk_settings_channel(', + ); + final startupStart = source.indexOf( + 'static void my_application_startup(GApplication* application)', + ); + final shutdownStart = source.indexOf( + 'static void my_application_shutdown(GApplication* application)', + ); + expect(notifyStart, isNonNegative); + expect(listenStart, greaterThan(notifyStart)); + expect(cancelStart, greaterThan(listenStart)); + expect(registerStart, greaterThan(cancelStart)); + expect(startupStart, greaterThan(registerStart)); + expect(shutdownStart, greaterThan(startupStart)); + final notify = source.substring(notifyStart, listenStart); + final cancel = source.substring(cancelStart, registerStart); + final startup = source.substring(startupStart, shutdownStart); + expect(notify, contains('refresh_header_bar_css(self)')); + expect(notify, contains('send_gtk_theme_colors_event(self)')); + expect(cancel, isNot(contains('disconnect_gtk_theme_colors_signals'))); + expect(startup, contains('connect_gtk_theme_colors_signals(')); expect( source, contains('g_clear_object(&self->gtk_theme_colors_event_channel)'), ); expect(headerBarService, contains('required this.preferDark')); expect(headerBarService, contains("'preferDark': preferDark")); + expect(app, contains('preferDark: theme.brightness == Brightness.dark')); + expect(app, isNot(contains('popoverShadowColor:'))); expect( app, - contains( - 'final preferDark = Theme.of(context).brightness == Brightness.dark', - ), + isNot(contains('BusyMaxAlpha.nativeHeaderMenuShadowOpacity')), ); - expect(app, contains('preferDark: preferDark')); expect(source, contains('static void set_gtk_theme_preference')); expect( source, contains('"gtk-application-prefer-dark-theme", prefer_dark'), ); - expect(source, contains('yaru_theme_name_for_preference')); - expect(source, contains('available_gtk_theme_for_preference')); - expect(source, contains('available_icon_theme_for_preference')); - expect(source, contains('g_str_has_suffix(theme_name, "-dark")')); + expect( + source, + isNot(contains('g_object_set(settings, "gtk-theme-name"')), + ); + expect( + source, + isNot(contains('g_object_set(settings, "gtk-icon-theme-name"')), + ); + expect(source, isNot(contains('gtk_icon_theme_set_custom_theme'))); expect(source, contains('theme_selected_bg_color')); expect(source, contains('set_theme_color(result, "accent"')); - expect(gtkFontService, contains('final Color? accent;')); + expect(source, contains('set_theme_color(result, "accentForeground"')); + expect(source, contains('set_theme_color(result, "divider"')); + expect(source, contains('set_theme_color(result, "cardShade"')); + expect(source, contains('set_theme_color(result, "floatingBorder"')); expect( - gtkFontService, - contains("accent: _parseColor(value['accent'] as String?)"), + source, + contains('lookup_context_color(window_context, "card_shade_color"'), ); expect( - app, + source, + contains('lookup_context_color(window_context, "popover_border_color"'), + ); + expect( + source, contains( - 'ubuntuAccentColor ?? gtkThemeColors?.accent ?? systemColor.accent', + 'lookup_context_color(window_context, "floating_border_color"', ), ); + expect(source, isNot(contains('sample_widget_border_color('))); + expect(source, contains('gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)')); + expect(source, contains('sample_widget_background(separator')); + expect(source, isNot(contains('divider_color.alpha *='))); + expect(source, contains('GTK_STYLE_CLASS_DIM_LABEL')); + expect(source, contains('"opacity", &opacity')); + expect(source, contains('"setGtkThemePreference"')); + expect(source, contains('set_gtk_theme_preference(fl_method_bool_arg')); + expect(gtkFontService, contains('final Color? accent;')); + expect(gtkFontService, contains("accent: _parseColor(value['accent'])")); + expect(gtkFontService, contains('final Color? accentForeground;')); expect( - compactApp, + app, contains( - 'ubuntuAccentColor ?? gtkThemeColors?.accent ?? systemColor.accent', + 'gtkThemeColors?.accent ?? ubuntuAccentColor ?? systemColor.accent', ), ); - expect(source, contains('gtk_icon_theme_set_custom_theme')); expect(source, contains('fl_lookup_optional_bool_arg')); expect( source, @@ -977,6 +2299,14 @@ void main() { expect(source, contains('set_gtk_theme_preference(prefer_dark);')); expect(source, isNot(contains('prefer_dark_gtk_theme'))); expect(source, isNot(contains('set_gtk_theme_preference(TRUE)'))); + final initialThemeStart = main.indexOf( + 'await _applyInitialNativeHeaderBarTheme(', + ); + final runAppStart = main.indexOf('runApp('); + expect(initialThemeStart, isNonNegative); + expect(runAppStart, greaterThan(initialThemeStart)); + expect(main, contains('busyMaxHeaderBarThemeFor(')); + expect(main, contains('await headerBarService.setTheme(')); }); test('app code does not bypass centralized typography', () { @@ -1022,31 +2352,115 @@ void main() { expect(source, isNot(contains('return TextTheme('))); }); - test('shared menu button has no nested hover background', () { + test('shared menus prefer native GTK with one Yaru-themed fallback', () { final source = File('lib/src/app/busymax_design.dart').readAsStringSync(); - - expect(source, contains('class BusyMaxMenuButton')); - expect(source, contains('class BusyMaxMenuEntry')); - expect(source, contains('busyMaxDropdownMenuStyle')); - expect(source, contains('busyMaxDropdownMenuItemStyle')); - expect(source, contains('builder: (context, controller, child)')); - expect( - source, - contains('side: const WidgetStatePropertyAll(BorderSide.none)'), + final menuStart = source.indexOf( + 'Future?> showBusyMaxMenu', ); - expect( - source, - contains( - 'surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent)', - ), + final triggerStart = source.indexOf( + 'typedef BusyMaxMenuTriggerBuilder', + menuStart, ); - expect(source, isNot(contains('_BusyMaxPopupMenuTrigger'))); - expect(source, isNot(contains('MouseRegion('))); - expect(source, isNot(contains('AnimatedContainer('))); - expect( - source, - isNot(contains('context.findRenderObject() as RenderBox')), + final menuBody = source.substring(menuStart, triggerStart); + final fallbackStart = menuBody.indexOf( + 'Future _showBusyMaxFlutterMenu', ); + final fallbackEnd = menuBody.indexOf( + 'Widget _busyMaxFallbackMenuEntry', + fallbackStart, + ); + final fallbackBody = menuBody.substring(fallbackStart, fallbackEnd); + + expect(source, contains('class BusyMaxMenuButton')); + expect(source, contains('class BusyMaxMenuEntry')); + expect(menuBody, contains('nativeMenuService.show(')); + expect(menuBody, contains('if (nativeResult.available)')); + expect(menuBody, contains('_showBusyMaxFlutterMenu(')); + expect(fallbackBody, contains('final selection = showMenu(')); + expect(fallbackBody, contains('return await selection;')); + expect(fallbackBody, contains('session._releaseFallbackRoute();')); + expect(fallbackBody, contains('_BusyMaxPopupMenuItem(')); + expect(fallbackBody, contains('extends PopupMenuItem')); + expect(fallbackBody, contains('super.build(context)')); + expect(fallbackBody, contains('hoverColor: widget.hoverColor')); + expect(fallbackBody, contains('YaruRadio(')); + expect(fallbackBody, contains('inMutuallyExclusiveGroup: true')); + expect(fallbackBody, isNot(contains('YaruCheckedPopupMenuItem'))); + expect(fallbackBody, isNot(contains('MenuAnchor('))); + expect(fallbackBody, isNot(contains('MenuItemButton('))); + expect(fallbackBody, isNot(contains('DropdownMenu('))); + expect(fallbackBody, isNot(contains('shape:'))); + expect(fallbackBody, isNot(contains('color:'))); + expect(fallbackBody, isNot(contains('elevation:'))); + expect(fallbackBody, isNot(contains('constraints:'))); + }); + + test('form combo is a native-style row using the shared menu adapter', () { + final source = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final rowStart = source.indexOf('class BusyMaxComboRow'); + final rowEnd = source.indexOf('class BusyMaxSwitchRow'); + + expect(rowStart, isNonNegative); + expect(rowEnd, greaterThan(rowStart)); + + final rowBody = source.substring(rowStart, rowEnd); + expect(rowBody, contains('BusyMaxMenuButton(')); + expect(rowBody, contains('BusyMaxMenuEntry(')); + expect(rowBody, contains('selected: value == selected')); + expect(rowBody, contains('triggerBuilder:')); + expect(rowBody, contains('trigger.anchor(')); + expect(rowBody, contains('YaruListTile.square(')); + expect(rowBody, contains('onTap: trigger.onPressed')); + expect(rowBody, contains('focusNode: trigger.focusNode')); + expect(rowBody, contains('YaruIcons.pan_down')); + expect(rowBody, isNot(contains('YaruIcons.pan_up'))); + expect(rowBody, isNot(contains('BusyMaxPushButton.standard('))); + expect(rowBody, isNot(contains('ButtonStyleButton'))); + expect(rowBody, isNot(contains('DropdownMenu'))); + expect(rowBody, isNot(contains('DropdownMenuEntry'))); + expect(rowBody, isNot(contains('YaruPopupMenuButton'))); + expect(rowBody, isNot(contains('PopupMenuItem'))); + expect(rowBody, isNot(contains('OutlinedButton('))); + expect(rowBody, isNot(contains('BusyMaxElevation'))); + expect(rowBody, isNot(contains('opacity: 0.6'))); + }); + + test('boxed-list rows use the dedicated native card-shade role', () { + final source = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final start = source.indexOf('class _BusyMaxGroupedListSurface'); + final end = source.indexOf('typedef BusyMaxRowActivationCallback', start); + + expect(start, isNonNegative); + expect(end, greaterThan(start)); + final body = source.substring(start, end); + expect(body, contains('color: surfaceColors.cardShade')); + expect(body, isNot(contains('color: surfaceColors.divider'))); + }); + + test('time mode delegates the complete mode control to Yaru', () { + final source = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final start = source.indexOf('class BusyMaxModeSwitcher'); + final end = source.indexOf('class BusyMaxModalEditorScaffold'); + + expect(start, isNonNegative); + expect(end, greaterThan(start)); + final body = source.substring(start, end); + expect(body, contains('return YaruTabBar(')); + expect(body, contains('YaruTab(label: widget.labelFor(value))')); + expect(body, contains('BusyMaxModeSwitcher(')); + expect(body, isNot(contains('YaruListTile'))); + expect(body, isNot(contains('timeModeDescription'))); + expect(body, isNot(contains('MediaQuery'))); + expect(body, isNot(contains('ToggleButtons'))); + expect(body, isNot(contains('SegmentedButton'))); + expect(body, isNot(contains('LayoutBuilder'))); + expect(body, isNot(contains('BoxConstraints'))); + expect(body, isNot(contains('constraints.maxWidth'))); + expect(body, isNot(contains('Padding('))); + expect(body, isNot(contains('height:'))); + expect(body, isNot(contains('borderRadius:'))); + expect(body, isNot(contains('fillColor:'))); + expect(body, isNot(contains('labelColor:'))); }); test('feature code avoids raw Material controls with Yaru replacements', () { @@ -1060,17 +2474,36 @@ void main() { for (var index = 0; index < lines.length; index++) { final line = lines[index]; final location = '${file.path}:${index + 1}'; - expect(line, isNot(contains('AlertDialog')), reason: location); + final isSharedConfirmationFallback = + file.path.endsWith('lib/src/app/busymax_design.dart') && + line.contains('return AlertDialog('); + expect( + line.contains('AlertDialog'), + isSharedConfirmationFallback, + reason: location, + ); expect( line, isNot(contains('DropdownButtonFormField')), reason: location, ); + expect( + _hasRawDropdownMenu(line), + isFalse, + reason: '$location should use BusyMaxComboRow.', + ); + expect( + _hasRawMenuItemButton(line), + isFalse, + reason: '$location should use BusyMaxMenuEntry.', + ); + expect(line, isNot(contains('ToggleButtons(')), reason: location); + expect(line, isNot(contains('SegmentedButton(')), reason: location); expect(_hasRawMenuAnchor(file, line), isFalse, reason: location); expect( - _hasRawPopupMenuButton(file, line), + _hasRawPopupMenuButton(line), isFalse, - reason: '$location should use BusyMaxMenuButton/MenuButtonBuilder.', + reason: '$location should use BusyMaxMenuButton.', ); expect( _hasRawPopupMenuEntry(file, line), @@ -1091,7 +2524,7 @@ void main() { expect(line, isNot(contains('AppBar(')), reason: location); expect(line, isNot(contains('TextButton.icon')), reason: location); expect( - _hasRawIconButton(line), + _hasRawIconButton(file, line), isFalse, reason: '$location should use YaruIconButton.', ); @@ -1113,12 +2546,19 @@ Iterable _dartFilesIn(String path) sync* { } } -bool _hasRawPopupMenuButton(File file, String line) { +bool _hasRawPopupMenuButton(String line) { return line.contains('PopupMenuButton') && - !line.contains('YaruPopupMenuButton') && !line.contains('BusyMaxMenuButton'); } +bool _hasRawDropdownMenu(String line) { + return RegExp(r'\bDropdownMenu(?:<[^>]+>)?\s*\(').hasMatch(line); +} + +bool _hasRawMenuItemButton(String line) { + return RegExp(r'\bMenuItemButton(?:<[^>]+>)?\s*\(').hasMatch(line); +} + bool _hasRawPopupMenuEntry(File file, String line) { if (file.path.endsWith('lib/src/app/busymax_design.dart')) { return false; @@ -1131,7 +2571,7 @@ bool _hasRawMenuAnchor(File file, String line) { if (file.path.endsWith('lib/src/app/busymax_design.dart')) { return false; } - return line.contains('MenuAnchor'); + return RegExp(r'\bMenuAnchor\s*\(').hasMatch(line); } bool _hasRawCheckbox(String line) { @@ -1142,14 +2582,24 @@ bool _hasRawSwitch(String line) { return line.contains('Switch(') && !line.contains('YaruSwitch('); } -bool _hasRawIconButton(String line) { - return line.contains('IconButton(') && !line.contains('YaruIconButton('); +bool _hasRawIconButton(File file, String line) { + if (file.path.endsWith('lib/src/app/busymax_design.dart')) { + return false; + } + return line.contains('IconButton(') && + !line.contains('YaruIconButton(') && + !line.contains('BusyMaxHeaderIconButton(') && + !line.contains('BusyMaxPopoverIconButton('); } bool _isAllowedFontSizeException(File file, String line) { if (file.path.endsWith('lib/src/app/busymax_yaru_theme.dart')) { return true; } + if (file.path.endsWith('lib/src/app/busymax_app.dart') && + line.contains('theme.tooltipTheme.textStyle?.fontSize')) { + return true; + } return file.path.endsWith( 'lib/src/features/tasks/presentation/desktop_date_time_fields.dart', ) && diff --git a/test/app/rtl_glyphs_test.dart b/test/app/rtl_glyphs_test.dart new file mode 100644 index 0000000..229c505 --- /dev/null +++ b/test/app/rtl_glyphs_test.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:busymax/src/app/busymax_glyphs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +void main() { + test('navigation glyphs resolve for both reading directions', () { + expect(BusyMaxGlyphs.backFor(TextDirection.ltr), YaruIcons.arrow_left); + expect(BusyMaxGlyphs.backFor(TextDirection.rtl), YaruIcons.arrow_right); + expect(BusyMaxGlyphs.previousFor(TextDirection.ltr), YaruIcons.arrow_left); + expect(BusyMaxGlyphs.previousFor(TextDirection.rtl), YaruIcons.arrow_right); + expect(BusyMaxGlyphs.nextFor(TextDirection.ltr), YaruIcons.arrow_right); + expect(BusyMaxGlyphs.nextFor(TextDirection.rtl), YaruIcons.arrow_left); + expect(BusyMaxGlyphs.forwardFor(TextDirection.ltr), YaruIcons.go_next); + expect(BusyMaxGlyphs.forwardFor(TextDirection.rtl), YaruIcons.go_previous); + expect(BusyMaxGlyphs.collapsedFor(TextDirection.ltr), YaruIcons.pan_end); + expect(BusyMaxGlyphs.collapsedFor(TextDirection.rtl), YaruIcons.pan_start); + }); + + test('hierarchy glyphs resolve for both reading directions', () { + expect( + BusyMaxGlyphs.chevronForwardFor(TextDirection.ltr), + Icons.chevron_right, + ); + expect( + BusyMaxGlyphs.chevronForwardFor(TextDirection.rtl), + Icons.chevron_left, + ); + expect( + BusyMaxGlyphs.subdirectoryFor(TextDirection.ltr), + Icons.subdirectory_arrow_right, + ); + expect( + BusyMaxGlyphs.subdirectoryFor(TextDirection.rtl), + Icons.subdirectory_arrow_left, + ); + }); + + test('Arabic and Persian glyph coverage is packaged in the snap', () { + expect( + File('snap/snapcraft.yaml').readAsStringSync(), + contains('fonts-noto-core'), + ); + }); +} diff --git a/test/app/surface_palette_render_test.dart b/test/app/surface_palette_render_test.dart new file mode 100644 index 0000000..07f940a --- /dev/null +++ b/test/app/surface_palette_render_test.dart @@ -0,0 +1,294 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + for (final baseline in const [ + _SurfaceBaseline( + brightness: Brightness.light, + view: Color(0xFFFFFFFF), + window: Color(0xFFFAFAFA), + sidebar: Color(0xFFEBEBEB), + card: Color(0xFFFFFFFF), + popover: Color(0xFFFAFAFA), + floatingBorder: Color.fromRGBO(0, 0, 0, 0.14), + ), + _SurfaceBaseline( + brightness: Brightness.dark, + view: Color(0xFF272727), + window: Color(0xFF2C2C2C), + sidebar: Color(0xFF393939), + card: Color(0xFF3D3D3D), + popover: Color(0xFF3E3E3E), + floatingBorder: Color.fromRGBO(0, 0, 0, 0.14), + ), + ]) { + testWidgets( + 'renders the reviewed ${baseline.brightness.name} surface palette', + (tester) async { + tester.view + ..physicalSize = const Size(800, 600) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final boundaryKey = GlobalKey(); + final viewProbe = GlobalKey(); + final sidebarProbe = GlobalKey(); + final dialogProbe = GlobalKey(); + final cardProbe = GlobalKey(); + final popoverProbe = GlobalKey(); + final popoverSurfaceKey = GlobalKey(); + final contentPopoverProbe = GlobalKey(); + final theme = BusyMaxYaruTheme.build( + brightness: baseline.brightness, + accentColor: const Color(0xFFE95464), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (context) { + final colors = BusyMaxSurfaceColors.of(context); + return RepaintBoundary( + key: boundaryKey, + child: Scaffold( + backgroundColor: colors.view, + body: Stack( + children: [ + Align( + alignment: Alignment.centerLeft, + child: BusyMaxSidebarSurface( + child: SizedBox( + width: 120, + height: double.infinity, + child: Center( + child: SizedBox.square( + key: sidebarProbe, + dimension: 16, + ), + ), + ), + ), + ), + Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.all(24), + child: SizedBox.square( + key: viewProbe, + dimension: 16, + ), + ), + ), + BusyMaxModalEditorSurface( + maxWidth: 320, + maxHeight: 260, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.square( + key: dialogProbe, + dimension: 16, + ), + const SizedBox(height: 24), + BusyMaxGroupedSurface( + child: SizedBox( + key: cardProbe, + width: 180, + height: 72, + ), + ), + ], + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(24), + child: BusyMaxContentPopoverSurface( + padding: const EdgeInsets.all(20), + child: SizedBox.square( + key: contentPopoverProbe, + dimension: 16, + ), + ), + ), + ), + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.all(24), + child: BusyMaxPopoverSurface( + key: popoverSurfaceKey, + color: colors.popover, + padding: const EdgeInsets.all(20), + child: SizedBox.square( + key: popoverProbe, + dimension: 16, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + final pixels = await _capturePixels(tester, boundaryKey); + expect(_pixelAtProbe(tester, pixels, sidebarProbe), baseline.sidebar); + expect(_pixelAtProbe(tester, pixels, viewProbe), baseline.view); + expect(_pixelAtProbe(tester, pixels, dialogProbe), baseline.window); + expect(_pixelAtProbe(tester, pixels, cardProbe), baseline.card); + expect(_pixelAtProbe(tester, pixels, popoverProbe), baseline.popover); + final popoverSize = tester.getSize(find.byKey(popoverSurfaceKey)); + final edge = _pixelAtLocal( + tester, + pixels, + popoverSurfaceKey, + Offset(0.5, popoverSize.height / 2), + ); + final expectedEdge = Color.alphaBlend( + baseline.floatingBorder, + baseline.popover, + ); + _expectColorNear(edge, expectedEdge, tolerance: 3); + if (baseline.brightness == Brightness.dark) { + expect( + edge.computeLuminance(), + lessThan(baseline.popover.computeLuminance()), + ); + } + expect( + _pixelAtProbe(tester, pixels, contentPopoverProbe), + baseline.popover, + ); + }, + ); + } +} + +class _SurfaceBaseline { + const _SurfaceBaseline({ + required this.brightness, + required this.view, + required this.window, + required this.sidebar, + required this.card, + required this.popover, + required this.floatingBorder, + }); + + final Brightness brightness; + final Color view; + final Color window; + final Color sidebar; + final Color card; + final Color popover; + final Color floatingBorder; +} + +Future<_CapturedPixels> _capturePixels( + WidgetTester tester, + GlobalKey boundaryKey, +) async { + final boundary = + boundaryKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = (await tester.binding.runAsync( + () => boundary.toImage(pixelRatio: 1), + ))!; + try { + final data = (await tester.binding.runAsync( + () => image.toByteData(format: ui.ImageByteFormat.rawStraightRgba), + ))!; + return _CapturedPixels( + bytes: data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + width: image.width, + boundary: boundary, + ); + } finally { + image.dispose(); + } +} + +Color _pixelAtProbe( + WidgetTester tester, + _CapturedPixels pixels, + GlobalKey probeKey, +) { + final globalCenter = tester.getCenter(find.byKey(probeKey)); + final localCenter = pixels.boundary.globalToLocal(globalCenter); + final x = localCenter.dx.round(); + final y = localCenter.dy.round(); + final offset = (y * pixels.width + x) * 4; + return Color.fromARGB( + pixels.bytes[offset + 3], + pixels.bytes[offset], + pixels.bytes[offset + 1], + pixels.bytes[offset + 2], + ); +} + +Color _pixelAtLocal( + WidgetTester tester, + _CapturedPixels pixels, + GlobalKey probeKey, + Offset localOffset, +) { + final box = tester.renderObject(find.byKey(probeKey)); + final globalPoint = box.localToGlobal(localOffset); + final point = pixels.boundary.globalToLocal(globalPoint); + final x = point.dx.floor(); + final y = point.dy.floor(); + final offset = (y * pixels.width + x) * 4; + return Color.fromARGB( + pixels.bytes[offset + 3], + pixels.bytes[offset], + pixels.bytes[offset + 1], + pixels.bytes[offset + 2], + ); +} + +void _expectColorNear(Color actual, Color expected, {required int tolerance}) { + expect( + (actual.r * 255 - expected.r * 255).abs(), + lessThanOrEqualTo(tolerance), + ); + expect( + (actual.g * 255 - expected.g * 255).abs(), + lessThanOrEqualTo(tolerance), + ); + expect( + (actual.b * 255 - expected.b * 255).abs(), + lessThanOrEqualTo(tolerance), + ); + expect( + (actual.a * 255 - expected.a * 255).abs(), + lessThanOrEqualTo(tolerance), + ); +} + +class _CapturedPixels { + const _CapturedPixels({ + required this.bytes, + required this.width, + required this.boundary, + }); + + final Uint8List bytes; + final int width; + final RenderRepaintBoundary boundary; +} diff --git a/test/app/system_accent_test.dart b/test/app/system_accent_test.dart index 3db300f..99945ed 100644 --- a/test/app/system_accent_test.dart +++ b/test/app/system_accent_test.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:busymax/src/app/system_accent.dart'; import 'package:dbus/dbus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/theme.dart'; void main() { test('reads RGB portal accent color values', () { @@ -24,6 +27,117 @@ void main() { colorFromUbuntuAccentNameValue(const DBusString('purple')), const Color(0xff7764d8), ); + expect(ubuntuAccentNameColor('orange'), YaruColors.orange); expect(colorFromUbuntuAccentNameValue(const DBusString('unknown')), isNull); }); + + test('falls back to the Ubuntu setting when the RGB read fails', () async { + var readGnome = false; + + final color = await readPreferredLinuxAccentColor( + readFreedesktop: () => Future.error( + StateError('freedesktop accent key is unavailable'), + ), + readGnome: () async { + readGnome = true; + return YaruVariant.orange.color; + }, + ); + + expect(color, YaruVariant.orange.color); + expect(readGnome, isTrue); + }); + + test('does not read the named fallback after an exact RGB result', () async { + var readGnome = false; + const exactRgb = Color(0xFF336699); + + final color = await readPreferredLinuxAccentColor( + readFreedesktop: () async => exactRgb, + readGnome: () async { + readGnome = true; + return YaruVariant.blue.color; + }, + ); + + expect(color, exactRgb); + expect(readGnome, isFalse); + }); + + test('an exact RGB signal remains authoritative over named signals', () { + final resolver = LinuxAccentChangeResolver(); + final exactRgb = DBusStruct([ + const DBusDouble(0.2), + const DBusDouble(0.4), + const DBusDouble(0.6), + ]); + + expect( + resolver.resolve( + 'org.gnome.desktop.interface', + const DBusString('orange'), + ), + YaruVariant.orange.color, + ); + expect( + resolver.resolve('org.freedesktop.appearance', exactRgb), + const Color(0xFF336699), + ); + expect( + resolver.resolve('org.gnome.desktop.interface', const DBusString('blue')), + isNull, + ); + }); + + test('subscribes before reading the initial accent snapshot', () async { + var subscribed = false; + final snapshot = Completer(); + final changes = StreamController( + onListen: () => subscribed = true, + ); + final colors = watchPreferredLinuxAccentColors( + readFreedesktop: () => snapshot.future, + readGnome: () async => YaruVariant.orange.color, + changes: changes.stream, + ).toList(); + + await Future.delayed(Duration.zero); + expect(subscribed, isTrue); + changes.add(( + namespace: 'org.freedesktop.appearance', + value: DBusStruct([ + const DBusDouble(0.2), + const DBusDouble(0.4), + const DBusDouble(0.6), + ]), + )); + snapshot.complete(null); + await changes.close(); + + expect(await colors, [YaruVariant.orange.color, const Color(0xFF336699)]); + }); + + test( + 'an initial exact accent cannot be downgraded by a named signal', + () async { + const exactRgb = Color(0xFF336699); + final snapshot = Completer(); + final changes = StreamController(); + final colors = watchPreferredLinuxAccentColors( + readFreedesktop: () => snapshot.future, + readGnome: () async => YaruVariant.orange.color, + changes: changes.stream, + ).toList(); + + await Future.delayed(Duration.zero); + changes.add(( + namespace: 'org.gnome.desktop.interface', + value: const DBusString('blue'), + )); + snapshot.complete(exactRgb); + await changes.close(); + + expect(await colors, [exactRgb]); + }, + ); } diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 5ef6707..cdf1666 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -6,7 +6,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:system_theme/system_theme.dart'; +import 'package:yaru/constants.dart'; import 'package:yaru/theme.dart'; +import 'package:yaru/widgets.dart' show YaruInfoType; import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; @@ -19,11 +21,41 @@ import 'package:busymax/src/l10n/l10n.dart'; import 'package:busymax/src/platform/busymax_tray_service.dart'; import 'package:busymax/src/platform/gtk_font_service.dart'; import 'package:busymax/src/platform/linux_window_service.dart'; +import 'package:busymax/src/schedule/schedule_commands.dart'; import 'package:busymax/src/schedule/schedule_view_mode.dart'; import '../test_localized_app.dart'; void main() { + test('native header theme uses the exact first-frame semantic palette', () { + final theme = _buildBusyMaxTheme(brightness: Brightness.light); + final colors = theme.extension()!; + final headerTheme = busyMaxHeaderBarThemeFor(theme, highContrast: false); + + expect(headerTheme.preferDark, isFalse); + expect(headerTheme.highContrast, isFalse); + expect(headerTheme.windowBackgroundColor, colors.window); + expect(headerTheme.backgroundColor, colors.window); + expect(headerTheme.sidebarBackgroundColor, colors.sidebar); + expect(headerTheme.foregroundColor, colors.foreground); + expect(headerTheme.dialogBackgroundColor, colors.dialog); + expect(headerTheme.modalBarrierColor, colors.shade); + expect(headerTheme.tooltip.backgroundColor, BusyMaxTooltipStyle.background); + expect(headerTheme.tooltip.foregroundColor, BusyMaxTooltipStyle.foreground); + expect(headerTheme.tooltip.borderColor, BusyMaxTooltipStyle.border); + expect(headerTheme.tooltip.borderRadius, BusyMaxRadius.tooltip); + expect( + headerTheme.tooltip.fontSize, + theme.tooltipTheme.textStyle?.fontSize, + ); + expect( + headerTheme.tooltip.horizontalPadding, + BusyMaxSpacing.tooltipHorizontal, + ); + expect(headerTheme.tooltip.verticalPadding, BusyMaxSpacing.tooltipVertical); + expect(headerTheme.tooltip.minimumHeight, BusyMaxSizes.tooltipMinHeight); + }); + test('builds with system accent and tokenized control surfaces', () { final light = _buildBusyMaxTheme(brightness: Brightness.light); final dark = _buildBusyMaxTheme(brightness: Brightness.dark); @@ -34,11 +66,13 @@ void main() { final selected = {WidgetState.selected}; final lightSurfaceColors = light.extension()!; + final yaruBase = createYaruLightTheme(primaryColor: _testAccentColor); expect(light.colorScheme.primary, _testAccentColor); expect(dark.colorScheme.primary, _testAccentColor); expect(light.primaryColor, _testAccentColor); - expect(light.visualDensity, VisualDensity.compact); + expect(light.visualDensity, yaruBase.visualDensity); + expect(light.splashFactory, yaruBase.splashFactory); expect(light.scaffoldBackgroundColor, isNot(_testAccentColor)); expect(light.colorScheme.surface, alternate.colorScheme.surface); expect( @@ -64,20 +98,36 @@ void main() { ); expect( light.filledButtonTheme.style?.backgroundColor?.resolve({}), - _testAccentColor, + lightSurfaceColors.control, ); expect( - light.segmentedButtonTheme.style?.foregroundColor?.resolve(selected), - _testAccentColor, + light.filledButtonTheme.style?.backgroundColor?.resolve(selected), + lightSurfaceColors.controlActive, ); expect( - light.segmentedButtonTheme.style?.side?.resolve(selected)?.color, - _testAccentColor, + light.filledButtonTheme.style?.iconColor?.resolve({}), + lightSurfaceColors.foreground, ); expect( - light.segmentedButtonTheme.style?.backgroundColor?.resolve(selected), - light.colorScheme.primaryContainer, + light.elevatedButtonTheme.style?.backgroundColor?.resolve({}), + _testAccentColor, ); + expect( + light.elevatedButtonTheme.style?.iconColor?.resolve({}), + light.colorScheme.onPrimary, + ); + for (final style in [ + light.filledButtonTheme.style, + light.elevatedButtonTheme.style, + light.outlinedButtonTheme.style, + light.textButtonTheme.style, + ]) { + expect( + style?.iconColor?.resolve({WidgetState.disabled}), + lightSurfaceColors.disabledForeground, + ); + } + expect(light.toggleButtonsTheme, yaruBase.toggleButtonsTheme); expect(light.floatingActionButtonTheme.backgroundColor, _testAccentColor); expect(light.progressIndicatorTheme.color, _testAccentColor); expect(light.textSelectionTheme.cursorColor, _testAccentColor); @@ -89,31 +139,269 @@ void main() { final outlinedShape = light.outlinedButtonTheme.style?.shape?.resolve({}) as RoundedRectangleBorder; - expect(outlinedShape.borderRadius, BorderRadius.circular(12)); + expect(outlinedShape.borderRadius, BorderRadius.circular(BusyMaxRadius.sm)); - final pushButtonStyle = busyMaxPushButtonStyle(null); expect( - pushButtonStyle.minimumSize?.resolve({}), - BusyMaxSizes.pushButtonSize, + light.outlinedButtonTheme.style?.side?.resolve({}), + yaruBase.outlinedButtonTheme.style?.side?.resolve({}), ); + for (final pair in [ + (light.outlinedButtonTheme.style, yaruBase.outlinedButtonTheme.style), + (light.filledButtonTheme.style, yaruBase.filledButtonTheme.style), + (light.elevatedButtonTheme.style, yaruBase.elevatedButtonTheme.style), + ]) { + expect(pair.$1?.shape?.resolve({}), pair.$2?.shape?.resolve({})); + for (final states in [ + {WidgetState.hovered}, + {WidgetState.focused}, + {WidgetState.pressed}, + ]) { + expect( + pair.$1?.overlayColor?.resolve(states), + pair.$2?.overlayColor?.resolve(states), + ); + } + } + }); + + testWidgets('desktop tooltips use one explicit natural-width geometry', ( + tester, + ) async { + Future<({Rect surface, Rect text})> measureTooltip({ + required Brightness brightness, + required String message, + }) async { + final tooltipKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + theme: _buildBusyMaxTheme(brightness: brightness), + home: Scaffold( + body: Center( + child: Tooltip( + key: tooltipKey, + message: message, + child: const SizedBox.square(dimension: 32), + ), + ), + ), + ), + ); + expect(tooltipKey.currentState!.ensureTooltipVisible(), isTrue); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final messageFinder = find.text(message); + expect(messageFinder, findsOneWidget); + final textRect = tester.getRect(messageFinder); + Rect? surfaceRect; + tester.element(messageFinder).visitAncestorElements((element) { + if (element.widget case final ConstrainedBox constrained + when constrained.constraints == BusyMaxTooltipStyle.constraints) { + final box = element.renderObject! as RenderBox; + surfaceRect = box.localToGlobal(Offset.zero) & box.size; + return false; + } + return true; + }); + expect(surfaceRect, isNotNull); + return (surface: surfaceRect!, text: textRect); + } + + for (final brightness in Brightness.values) { + final short = await measureTooltip( + brightness: brightness, + message: 'Main menu', + ); + final long = await measureTooltip( + brightness: brightness, + message: 'Show sidebar panel', + ); + + for (final measurement in [short, long]) { + expect(measurement.surface.height, BusyMaxSizes.tooltipMinHeight); + expect( + measurement.surface.width, + moreOrLessEquals( + measurement.text.width + + (BusyMaxSpacing.tooltipHorizontal + BusyMaxStroke.outline) * 2, + epsilon: 0.01, + ), + ); + } + expect(long.surface.width, greaterThan(short.surface.width)); + } + }); + + test('semantic theme retains Yaru component geometry and interactions', () { + final theme = _buildBusyMaxTheme(brightness: Brightness.light); + final base = createYaruLightTheme(primaryColor: _testAccentColor); + final colors = theme.extension()!; + + expect(theme.inputDecorationTheme.filled, base.inputDecorationTheme.filled); expect( - pushButtonStyle.fixedSize?.resolve({}), - const Size.fromHeight(BusyMaxSizes.pushButtonHeight), + theme.inputDecorationTheme.fillColor, + base.inputDecorationTheme.fillColor, ); expect( - pushButtonStyle.padding?.resolve({}), - const EdgeInsets.symmetric(horizontal: 12), + theme.inputDecorationTheme.contentPadding, + base.inputDecorationTheme.contentPadding, + ); + final inputShape = + theme.inputDecorationTheme.enabledBorder as OutlineInputBorder; + final baseInputShape = + base.inputDecorationTheme.enabledBorder as OutlineInputBorder; + expect(inputShape.borderRadius, baseInputShape.borderRadius); + expect(inputShape.borderSide.width, baseInputShape.borderSide.width); + expect( + inputShape.borderSide.strokeAlign, + baseInputShape.borderSide.strokeAlign, + ); + expect(inputShape.borderSide.color, colors.border); + + expect(theme.cardTheme.color, colors.card); + expect(theme.cardTheme.color?.a, 1); + expect(theme.cardTheme.surfaceTintColor, Colors.transparent); + expect(theme.cardTheme.shadowColor, theme.colorScheme.shadow); + expect(theme.cardTheme.elevation, BusyMaxElevation.groupedCard); + expect(theme.cardTheme.margin, base.cardTheme.margin); + expect(theme.cardTheme.clipBehavior, base.cardTheme.clipBehavior); + final cardShape = theme.cardTheme.shape! as RoundedRectangleBorder; + expect(cardShape.borderRadius, BorderRadius.circular(BusyMaxRadius.md)); + expect(cardShape.side, BorderSide.none); + + expect( + theme.dropdownMenuTheme.inputDecorationTheme?.constraints, + base.dropdownMenuTheme.inputDecorationTheme?.constraints, ); - expect(light.outlinedButtonTheme.style?.side?.resolve({}), BorderSide.none); + final dialogShape = theme.dialogTheme.shape! as RoundedRectangleBorder; + final baseDialogShape = base.dialogTheme.shape! as RoundedRectangleBorder; + expect(theme.dialogTheme.elevation, base.dialogTheme.elevation); + expect(theme.dialogTheme.shadowColor, theme.colorScheme.shadow); + expect(dialogShape.borderRadius, baseDialogShape.borderRadius); + expect(dialogShape.borderRadius, BorderRadius.circular(kYaruWindowRadius)); + expect(dialogShape.side, BorderSide(color: colors.dialogOutline)); + expect(dialogShape.side, isNot(baseDialogShape.side)); + expect(BusyMaxRadius.window, kYaruWindowRadius); + + for (final pair in [ + (theme.outlinedButtonTheme.style, base.outlinedButtonTheme.style), + (theme.filledButtonTheme.style, base.filledButtonTheme.style), + (theme.elevatedButtonTheme.style, base.elevatedButtonTheme.style), + (theme.textButtonTheme.style, base.textButtonTheme.style), + ]) { + final padding = pair.$1?.padding?.resolve(const {})! as EdgeInsets; + final basePadding = pair.$2?.padding?.resolve(const {})! as EdgeInsets; + expect(padding.horizontal, basePadding.horizontal); + expect(padding.vertical, 0); + expect( + pair.$1?.minimumSize?.resolve(const {}), + pair.$2?.minimumSize?.resolve(const {}), + ); + expect(pair.$1?.visualDensity, VisualDensity.standard); + expect(pair.$1?.tapTargetSize, MaterialTapTargetSize.shrinkWrap); + } + + final checkboxShape = theme.checkboxTheme.shape! as RoundedRectangleBorder; + final baseCheckboxShape = + base.checkboxTheme.shape! as RoundedRectangleBorder; + expect(checkboxShape.borderRadius, baseCheckboxShape.borderRadius); + expect(checkboxShape.borderRadius, BorderRadius.circular(kYaruCheckRadius)); + + final popupShape = theme.popupMenuTheme.shape! as OutlineInputBorder; + final basePopupShape = base.popupMenuTheme.shape! as OutlineInputBorder; + expect(popupShape.borderRadius, basePopupShape.borderRadius); + expect(popupShape.borderSide, BorderSide(color: colors.floatingBorder)); expect( - light.outlinedButtonTheme.style?.side?.resolve({WidgetState.disabled}), - BorderSide.none, + theme.popupMenuTheme.elevation, + theme.menuTheme.style?.elevation?.resolve(const {}), ); expect( - light.outlinedButtonTheme.style?.side?.resolve({WidgetState.focused}), - BorderSide(color: _testAccentColor), + theme.menuTheme.style?.elevation?.resolve(const {}), + base.menuTheme.style?.elevation?.resolve(const {}), + ); + expect(theme.popupMenuTheme.menuPadding, base.popupMenuTheme.menuPadding); + expect(theme.popupMenuTheme.position, base.popupMenuTheme.position); + expect( + theme.menuTheme.style?.side?.resolve({}), + BorderSide(color: colors.floatingBorder), + ); + expect( + theme.dropdownMenuTheme.menuStyle?.side?.resolve({}), + BorderSide(color: colors.floatingBorder), + ); + + for (final style in [ + theme.textTheme.titleSmall, + theme.textTheme.bodySmall, + theme.textTheme.labelLarge, + theme.textTheme.labelMedium, + theme.textTheme.labelSmall, + ]) { + expect(style?.color, colors.foreground); + expect(style?.color, isNot(colors.mutedForeground)); + } + }); + + testWidgets('semantic Yaru status colors do not follow the app accent', ( + tester, + ) async { + for (final brightness in Brightness.values) { + Map? colorsForFirstAccent; + for (final accent in const [Color(0xFFE95420), Color(0xFF7764D8)]) { + final theme = _buildBusyMaxTheme( + brightness: brightness, + accentColor: accent, + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + darkTheme: theme, + themeMode: brightness == Brightness.dark + ? ThemeMode.dark + : ThemeMode.light, + home: const Scaffold( + body: SizedBox(key: ValueKey('status-color-probe')), + ), + ), + ); + + final context = tester.element( + find.byKey(const ValueKey('status-color-probe')), + ); + final semanticColors = YaruColors.of(context); + final actual = { + for (final type in YaruInfoType.values) type: type.getColor(context), + }; + + expect(actual, { + YaruInfoType.information: semanticColors.link, + YaruInfoType.success: semanticColors.success, + YaruInfoType.important: YaruColors.purple, + YaruInfoType.warning: semanticColors.warning, + YaruInfoType.danger: semanticColors.error, + }); + colorsForFirstAccent ??= actual; + expect(actual, colorsForFirstAccent); + expect(actual[YaruInfoType.information], isNot(accent)); + } + } + }); + + test('shared push buttons expose semantic Yaru roles', () { + final standard = BusyMaxPushButton.standard( + onPressed: () {}, + child: const Text('Standard'), ); + final suggested = BusyMaxPushButton.suggested( + onPressed: () {}, + child: const Text('Suggested'), + ); + + expect(standard, isA()); + expect(suggested, isA()); + expect(standard.style, isNull); + expect(suggested.style, isNull); }); test('BusyMax Yaru theme exposes semantic fallback surfaces', () { @@ -123,57 +411,183 @@ void main() { final lightColors = light.extension()!; final darkColors = dark.extension()!; - expect(lightColors.window, const Color(0xFFFAFAFB)); + expect(lightColors.window, const Color(0xFFFAFAFA)); expect(lightColors.view, const Color(0xFFFFFFFF)); - expect(lightColors.sidebar, const Color(0xFFEBEBED)); + expect(lightColors.sidebar, const Color(0xFFEBEBEB)); + expect(lightColors.secondarySidebar, const Color(0xFFF0F0F0)); + expect(lightColors.headerbar, const Color(0xFFFAFAFA)); + expect(lightColors.headerbarFlat, lightColors.view); expect(lightColors.card, const Color(0xFFFFFFFF)); - expect(lightColors.dialog, const Color(0xFFFAFAFB)); - expect(lightColors.popover, const Color(0xFFFFFFFF)); - expect(darkColors.window, const Color(0xFF1D1D20)); - expect(darkColors.view, const Color(0xFF1D1D20)); - expect(darkColors.sidebar, const Color(0xFF2E2E32)); - expect(darkColors.card, const Color(0xFF222226)); - expect(darkColors.dialog, const Color(0xFF222226)); - expect(darkColors.popover, const Color(0xFF383838)); - expect(darkColors.view, isNot(const Color(0xFF3E3E3E))); + expect(lightColors.groupedSurface, const Color(0xFFFFFFFF)); + expect(lightColors.dialog, const Color(0xFFFAFAFA)); + expect(lightColors.popover, const Color(0xFFFAFAFA)); + expect(lightColors.control, const Color.fromRGBO(0, 0, 0, 0.10)); + expect(lightColors.controlHover, const Color.fromRGBO(0, 0, 0, 0.14)); + expect(lightColors.controlActive, const Color.fromRGBO(0, 0, 0, 0.18)); + expect(lightColors.mutedForeground, const Color(0xFF666666)); + expect( + lightColors.dialogOutline, + const Color.fromRGBO(255, 255, 255, 0.07), + ); + expect(lightColors.floatingBorder, const Color.fromRGBO(0, 0, 0, 0.14)); + expect(lightColors.sidebarBorder, const Color.fromRGBO(24, 24, 24, 0.08)); + expect(darkColors.window, const Color(0xFF2C2C2C)); + expect(darkColors.view, const Color(0xFF272727)); + expect(darkColors.sidebar, const Color(0xFF393939)); + expect(darkColors.secondarySidebar, const Color(0xFF323232)); + expect(darkColors.headerbar, const Color(0xFF393939)); + expect(darkColors.card, const Color(0xFF3D3D3D)); + expect( + darkColors.groupedSurface, + const Color.fromRGBO(255, 255, 255, 0.08), + ); + expect(darkColors.dialog, const Color(0xFF3E3E3E)); + expect(darkColors.popover, const Color(0xFF3E3E3E)); + expect(darkColors.mutedForeground, const Color(0xFFB5B5B5)); + expect(darkColors.border, const Color.fromRGBO(0, 0, 0, 0.75)); + expect(darkColors.dialogOutline, const Color.fromRGBO(255, 255, 255, 0.07)); + expect(darkColors.floatingBorder, const Color.fromRGBO(0, 0, 0, 0.14)); + expect(darkColors.sidebarBorder, const Color.fromRGBO(16, 16, 16, 0.35)); + expect( + Color.alphaBlend(darkColors.sidebarBorder, darkColors.sidebar).toARGB32(), + const Color(0xFF2B2B2B).toARGB32(), + ); + expect(darkColors.shade, const Color.fromRGBO(0, 0, 0, 0.25)); + expect(darkColors.groupedSurface.a, lessThan(1)); + expect( + Color.alphaBlend(darkColors.groupedSurface, darkColors.window).toARGB32(), + darkColors.card.toARGB32(), + ); + expect( + Color.alphaBlend( + darkColors.groupedSurface, + darkColors.dialog, + ).computeLuminance(), + greaterThan(darkColors.dialog.computeLuminance()), + ); + for (final (theme, colors) in [(light, lightColors), (dark, darkColors)]) { + _expectOpaqueMonotonicSurfaceContainers(theme); + expect(theme.colorScheme.surfaceContainerLowest, colors.view); + expect(theme.colorScheme.surfaceContainerLow, colors.window); + expect(theme.colorScheme.surfaceContainer, colors.secondarySidebar); + expect(theme.colorScheme.surfaceContainerHigh, colors.secondarySidebar); + expect(theme.colorScheme.surfaceContainerHighest, colors.sidebar); + expect(colors.mutedForeground.a, 1); + final effectiveGroupedSurfaces = [ + for (final parent in [ + colors.window, + colors.view, + colors.dialog, + colors.popover, + ]) + Color.alphaBlend(colors.groupedSurface, parent), + ]; + for (final surface in [ + colors.window, + colors.view, + colors.sidebar, + colors.secondarySidebar, + colors.headerbar, + colors.headerbarFlat, + colors.card, + colors.dialog, + colors.popover, + ]) { + _expectNeutralSurface(surface); + expect( + _contrastRatio(colors.mutedForeground, surface), + greaterThanOrEqualTo(4.5), + reason: 'Muted text must remain legible on $surface', + ); + } + for (final surface in effectiveGroupedSurfaces) { + _expectNeutralSurface(surface); + expect( + _contrastRatio(colors.mutedForeground, surface), + greaterThanOrEqualTo(4), + reason: + 'Native contextual card layers must retain clear secondary text', + ); + } + } expect(light.scaffoldBackgroundColor, lightColors.window); expect(dark.scaffoldBackgroundColor, darkColors.window); expect(light.cardColor, lightColors.card); expect(dark.cardColor, darkColors.card); + expect(light.cardTheme.color, lightColors.card); + expect(dark.cardTheme.color, darkColors.card); + expect(light.cardTheme.color?.a, 1); + expect(dark.cardTheme.color?.a, 1); + expect(light.cardTheme.elevation, BusyMaxElevation.groupedCard); + expect(dark.cardTheme.elevation, BusyMaxElevation.groupedCard); + expect(light.cardTheme.shadowColor, light.colorScheme.shadow); + expect(dark.cardTheme.shadowColor, dark.colorScheme.shadow); expect(light.dialogTheme.backgroundColor, lightColors.dialog); expect(dark.dialogTheme.backgroundColor, darkColors.dialog); + expect(light.dialogTheme.shadowColor, light.colorScheme.shadow); + expect(dark.dialogTheme.shadowColor, dark.colorScheme.shadow); expect(light.popupMenuTheme.color, lightColors.popover); expect(dark.popupMenuTheme.color, darkColors.popover); - expect(light.tooltipTheme.decoration, isA()); - expect(dark.tooltipTheme.decoration, isA()); expect( - (light.tooltipTheme.decoration! as BoxDecoration).color, - lightColors.popover, + light.popupMenuTheme.labelTextStyle?.resolve(const {})?.color, + lightColors.foreground, ); - expect((light.tooltipTheme.decoration! as BoxDecoration).border, isNull); expect( - (light.tooltipTheme.decoration! as BoxDecoration).boxShadow, - BusyMaxShadow.tooltipShadows(lightColors.shade), + light.popupMenuTheme.labelTextStyle?.resolve(const { + WidgetState.disabled, + })?.color, + lightColors.disabledForeground, ); expect( - (dark.tooltipTheme.decoration! as BoxDecoration).color, + dark.menuTheme.style?.backgroundColor?.resolve(const {}), darkColors.popover, ); - expect((dark.tooltipTheme.decoration! as BoxDecoration).border, isNull); expect( - (dark.tooltipTheme.decoration! as BoxDecoration).boxShadow, - BusyMaxShadow.tooltipShadows(darkColors.shade), - ); - expect(light.tooltipTheme.textStyle?.color, lightColors.foreground); - expect(dark.tooltipTheme.textStyle?.color, darkColors.foreground); - expect( - light.tooltipTheme.textStyle?.fontSize, - light.textTheme.bodyMedium?.fontSize, + dark.dropdownMenuTheme.menuStyle?.backgroundColor?.resolve(const {}), + darkColors.popover, ); + final yaruDark = createYaruDarkTheme(primaryColor: _testAccentColor); + for (final pair in [ + (dark.menuTheme.style, yaruDark.menuTheme.style), + (dark.dropdownMenuTheme.menuStyle, yaruDark.dropdownMenuTheme.menuStyle), + ]) { + expect( + pair.$1?.elevation?.resolve(const {}), + pair.$2?.elevation?.resolve(const {}), + ); + expect( + pair.$1?.shape?.resolve(const {}), + pair.$2?.shape?.resolve(const {}), + ); + expect( + pair.$1?.side?.resolve(const {}), + BorderSide(color: darkColors.floatingBorder), + ); + expect( + pair.$1?.padding?.resolve(const {}), + pair.$2?.padding?.resolve(const {}), + ); + } + final yaruLight = createYaruLightTheme(primaryColor: _testAccentColor); + for (final theme in [light, dark]) { + final decoration = theme.tooltipTheme.decoration! as BoxDecoration; + final border = decoration.border! as Border; + expect(decoration.color, BusyMaxTooltipStyle.background); + expect(decoration.borderRadius, BusyMaxTooltipStyle.borderRadius); + expect(border.top.color, BusyMaxTooltipStyle.border); + expect( + theme.tooltipTheme.textStyle?.color, + BusyMaxTooltipStyle.foreground, + ); + expect(theme.tooltipTheme.padding, BusyMaxTooltipStyle.padding); + expect(theme.tooltipTheme.constraints, BusyMaxTooltipStyle.constraints); + expect(theme.tooltipTheme.waitDuration, BusyMaxMotion.tooltipWait); + } expect( - dark.tooltipTheme.textStyle?.fontSize, - dark.textTheme.bodyMedium?.fontSize, + light.tooltipTheme.waitDuration, + yaruLight.tooltipTheme.waitDuration, ); + expect(dark.tooltipTheme.waitDuration, yaruDark.tooltipTheme.waitDuration); }); test('BusyMaxSurfaceColors copyWith preserves and overrides fields', () { @@ -184,13 +598,17 @@ void main() { final updated = base.copyWith( window: const Color(0xFF010203), sidebar: const Color(0xFF040506), + groupedSurface: const Color(0xFF060708), disabledForeground: const Color(0xFF070809), + dialogOutline: const Color(0xFF090A0B), shade: const Color(0xFF0A0B0C), ); expect(updated.window, const Color(0xFF010203)); expect(updated.sidebar, const Color(0xFF040506)); + expect(updated.groupedSurface, const Color(0xFF060708)); expect(updated.disabledForeground, const Color(0xFF070809)); + expect(updated.dialogOutline, const Color(0xFF090A0B)); expect(updated.shade, const Color(0xFF0A0B0C)); expect(updated.view, base.view); expect(updated.popover, base.popover); @@ -208,7 +626,15 @@ void main() { expect(midpoint.window, Color.lerp(start.window, end.window, 0.5)); expect(midpoint.sidebar, Color.lerp(start.sidebar, end.sidebar, 0.5)); + expect( + midpoint.groupedSurface, + Color.lerp(start.groupedSurface, end.groupedSurface, 0.5), + ); expect(midpoint.dialog, Color.lerp(start.dialog, end.dialog, 0.5)); + expect( + midpoint.dialogOutline, + Color.lerp(start.dialogOutline, end.dialogOutline, 0.5), + ); expect( midpoint.disabledForeground, Color.lerp(start.disabledForeground, end.disabledForeground, 0.5), @@ -316,6 +742,13 @@ void main() { family: gtkFamily, scale: scale, ); + _expectComponentStyleUsesTypography( + theme.popupMenuTheme.labelTextStyle?.resolve(buttonStates), + baseStyle: base.popupMenuTheme.labelTextStyle?.resolve(buttonStates), + fallback: textTheme.bodyMedium, + family: gtkFamily, + scale: scale, + ); _expectComponentStyleUsesTypography( theme.dialogTheme.titleTextStyle, baseStyle: base.dialogTheme.titleTextStyle, @@ -372,13 +805,15 @@ void main() { family: gtkFamily, scale: scale, ); - _expectComponentStyleUsesTypography( - theme.tooltipTheme.textStyle, - baseStyle: null, - fallback: textTheme.bodyMedium, - family: gtkFamily, - scale: scale, + expect( + theme.tooltipTheme.textStyle?.fontFamily, + theme.textTheme.bodyMedium?.fontFamily, ); + expect( + theme.tooltipTheme.textStyle?.fontSize, + theme.textTheme.bodyMedium?.fontSize, + ); + expect(theme.tooltipTheme.textStyle?.color, BusyMaxTooltipStyle.foreground); _expectComponentStyleUsesTypography( theme.snackBarTheme.contentTextStyle, baseStyle: base.snackBarTheme.contentTextStyle, @@ -406,7 +841,6 @@ void main() { (theme.filledButtonTheme.style, base.filledButtonTheme.style), (theme.elevatedButtonTheme.style, base.elevatedButtonTheme.style), (theme.textButtonTheme.style, base.textButtonTheme.style), - (theme.segmentedButtonTheme.style, base.segmentedButtonTheme.style), ]) { _expectComponentStyleUsesTypography( pair.$1?.textStyle?.resolve(buttonStates), @@ -462,7 +896,7 @@ void main() { sidebar: Color(0xFF303030), headerbar: Color(0xFF282828), headerbarFlat: Color(0xFF242424), - card: Color(0xFF2C2C2C), + card: Color(0xFF444444), dialog: Color(0xFF343434), popover: Color(0xFF383838), control: Color(0x1AFFFFFF), @@ -474,7 +908,9 @@ void main() { disabledForeground: Color(0x61FFFFFF), disabledControl: Color(0x0FFFFFFF), border: Color(0x66000000), - subtleBorder: Color(0x1AFFFFFF), + divider: Color(0x1AFFFFFF), + cardShade: Color(0x5A101010), + floatingBorder: Color(0x24000000), sidebarBorder: Color(0x33000000), shade: Color(0x55000000), ); @@ -485,14 +921,43 @@ void main() { expect(theme.scaffoldBackgroundColor, gtkColors.window); expect(theme.colorScheme.surface, gtkColors.view); - expect(theme.colorScheme.surfaceContainer, gtkColors.card); - expect(theme.colorScheme.surfaceContainerHigh, gtkColors.control); - expect(theme.colorScheme.surfaceContainerHighest, gtkColors.controlHover); + _expectOpaqueMonotonicSurfaceContainers(theme); + expect(theme.colorScheme.surfaceContainerHigh, isNot(gtkColors.control)); + expect( + theme.colorScheme.surfaceContainerHighest, + isNot(gtkColors.controlHover), + ); expect(theme.colorScheme.onSurface, gtkColors.foreground); expect(theme.colorScheme.onSurfaceVariant, gtkColors.mutedForeground); + final colors = theme.extension()!; expect(theme.dialogTheme.backgroundColor, gtkColors.dialog); expect(theme.popupMenuTheme.color, gtkColors.popover); - expect(theme.extension()?.sidebar, gtkColors.sidebar); + expect(colors.dialog, gtkColors.dialog); + expect(colors.popover, gtkColors.popover); + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.groupedSurface, gtkColors.card); + expect(colors.cardShade, gtkColors.cardShade); + }); + + test('BusyMax theme uses GTK accent foreground when it is readable', () { + const accent = Color(0xFF006B50); + const accentForeground = Color(0xFFF5FFF9); + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + accent: accent, + accentForeground: accentForeground, + ); + final theme = _buildBusyMaxTheme( + brightness: Brightness.light, + accentColor: accent, + gtkThemeColors: gtkColors, + ); + + expect(theme.colorScheme.onPrimary, accentForeground); + expect( + theme.elevatedButtonTheme.style?.foregroundColor?.resolve({}), + accentForeground, + ); }); test('BusyMax theme ignores light GTK runtime shade samples', () { @@ -510,10 +975,94 @@ void main() { final colors = theme.extension()!; expect(colors.shade, busyMaxFallbackSurfaceColors(Brightness.light).shade); - expect(theme.popupMenuTheme.shadowColor, colors.shade); + expect( + colors.groupedSurface, + busyMaxFallbackSurfaceColors(Brightness.light).groupedSurface, + ); + expect(theme.shadowColor, theme.colorScheme.shadow); + expect(theme.popupMenuTheme.shadowColor, theme.colorScheme.shadow); + expect(theme.popupMenuTheme.shadowColor, isNot(colors.shade)); + }); + + test('BusyMax preserves a flat light GTK3 sidebar role', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFFAFAFA), + view: Color(0xFFFAFAFA), + sidebar: Color(0xFFFAFAFA), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ).extension()!; + + expect(colors.sidebar, gtkColors.sidebar); + }); + + test('light view keeps its semantic fallback when GTK omits it', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFF4F4F4), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ).extension()!; + final fallback = busyMaxFallbackSurfaceColors(Brightness.light); + + expect(colors.window, gtkColors.window); + expect(colors.view, fallback.view); + expect(colors.headerbarFlat, fallback.view); + }); + + test('an explicit GTK workspace view remains authoritative', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFF4F4F4), + view: Color(0xFFFFFFFF), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ).extension()!; + + expect(colors.window, gtkColors.window); + expect(colors.view, gtkColors.view); + expect(colors.headerbarFlat, gtkColors.view); }); - test('BusyMax theme ignores too-dark GTK popover samples', () { + test('dark workspace keeps its distinct fallback when GTK omits view', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF303030), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + final fallback = busyMaxFallbackSurfaceColors(Brightness.dark); + + expect(colors.window, gtkColors.window); + expect(colors.view, fallback.view); + expect(colors.headerbarFlat, fallback.view); + }); + + test('BusyMax preserves a distinct light GTK sidebar sample', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFFAFAFA), + view: Color(0xFFFFFFFF), + sidebar: Color(0xFFE8E8EA), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ).extension()!; + + expect(colors.sidebar, gtkColors.sidebar); + }); + + test('BusyMax preserves named GTK floating-surface roles', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF242424), @@ -526,70 +1075,374 @@ void main() { gtkThemeColors: gtkColors, ); - expect(theme.popupMenuTheme.color, const Color(0xFF383838)); + final colors = theme.extension()!; + + expect(theme.popupMenuTheme.color, gtkColors.popover); + expect(colors.popover, gtkColors.popover); + expect(colors.dialog, busyMaxFallbackSurfaceColors(Brightness.dark).dialog); expect( - theme.extension()?.popover, - const Color(0xFF383838), + colors.groupedSurface, + busyMaxFallbackSurfaceColors(Brightness.dark).groupedSurface, ); }); - test('BusyMax theme ignores blue purple GTK dark surface samples', () { + test('BusyMax preserves explicitly supplied readable GTK surface roles', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, - window: Color(0xFF241F31), - view: Color(0xFF241F31), - sidebar: Color(0xFF3D3846), - headerbar: Color(0xFF241F31), - card: Color(0xFF3D3846), - dialog: Color(0xFF241F31), - popover: Color(0xFF3D3846), + window: Color(0xFF2C2C2C), + view: Color(0xFF272727), + sidebar: Color(0xFF2A2A2A), + popover: Color(0xFF1D1D1D), + foreground: Color(0xFFF7F7F7), ); final theme = _buildBusyMaxTheme( brightness: Brightness.dark, gtkThemeColors: gtkColors, ); final colors = theme.extension()!; + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.dialog, busyMaxFallbackSurfaceColors(Brightness.dark).dialog); + expect(colors.popover, gtkColors.popover); + expect(theme.popupMenuTheme.color, colors.popover); + expect( + theme.menuTheme.style?.backgroundColor?.resolve(const {}), + colors.popover, + ); + expect( + theme.dropdownMenuTheme.menuStyle?.backgroundColor?.resolve(const {}), + colors.popover, + ); + }); + + test('BusyMax does not tint readable neutral GTK semantic roles', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF2C2C2C), + view: Color(0xFF272727), + sidebar: Color(0xFF2C2C2C), + headerbar: Color(0xFF131313), + dialog: Color(0xFF2C2C2C), + popover: Color(0xFF1D1D1D), + foreground: Color(0xFFF7F7F7), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + + expect(colors.window, gtkColors.window); + expect(colors.view, gtkColors.view); + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.headerbar, gtkColors.headerbar); + expect(colors.dialog, gtkColors.dialog); + expect(colors.popover, gtkColors.popover); + for (final surface in [ + colors.window, + colors.view, + colors.sidebar, + colors.secondarySidebar, + colors.headerbar, + colors.headerbarFlat, + colors.card, + colors.groupedSurface, + colors.dialog, + colors.popover, + ]) { + _expectNeutralSurface(surface); + } + }); - expect(theme.scaffoldBackgroundColor, const Color(0xFF1D1D20)); - expect(theme.colorScheme.surface, const Color(0xFF1D1D20)); - expect(theme.colorScheme.surfaceContainer, const Color(0xFF222226)); + test('BusyMax preserves a GTK card layer for its actual parent', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF2C2C2C), + view: Color(0xFF272727), + card: Color.fromRGBO(255, 255, 255, 0.08), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + final expectedCard = Color.alphaBlend(gtkColors.card!, gtkColors.window!); + final wrongParent = Color.alphaBlend(gtkColors.card!, gtkColors.view!); + final expectedDialogCard = Color.alphaBlend(gtkColors.card!, colors.dialog); + + expect(colors.card, expectedCard); + expect(colors.groupedSurface, gtkColors.card); + expect(colors.groupedSurface.a, lessThan(1)); expect( - theme.colorScheme.surfaceContainerHigh, - const Color.fromRGBO(255, 255, 255, 0.10), + Color.alphaBlend(colors.groupedSurface, colors.window), + expectedCard, ); expect( - theme.colorScheme.surfaceContainerHighest, - const Color.fromRGBO(255, 255, 255, 0.14), + Color.alphaBlend(colors.groupedSurface, colors.dialog), + expectedDialogCard, + ); + expect(expectedDialogCard, isNot(expectedCard)); + expect(colors.card, isNot(wrongParent)); + }); + + test('BusyMax preserves custom GTK surface roles', () { + const parent = Color(0xFF3E3E3E); + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: parent, + view: parent, + sidebar: Color(0xFF2A2A2A), + secondarySidebar: Color(0xFF303030), + headerbar: Color(0xFF303030), + card: Color(0xFF303030), + dialog: Color(0xFF303030), + popover: Color(0xFF303030), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.secondarySidebar, gtkColors.secondarySidebar); + expect(colors.headerbar, gtkColors.headerbar); + expect(colors.card, gtkColors.card); + expect(colors.groupedSurface, gtkColors.card); + expect(colors.dialog, gtkColors.dialog); + expect(colors.popover, gtkColors.popover); + }); + + test('BusyMax grouped surfaces ignore unreadable GTK card samples', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFFFFFFF), + view: Color(0xFFFFFFFF), + card: Color(0xFF101010), + popover: Color(0xFFEEEEEE), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ).extension()!; + + expect( + colors.groupedSurface, + busyMaxFallbackSurfaceColors(Brightness.light).groupedSurface, + ); + expect(colors.groupedSurface, isNot(gtkColors.card)); + expect(colors.groupedSurface, isNot(gtkColors.popover)); + }); + + test('BusyMax preserves readable flat or darker GTK card samples', () { + for (final card in const [Color(0xFF242424), Color(0xFF101010)]) { + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: GtkThemeColors( + brightness: Brightness.dark, + window: const Color(0xFF202020), + view: const Color(0xFF242424), + card: card, + ), + ).extension()!; + + expect(colors.card, card); + expect(colors.groupedSurface, card); + expect(colors.groupedSurface.a, 1); + } + }); + + test('BusyMax keeps an opaque GTK card independent of floating surfaces', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF202020), + view: Color(0xFF242424), + card: Color(0xFF303030), + dialog: Color(0xFF3E3E3E), + popover: Color(0xFF3E3E3E), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + expect(colors.card, gtkColors.card); + expect(colors.groupedSurface, gtkColors.card); + expect(colors.groupedSurface.a, 1); + }); + + test('BusyMax dark sidebar preserves its named recessed boundary role', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF202020), + view: Color(0xFF242424), + sidebar: Color(0xFF303030), + sidebarBorder: Color.fromRGBO(16, 16, 16, 0.35), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + + expect(colors.sidebarBorder, gtkColors.sidebarBorder); + expect( + Color.alphaBlend(colors.sidebarBorder, colors.sidebar).computeLuminance(), + lessThan(colors.sidebar.computeLuminance()), + ); + }); + + test( + 'BusyMax keeps native divider, card shade, and outline roles separate', + () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF2C2C2C), + card: Color(0xFF3D3D3D), + divider: Color.fromRGBO(0, 0, 6, 0.56), + cardShade: Color.fromRGBO(16, 16, 16, 0.35), + floatingBorder: Color.fromRGBO(255, 255, 255, 0.14), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + + expect(colors.divider, gtkColors.divider); + expect(colors.cardShade, gtkColors.cardShade); + expect(colors.floatingBorder, gtkColors.floatingBorder); + expect(colors.cardShade, isNot(colors.divider)); + expect(colors.cardShade, isNot(colors.floatingBorder)); + }, + ); + + test('BusyMax uses the semantic card shade when GTK omits the role', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF2C2C2C), + card: Color(0xFF3D3D3D), + divider: Color.fromRGBO(255, 255, 255, 0.10), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + + expect( + colors.cardShade, + busyMaxFallbackSurfaceColors(Brightness.dark).cardShade, ); - expect(theme.dialogTheme.backgroundColor, const Color(0xFF222226)); - expect(colors.sidebar, isNot(const Color(0xFF3D3846))); + expect(colors.cardShade, isNot(colors.divider)); + }); + + test('BusyMax theme preserves chromatic GTK dark surface samples', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF241F31), + view: Color(0xFF241F31), + sidebar: Color(0xFF4A4458), + headerbar: Color(0xFF342F40), + card: Color(0xFF4A4458), + dialog: Color(0xFF342F40), + popover: Color(0xFF342F40), + ); + final theme = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ); + final colors = theme.extension()!; + + expect(theme.scaffoldBackgroundColor, gtkColors.window); + expect(theme.colorScheme.surface, gtkColors.view); + _expectOpaqueMonotonicSurfaceContainers(theme); + expect(theme.dialogTheme.backgroundColor, gtkColors.dialog); + expect(colors.sidebar, gtkColors.sidebar); expect(colors.control, const Color.fromRGBO(255, 255, 255, 0.10)); expect(colors.controlHover, const Color.fromRGBO(255, 255, 255, 0.14)); - expect(colors.popover, const Color(0xFF383838)); + expect(colors.dialog, gtkColors.dialog); + expect(colors.popover, gtkColors.popover); + expect(colors.groupedSurface, gtkColors.card); }); - test('BusyMax theme ignores GTK accent control samples', () { + test('BusyMax theme preserves chromatic GTK control samples', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, - control: Color(0x22004A99), - controlHover: Color(0x33005BBB), - controlActive: Color(0x44006DDD), + control: Color.fromRGBO(120, 180, 255, 0.12), + controlHover: Color.fromRGBO(120, 180, 255, 0.18), + controlActive: Color.fromRGBO(120, 180, 255, 0.24), ); final theme = _buildBusyMaxTheme( brightness: Brightness.dark, gtkThemeColors: gtkColors, ); final colors = theme.extension()!; + expect(colors.control, gtkColors.control); + expect(colors.controlHover, gtkColors.controlHover); + expect(colors.controlActive, gtkColors.controlActive); + _expectOpaqueMonotonicSurfaceContainers(theme); + expect(theme.colorScheme.surfaceContainerHigh, isNot(gtkColors.control)); + expect( + theme.colorScheme.surfaceContainerHighest, + isNot(gtkColors.controlHover), + ); + }); + + test('BusyMax theme rejects an imperceptible GTK control ladder', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + control: Color.fromRGBO(0, 0, 0, 0.02), + controlHover: Color.fromRGBO(0, 0, 0, 0.04), + controlActive: Color.fromRGBO(0, 0, 0, 0.06), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ).extension()!; + final fallback = busyMaxFallbackSurfaceColors(Brightness.light); + + expect(colors.control, fallback.control); + expect(colors.controlHover, fallback.controlHover); + expect(colors.controlActive, fallback.controlActive); + }); + + test( + 'BusyMax theme rejects GTK controls indistinguishable from surfaces', + () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + control: Color.fromRGBO(255, 255, 255, 0.12), + controlHover: Color.fromRGBO(255, 255, 255, 0.18), + controlActive: Color.fromRGBO(255, 255, 255, 0.24), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ).extension()!; + final fallback = busyMaxFallbackSurfaceColors(Brightness.light); + + expect(colors.control, fallback.control); + expect(colors.controlHover, fallback.controlHover); + expect(colors.controlActive, fallback.controlActive); + }, + ); + + test('BusyMax theme rejects opaque GTK widget samples as overlay roles', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + control: Color(0xFF2C2C2C), + controlHover: Color(0xFF343434), + controlActive: Color(0xFF131313), + activeToggle: Color(0xFF343434), + disabledControl: Color(0xFF202020), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; final fallback = busyMaxFallbackSurfaceColors(Brightness.dark); expect(colors.control, fallback.control); expect(colors.controlHover, fallback.controlHover); expect(colors.controlActive, fallback.controlActive); - expect(theme.colorScheme.surfaceContainerHigh, fallback.control); - expect(theme.colorScheme.surfaceContainerHighest, fallback.controlHover); + expect(colors.activeToggle, fallback.activeToggle); + expect(colors.disabledControl, fallback.disabledControl); }); - test('BusyMax theme derives sidebar from collapsed GTK colors', () { + test('BusyMax theme preserves a flat custom sidebar role', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF3E3E3E), @@ -603,17 +1456,10 @@ void main() { expect(theme.scaffoldBackgroundColor, gtkColors.window); expect(theme.colorScheme.surface, gtkColors.view); - expect( - theme.extension()?.sidebar, - isNot(const Color(0xFF2E2E32)), - ); - expect( - theme.extension()?.sidebar, - isNot(theme.scaffoldBackgroundColor), - ); + expect(theme.extension()?.sidebar, gtkColors.window); }); - test('BusyMax theme uses native headerbar as sidebar candidate', () { + test('BusyMax theme keeps GTK semantic roles independent', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF242424), @@ -627,13 +1473,12 @@ void main() { ); expect(theme.colorScheme.surface, gtkColors.view); - expect( - theme.extension()?.sidebar, - gtkColors.headerbar, - ); + final colors = theme.extension()!; + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.headerbar, gtkColors.headerbar); }); - test('BusyMax theme ignores black GTK surface samples', () { + test('BusyMax theme preserves a readable black sidebar sample', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF000000), @@ -647,13 +1492,13 @@ void main() { ); final colors = theme.extension()!; - expect(theme.scaffoldBackgroundColor, const Color(0xFF1D1D20)); + expect(theme.scaffoldBackgroundColor, gtkColors.window); expect(theme.colorScheme.surface, gtkColors.view); - expect(colors.sidebar, isNot(const Color(0xFF000000))); - expect(colors.sidebar, isNot(const Color(0xFF2E2E32))); + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.headerbar, gtkColors.headerbar); }); - test('BusyMax theme ignores near-black GTK surface samples', () { + test('BusyMax theme preserves a flat near-black sidebar sample', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF101010), @@ -667,13 +1512,13 @@ void main() { ); final colors = theme.extension()!; - expect(theme.scaffoldBackgroundColor, const Color(0xFF1D1D20)); - expect(theme.colorScheme.surface, const Color(0xFF1D1D20)); - expect(colors.sidebar, isNot(const Color(0xFF101010))); - expect(colors.sidebar, isNot(theme.colorScheme.surface)); + expect(theme.scaffoldBackgroundColor, gtkColors.window); + expect(theme.colorScheme.surface, gtkColors.view); + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.headerbar, gtkColors.headerbar); }); - test('BusyMax theme ignores translucent GTK surface samples', () { + test('BusyMax theme composites translucent GTK surface layers', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0x33000000), @@ -686,11 +1531,16 @@ void main() { gtkThemeColors: gtkColors, ); final colors = theme.extension()!; + final window = Color.alphaBlend( + gtkColors.window!, + busyMaxFallbackSurfaceColors(Brightness.dark).window, + ); + final view = Color.alphaBlend(gtkColors.view!, window); - expect(theme.scaffoldBackgroundColor, const Color(0xFF1D1D20)); - expect(theme.colorScheme.surface, const Color(0xFF1D1D20)); - expect(colors.sidebar, isNot(const Color(0x33000000))); - expect(colors.sidebar, isNot(theme.colorScheme.surface)); + expect(theme.scaffoldBackgroundColor, window); + expect(theme.colorScheme.surface, view); + expect(colors.sidebar, Color.alphaBlend(gtkColors.sidebar!, window)); + expect(colors.headerbar, Color.alphaBlend(gtkColors.headerbar!, window)); }); test('BusyMax theme rejects unreadable GTK foreground samples', () { @@ -707,13 +1557,47 @@ void main() { gtkThemeColors: gtkColors, ); final colors = theme.extension()!; + final fallback = busyMaxFallbackSurfaceColors(Brightness.dark); - expect(colors.foreground, const Color(0xFFFFFFFF)); - expect(colors.mutedForeground, const Color.fromRGBO(255, 255, 255, 0.70)); + expect(colors.foreground, fallback.foreground); + expect(colors.mutedForeground, fallback.mutedForeground); + expect(colors.mutedForeground.a, 1); expect(theme.colorScheme.onSurface, colors.foreground); expect(theme.colorScheme.onSurfaceVariant, colors.mutedForeground); }); + test('BusyMax theme falls back only mixed-luminance GTK surface roles', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFFFFFFF), + view: Color(0xFFFFFFFF), + sidebar: Color(0xFF101010), + secondarySidebar: Color(0xFF111111), + headerbar: Color(0xFF121212), + headerbarFlat: Color(0xFF131313), + card: Color(0xFFFFFFFF), + dialog: Color(0xFF141414), + popover: Color(0xFF151515), + ); + final theme = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ); + final colors = theme.extension()!; + final fallback = busyMaxFallbackSurfaceColors(Brightness.light); + + expect(colors.window, gtkColors.window); + expect(colors.view, gtkColors.view); + expect(colors.card, gtkColors.card); + expect(colors.sidebar, fallback.sidebar); + expect(colors.secondarySidebar, fallback.secondarySidebar); + expect(colors.headerbar, fallback.headerbar); + expect(colors.headerbarFlat, fallback.headerbarFlat); + expect(colors.dialog, fallback.dialog); + expect(colors.popover, fallback.popover); + expect(colors.foreground, fallback.foreground); + }); + test('BusyMax theme ignores GTK runtime colors for other brightness', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, @@ -724,7 +1608,7 @@ void main() { gtkThemeColors: gtkColors, ); - expect(theme.scaffoldBackgroundColor, const Color(0xFFFAFAFB)); + expect(theme.scaffoldBackgroundColor, const Color(0xFFFAFAFA)); }); test( @@ -828,41 +1712,33 @@ void main() { expect(store.json['scheduleViewMode'], 'month'); }); - test('notification detail settings stay synchronized', () async { - final store = _MemorySettingsStore(); - final settings = AppSettingsController(store); - await Future.delayed(Duration.zero); - - await settings.setNotificationDetailLevel(NotificationDetailLevel.private); - expect(settings.state.detailedNotifications, isFalse); - - await settings.setDetailedNotifications(true); - expect( - settings.state.notificationDetailLevel, - NotificationDetailLevel.normal, - ); - - final loaded = AppSettings.fromJson({ - 'detailedNotifications': true, - 'notificationDetailLevel': 'private', - }); - expect(loaded.notificationDetailLevel, NotificationDetailLevel.normal); - }); - test('native headerbar receives semantic surface colors', () { final source = File('lib/src/app/busymax_app.dart').readAsStringSync(); + final synchronizer = File( + 'lib/src/platform/linux_header_bar_configuration_synchronizer.dart', + ).readAsStringSync(); expect( source, - contains('final colors = BusyMaxSurfaceColors.of(context);'), + contains('final colors = theme.extension()!;'), ); - expect(source, contains('await service.setTheme(')); + expect(source, contains('_headerBarConfigurationSynchronizer.schedule(')); + expect(synchronizer, contains('await service.setTheme(')); expect(source, contains('windowBackgroundColor: colors.window')); - expect(source, contains('backgroundColor: colors.view')); + expect(source, contains('backgroundColor: colors.window')); + expect(source, isNot(contains('backgroundColor: colors.headerbarFlat'))); + expect(source, isNot(contains('backgroundColor: colors.headerbar,'))); expect(source, contains('sidebarBackgroundColor: colors.sidebar')); - expect(source, contains('controlHoverColor: colors.controlHover')); - expect(source, contains('accentColor: colorScheme.primary')); - expect(source, contains('accentForegroundColor: colorScheme.onPrimary')); + expect(source, contains('foregroundColor: colors.foreground')); + expect(source, contains('sidebarBorderColor: colors.sidebarBorder')); + expect(source, isNot(contains('popoverBackgroundColor:'))); + expect(source, isNot(contains('menuHoverColor:'))); + expect(source, isNot(contains('popoverShadowColor:'))); + expect(source, contains('dialogBackgroundColor: colors.dialog')); + expect(source, isNot(contains('floatingBorderColor:'))); + expect(source, contains('modalBarrierColor: colors.shade')); + expect(source, isNot(contains('controlHoverColor: colors.controlHover'))); + expect(source, isNot(contains('accentColor: colorScheme.primary'))); expect(source, contains('menu: l10n.mainMenu')); expect(source, contains('settings: l10n.settings')); expect(source, contains('keyboardShortcuts: l10n.keyboardShortcuts')); @@ -872,50 +1748,35 @@ void main() { expect(source, isNot(contains('setSidebarBackgroundColor('))); }); - test( - 'root window wrapper clips bottom corners over matching native backing', - () { - final source = File('lib/src/app/busymax_app.dart').readAsStringSync(); + test('root window uses semantic backing while GTK owns window geometry', () { + final source = File('lib/src/app/busymax_app.dart').readAsStringSync(); - expect(source, contains('ClipRRect(')); - expect(source, contains('bottom: Radius.circular(BusyMaxRadius.window)')); - expect(source, contains('clipBehavior: Clip.antiAliasWithSaveLayer')); - expect( - source, - contains('color: BusyMaxSurfaceColors.of(context).window'), - ); - }, - ); + expect(source, isNot(contains('_BusyMaxWindowCornerClip'))); + expect(source, isNot(contains('ClipRRect('))); + expect(source, contains('color: BusyMaxSurfaceColors.of(context).window')); + }); test('signed-out onboarding background matches main content surface', () { final source = File( 'lib/src/features/auth/presentation/sign_in_screen.dart', ).readAsStringSync(); - final shellStart = source.indexOf( - 'constraints: const BoxConstraints(maxWidth: 900)', - ); - final shellEnd = source.indexOf('child: Column(', shellStart); - final shellSource = source.substring(shellStart, shellEnd); - expect(source, contains('color: BusyMaxSurfaceColors.of(context).view')); + expect(source, contains('color: BusyMaxSurfaceColors.of(context).window')); expect( source, - isNot(contains('color: BusyMaxSurfaceColors.of(context).window')), + isNot(contains('color: BusyMaxSurfaceColors.of(context).view')), ); - expect(shellSource, contains('BusyMaxSurface(')); - expect(shellSource, contains('filled: false')); - expect(shellSource, isNot(contains('filled: true'))); expect(source, contains('final title = context.l10n.onboardingSetupTitle')); - expect(source, contains('setTitleRange(title)')); + expect(source, contains('.claimSession()')); + expect(source, contains('_headerBarSession.updateState(')); + expect(source, contains('BusyMaxHeaderBarState(')); + expect(source, contains('title: title')); expect(source, isNot(contains('class _OnboardingHeader'))); expect(source, isNot(contains('class _OnboardingProgressDots'))); expect(source, isNot(contains('Border(top: BorderSide'))); - expect( - source, - contains('constraints: const BoxConstraints(maxWidth: 900)'), - ); - expect(source, contains('constraints: const BoxConstraints(')); - expect(source, contains('maxWidth: 480')); + expect(source, contains("key: const ValueKey('onboarding-content-rail')")); + expect(source, contains('busyMaxOnboardingContentMaxWidth')); + expect(source, contains('width: contentRailWidth')); }); testWidgets('BusyMaxApp wires localization delegates and system theme', ( @@ -943,6 +1804,53 @@ void main() { expect(app.localizationsDelegates, contains(AppLocalizations.delegate)); }); + testWidgets('BusyMaxApp does not dim Flutter content when inactive', ( + tester, + ) async { + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + addTearDown( + () => tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ), + ); + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + buildConfigProvider.overrideWithValue(_missingConfig), + databaseProvider.overrideWithValue(database), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + ], + child: const BusyMaxApp(), + ), + ); + await tester.pumpAndSettle(); + + ColoredBox flutterSurface() { + final shortcuts = tester + .widgetList(find.byType(Shortcuts)) + .firstWhere( + (widget) => + widget.child is Actions && + (widget.child as Actions).child is ColoredBox, + ); + return (shortcuts.child as Actions).child as ColoredBox; + } + + expect(flutterSurface().child, isNot(isA())); + expect(find.byKey(const ValueKey('busymax-window-backdrop')), findsNothing); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + await tester.pump(); + expect(flutterSurface().child, isNot(isA())); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(flutterSurface().child, isNot(isA())); + }); + testWidgets('tray startup waits for persisted start-minimized settings', ( tester, ) async { @@ -966,7 +1874,6 @@ void main() { required windowService, required labels, required onOpenAgenda, - onBeforeQuit, }) => trayService, ), ), @@ -994,7 +1901,82 @@ void main() { expect(windowService.hideWindowCalls, 1); }); - test('app sources avoid forbidden hardcoded accent colors', () { + testWidgets('tray Agenda opens the main window in Agenda mode', ( + tester, + ) async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + final windowService = _RecordingWindowService(); + late _RecordingTrayService trayService; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + buildConfigProvider.overrideWithValue(_missingConfig), + databaseProvider.overrideWithValue(database), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + linuxWindowServiceProvider.overrideWithValue(windowService), + ], + child: BusyMaxApp( + trayServiceFactory: + ({ + required windowService, + required labels, + required onOpenAgenda, + }) => trayService = _RecordingTrayService( + windowService, + openAgendaCallback: onOpenAgenda, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + await trayService.openAgenda(); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(BusyMaxApp)), + ); + final command = container.read(scheduleWorkspaceCommandProvider); + expect(windowService.showWindowCalls, 1); + expect(command?.kind, ScheduleWorkspaceCommandKind.agenda); + }); + + testWidgets('demo mode retains the local tray entry', (tester) async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + final windowService = _RecordingWindowService(); + final trayService = _RecordingTrayService(windowService); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + buildConfigProvider.overrideWithValue(_demoConfig), + databaseProvider.overrideWithValue(database), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + linuxWindowServiceProvider.overrideWithValue(windowService), + ], + child: BusyMaxApp( + trayServiceFactory: + ({ + required windowService, + required labels, + required onOpenAgenda, + }) => trayService, + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(trayService.startCalls, 1); + expect(trayService.available, isTrue); + expect(windowService.hideWindowCalls, 0); + }); + + test('production sources avoid forbidden hardcoded accent colors', () { final disallowedHue = String.fromCharCodes([111, 114, 97, 110, 103, 101]); final forbidden = [ '0xFF0D6E' @@ -1004,20 +1986,21 @@ void main() { 'Colors.' 'red', 'Colors.$disallowedHue', - 'YaruVariant.$disallowedHue', ]; final matches = []; + const centralizedAccentMapping = 'lib/src/app/system_accent.dart'; - for (final root in [Directory('lib'), Directory('test')]) { - for (final entry in root.listSync(recursive: true)) { - if (entry is! File) { - continue; - } - final text = utf8.decode(entry.readAsBytesSync(), allowMalformed: true); - for (final token in forbidden) { - if (text.contains(token)) { - matches.add('${entry.path}: $token'); - } + for (final entry in Directory('lib').listSync(recursive: true)) { + if (entry is! File) { + continue; + } + final text = utf8.decode(entry.readAsBytesSync(), allowMalformed: true); + final fileForbidden = entry.path == centralizedAccentMapping + ? forbidden + : [...forbidden, 'YaruVariant.', 'YaruColors.']; + for (final token in fileForbidden) { + if (text.contains(token)) { + matches.add('${entry.path}: $token'); } } } @@ -1040,6 +2023,56 @@ void main() { const _testAccentColor = Color(0xFF2E7D32); const _alternateTestAccentColor = Color(0xFF8A1D61); +void _expectNeutralSurface(Color color) { + final channels = color.toARGB32(); + final red = (channels >> 16) & 0xff; + final green = (channels >> 8) & 0xff; + final blue = channels & 0xff; + expect(green, red, reason: '$color has a red/green surface tint'); + expect(blue, red, reason: '$color has a red/blue surface tint'); +} + +void _expectOpaqueMonotonicSurfaceContainers(ThemeData theme) { + final containers = [ + theme.colorScheme.surfaceContainerLowest, + theme.colorScheme.surfaceContainerLow, + theme.colorScheme.surfaceContainer, + theme.colorScheme.surfaceContainerHigh, + theme.colorScheme.surfaceContainerHighest, + ]; + for (final color in containers) { + expect( + color.a, + 1, + reason: '${theme.brightness} surface containers must remain opaque', + ); + } + for (var index = 0; index < containers.length - 1; index++) { + final current = containers[index].computeLuminance(); + final next = containers[index + 1].computeLuminance(); + expect( + theme.brightness == Brightness.light ? current >= next : current <= next, + isTrue, + reason: '${theme.brightness} surface containers must follow elevation', + ); + } +} + +double _contrastRatio(Color foreground, Color background) { + final effectiveForeground = foreground.a < 1 + ? Color.alphaBlend(foreground, background) + : foreground; + final foregroundLuminance = effectiveForeground.computeLuminance(); + final backgroundLuminance = background.computeLuminance(); + final lighter = foregroundLuminance > backgroundLuminance + ? foregroundLuminance + : backgroundLuminance; + final darker = foregroundLuminance > backgroundLuminance + ? backgroundLuminance + : foregroundLuminance; + return (lighter + 0.05) / (darker + 0.05); +} + ThemeData _buildBusyMaxTheme({ required Brightness brightness, Color accentColor = _testAccentColor, @@ -1239,27 +2272,38 @@ class _DelayedSettingsStore implements LocalSettingsStore { class _RecordingWindowService extends LinuxWindowService { var hideWindowCalls = 0; + var showWindowCalls = 0; @override Future hideWindow() async { hideWindowCalls += 1; } + @override + Future showWindow() async { + showWindowCalls += 1; + } + @override Future setHideOnClose(bool enabled) async {} } class _RecordingTrayService extends BusyMaxTrayService { - _RecordingTrayService(LinuxWindowService windowService) - : super( - windowService: windowService, - labels: const BusyMaxTrayLabels( - openBusyMax: 'Open BusyMax', - agenda: 'Agenda', - quitBusyMax: 'Exit', - ), - onOpenAgenda: _noop, - ); + _RecordingTrayService( + LinuxWindowService windowService, { + Future Function() openAgendaCallback = _noop, + }) : _onOpenAgenda = openAgendaCallback, + super( + windowService: windowService, + labels: const BusyMaxTrayLabels( + openBusyMax: 'Open BusyMax', + agenda: 'Agenda', + quitBusyMax: 'Exit', + ), + onOpenAgenda: openAgendaCallback, + ); + + final Future Function() _onOpenAgenda; var startCalls = 0; var _available = false; @@ -1280,6 +2324,8 @@ class _RecordingTrayService extends BusyMaxTrayService { @override Future updateLabels(BusyMaxTrayLabels labels) async {} + + Future openAgenda() => _onOpenAgenda(); } Future _noop() async {} @@ -1292,3 +2338,12 @@ const _missingConfig = BuildConfig( oauthTokenEndpoint: 'https://oauth2.googleapis.com/token', oauthRevocationEndpoint: 'https://oauth2.googleapis.com/revoke', ); + +const _demoConfig = BuildConfig( + googleOAuthClientId: '', + googleOAuthClientSecret: '', + oauthAuthorizationEndpoint: 'https://example.test/authorize', + oauthTokenEndpoint: 'https://example.test/token', + oauthRevocationEndpoint: 'https://example.test/revoke', + useFakeProviderData: true, +); diff --git a/test/config/build_config_test.dart b/test/config/build_config_test.dart index cf15661..e85754a 100644 --- a/test/config/build_config_test.dart +++ b/test/config/build_config_test.dart @@ -9,6 +9,8 @@ void main() { expect(config.googleApiBaseUrl, 'https://www.googleapis.com'); expect(config.apiBaseUrl, 'https://www.googleapis.com'); expect(config.feedbackEndpoint, 'https://busystack.org/api/feedback'); + expect(config.useFakeProviderData, isFalse); + expect(config.demoTheme, BusyMaxDemoTheme.system); expect( config.oauthAuthorizationEndpoint, 'https://accounts.google.com/o/oauth2/v2/auth', @@ -34,4 +36,38 @@ void main() { expect(config.hasGoogleOAuthClientId, isFalse); expect(config.missingClientIdMessage, contains('flutter run -d linux')); }); + + test('demo mode is never enabled in release builds', () { + expect(busyMaxDemoModeEnabled(requested: true, releaseMode: false), isTrue); + expect(busyMaxDemoModeEnabled(requested: true, releaseMode: true), isFalse); + expect( + busyMaxDemoModeEnabled(requested: false, releaseMode: false), + isFalse, + ); + }); + + test('demo mode exposes only its local Google account flow', () { + const config = BuildConfig( + googleOAuthClientId: '', + googleOAuthClientSecret: '', + microsoftOAuthClientId: 'real-microsoft-client', + oauthAuthorizationEndpoint: 'https://example.test/authorize', + oauthTokenEndpoint: 'https://example.test/token', + oauthRevocationEndpoint: 'https://example.test/revoke', + useFakeProviderData: true, + demoTheme: BusyMaxDemoTheme.dark, + ); + + expect(config.hasGoogleOAuthClientId, isTrue); + expect(config.hasMicrosoftOAuthClientId, isFalse); + expect(config.hasAnyProviderConfigured, isTrue); + expect(config.demoTheme, BusyMaxDemoTheme.dark); + }); + + test('demo theme parser is tolerant and defaults to system', () { + expect(parseBusyMaxDemoTheme('LIGHT'), BusyMaxDemoTheme.light); + expect(parseBusyMaxDemoTheme(' dark '), BusyMaxDemoTheme.dark); + expect(parseBusyMaxDemoTheme('invalid'), BusyMaxDemoTheme.system); + expect(parseBusyMaxDemoTheme(''), BusyMaxDemoTheme.system); + }); } diff --git a/test/core/logging/redacting_logger_test.dart b/test/core/logging/redacting_logger_test.dart index ac77a2e..3ecc5ac 100644 --- a/test/core/logging/redacting_logger_test.dart +++ b/test/core/logging/redacting_logger_test.dart @@ -1,5 +1,6 @@ -import 'package:flutter_test/flutter_test.dart'; import 'package:busymax/src/core/logging/redacting_logger.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; void main() { test('redacts OAuth and bearer secrets', () { @@ -19,4 +20,28 @@ void main() { expect(redacted, isNot(contains('seven'))); expect(redacted, contains('[REDACTED]')); }); + + test('redacts a generic revocation token from a failed request URI', () { + final error = http.ClientException( + 'Connection failed', + Uri.parse( + 'https://oauth.example.test/revoke' + '?token=refresh-secret&reason=account-removal', + ), + ); + + final redacted = redactForLog( + 'Google authorization revocation failed: $error', + ); + + expect(redacted, isNot(contains('refresh-secret'))); + expect(redacted, contains('?token=[REDACTED]')); + expect(redacted, contains('reason=account-removal')); + }); + + test('does not redact generic token text outside a URL query', () { + const message = 'The parser returned token=identifier in normal prose.'; + + expect(redactForLog(message), message); + }); } diff --git a/test/core/time/time_zone_catalog_test.dart b/test/core/time/time_zone_catalog_test.dart new file mode 100644 index 0000000..a317db0 --- /dev/null +++ b/test/core/time/time_zone_catalog_test.dart @@ -0,0 +1,167 @@ +import 'package:busymax/src/core/time/linux_gweather_location_source.dart'; +import 'package:busymax/src/core/time/time_zone_catalog.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + BusyMaxTimeZoneCatalog.setSystemLocationsForTesting(const [ + BusyMaxSystemTimeZoneLocation( + name: 'Seattle', + englishName: 'Seattle', + countryCode: 'US', + timeZoneId: 'America/Los_Angeles', + ), + BusyMaxSystemTimeZoneLocation( + name: 'Miami', + englishName: 'Miami', + countryCode: 'US', + timeZoneId: 'America/New_York', + ), + BusyMaxSystemTimeZoneLocation( + name: 'Osaka', + englishName: 'Osaka', + countryCode: 'JP', + timeZoneId: 'Asia/Tokyo', + ), + BusyMaxSystemTimeZoneLocation( + name: 'Montréal', + englishName: 'Montreal', + countryCode: 'CA', + timeZoneId: 'America/Toronto', + ), + BusyMaxSystemTimeZoneLocation( + name: 'Vancouver', + englishName: 'Vancouver', + countryCode: 'CA', + timeZoneId: 'America/Vancouver', + ), + BusyMaxSystemTimeZoneLocation( + name: 'Burnaby', + englishName: 'Burnaby', + countryCode: 'CA', + timeZoneId: 'America/Vancouver', + ), + BusyMaxSystemTimeZoneLocation( + name: 'Victoria', + englishName: 'Victoria', + countryCode: 'CA', + timeZoneId: 'America/Vancouver', + ), + BusyMaxSystemTimeZoneLocation( + name: 'Victoria Falls', + englishName: 'Victoria Falls', + countryCode: 'ZW', + timeZoneId: 'Africa/Harare', + ), + ]); + }); + + tearDown(() { + BusyMaxTimeZoneCatalog.setSystemLocationsForTesting(null); + }); + + test('timezone catalog searches IANA locations with current codes', () { + final results = BusyMaxTimeZoneCatalog.search('Vancouver'); + final vancouver = results.singleWhere( + (location) => location.id == 'America/Vancouver', + ); + + expect(vancouver.region, 'America'); + expect(vancouver.name, 'Vancouver'); + expect(vancouver.code, isNotEmpty); + expect(vancouver.displayLabel, 'America/Vancouver (${vancouver.code})'); + }); + + test('timezone catalog includes the complete IANA location set', () { + final america = BusyMaxTimeZoneCatalog.search('America'); + final montreal = america.singleWhere( + (location) => location.id == 'America/Montreal', + ); + final easternAliases = BusyMaxTimeZoneCatalog.search('Eastern'); + + expect(america.length, greaterThan(80)); + expect(montreal.region, 'America'); + expect(montreal.name, 'Montreal'); + expect(montreal.code, isNotEmpty); + expect( + easternAliases.map((location) => location.id), + containsAll(['Canada/Eastern', 'US/Eastern']), + ); + }); + + test('location search resolves ordinary cities to IANA timezones', () async { + final seattle = await BusyMaxTimeZoneCatalog.searchLocations('Seattle'); + final miami = await BusyMaxTimeZoneCatalog.searchLocations('Miami'); + final osaka = await BusyMaxTimeZoneCatalog.searchLocations('Osaka'); + + expect( + seattle + .where((result) => result.name == 'Seattle') + .map((result) => result.location.id), + contains('America/Los_Angeles'), + ); + expect( + miami + .where((result) => result.name == 'Miami') + .map((result) => result.location.id), + contains('America/New_York'), + ); + expect( + osaka + .where((result) => result.name == 'Osaka') + .map((result) => result.location.id), + contains('Asia/Tokyo'), + ); + }); + + test( + 'location search matches English forms of localized system names', + () async { + final results = await BusyMaxTimeZoneCatalog.searchLocations('Montreal'); + final montreal = results.firstWhere( + (result) => + result.name == 'Montréal' && + result.location.id == 'America/Toronto', + ); + + expect(montreal.countryCode, 'CA'); + expect(montreal.subtitle, 'America/Toronto - CA'); + }, + ); + + test('city search does not match every city in the same timezone', () { + final results = BusyMaxTimeZoneCatalog.searchPreparedLocations('Vancouver'); + final burnaby = BusyMaxTimeZoneCatalog.preparedLocationOptions.firstWhere( + (result) => result.name == 'Burnaby', + ); + + expect(results, hasLength(1)); + expect(results.single.name, 'Vancouver'); + expect(results.single.location.id, 'America/Vancouver'); + expect(burnaby.searchText.toLowerCase(), isNot(contains('vancouver'))); + }); + + test('exact city matches rank before prefixes in other regions', () { + final results = BusyMaxTimeZoneCatalog.searchPreparedLocations('Victoria'); + final exactIndex = results.indexWhere( + (result) => result.name == 'Victoria', + ); + final prefixIndex = results.indexWhere( + (result) => result.name == 'Victoria Falls', + ); + + expect(exactIndex, 0); + expect(prefixIndex, greaterThan(exactIndex)); + }); + + test('timezone catalog preserves unknown existing identifiers', () { + final location = BusyMaxTimeZoneCatalog.location('Custom/Office_Time'); + + expect(location.id, 'Custom/Office_Time'); + expect(location.region, 'Custom'); + expect(location.name, 'Office Time'); + expect(location.code, 'Custom/Office_Time'); + }); +} diff --git a/test/demo/demo_profile_test.dart b/test/demo/demo_profile_test.dart new file mode 100644 index 0000000..2064b6a --- /dev/null +++ b/test/demo/demo_profile_test.dart @@ -0,0 +1,153 @@ +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/config/build_config.dart'; +import 'package:busymax/src/demo/demo_profile.dart'; +import 'package:busymax/src/demo/demo_seed.dart'; +import 'package:busymax/src/features/auth/data/auth_repository.dart'; +import 'package:busymax/src/features/feedback/data/feedback_submission.dart'; +import 'package:busymax/src/features/sync/account_sync_operations.dart'; +import 'package:busymax/src/google_tasks/oauth/oauth_token_store.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('demo settings keep tray and disable background effects', () async { + final settings = busyMaxDemoSettings(BusyMaxDemoTheme.dark); + final store = InMemoryLocalSettingsStore(settings.toJson()); + + expect(settings.themeModePreference, BusyMaxThemeModePreference.dark); + expect(settings.runInBackgroundWhenClosed, isFalse); + expect(settings.showTrayIcon, isTrue); + expect(settings.startMinimizedToTray, isFalse); + expect(settings.notifySyncFailures, isFalse); + expect(settings.notifyConflicts, isFalse); + expect(settings.notifyDueToday, isFalse); + expect(settings.notifyEventReminders, isFalse); + expect(settings.notifyTaskReminders, isFalse); + + final loaded = await loadInitialAppSettings(store); + expect(loaded.themeModePreference, BusyMaxThemeModePreference.dark); + await store.save( + loaded + .copyWith(themeModePreference: BusyMaxThemeModePreference.light) + .toJson(), + ); + expect( + store.snapshot['themeModePreference'], + BusyMaxThemeModePreference.light.name, + ); + }); + + test('demo provider graph remains local and owns its database', () async { + final profile = await BusyMaxDemoProfile.create(); + final settings = busyMaxDemoSettings(BusyMaxDemoTheme.system); + final settingsStore = InMemoryLocalSettingsStore(settings.toJson()); + final container = ProviderContainer( + overrides: [ + buildConfigProvider.overrideWithValue(_demoConfig), + localSettingsStoreProvider.overrideWithValue(settingsStore), + initialAppSettingsProvider.overrideWithValue(settings), + ...profile.overrides, + ], + ); + + final database = container.read(databaseProvider); + expect(database, same(profile.database)); + expect( + await database + .select(database.accounts) + .getSingle() + .then((account) => account.id), + busyMaxDemoAccountId, + ); + final seededTasks = await database.select(database.tasks).get(); + expect( + seededTasks.map((task) => task.title), + contains('Polish calendar prototype'), + ); + + final authState = await container + .read(authRepositoryProvider) + .loadSession(); + expect(authState.status, AuthSessionStatus.signedIn); + expect(authState.accountId, busyMaxDemoAccountId); + expect( + container.read(applicationOAuthGatewayProvider), + isA(), + ); + expect(container.read(applicationMicrosoftOAuthServiceProvider), isNull); + expect( + container.read(oAuthTokenStoreProvider), + isA(), + ); + expect( + container.read( + taskRemoteApiClientForAccountProvider(busyMaxDemoAccountId), + ), + isNull, + ); + + final sync = container.read(accountSyncOperationsProvider); + expect(sync, isA()); + await sync.syncAccount(busyMaxDemoAccountId, full: true); + await sync.syncTasks(busyMaxDemoAccountId, full: false); + await sync.syncCalendar(busyMaxDemoAccountId, full: false); + + expect( + container.read(desktopNotificationBackendProvider), + isA(), + ); + final feedbackReceipt = await container + .read(feedbackSubmissionServiceProvider) + .submit(_feedback); + expect(feedbackReceipt.id, 'demo-demo-submission'); + expect( + container + .read(baseHttpClientProvider) + .get(Uri.parse('https://example.test')), + throwsA(isA()), + ); + + container.dispose(); + await Future.delayed(Duration.zero); + expect( + database.select(database.accounts).get(), + throwsA(isA()), + ); + }); + + test('demo OAuth never needs an external authorization flow', () async { + final gateway = DemoOAuthGateway(); + + final result = await gateway.signIn(); + + expect(result.accountId, busyMaxDemoAccountId); + expect(await gateway.activeAccountId, busyMaxDemoAccountId); + expect(await gateway.readActiveTokenSet(), isNotNull); + expect( + (await gateway.fetchUserInfo(result.tokenSet))?.email, + 'alex@example.com', + ); + + await gateway.revokeAndSignOutAccount(busyMaxDemoAccountId); + expect(await gateway.activeAccountId, isNull); + }); +} + +const _demoConfig = BuildConfig( + googleOAuthClientId: '', + googleOAuthClientSecret: '', + oauthAuthorizationEndpoint: 'https://example.test/authorize', + oauthTokenEndpoint: 'https://example.test/token', + oauthRevocationEndpoint: 'https://example.test/revoke', + useFakeProviderData: true, +); + +const _feedback = FeedbackSubmission( + submissionId: 'demo-submission', + appVersion: '1.0.0', + buildNumber: '1', + category: FeedbackCategory.usability, + subject: 'Demo feedback', + message: 'This remains inside the local demo profile.', + replyEmail: null, +); diff --git a/test/demo/demo_seed_test.dart b/test/demo/demo_seed_test.dart new file mode 100644 index 0000000..5f37eb4 --- /dev/null +++ b/test/demo/demo_seed_test.dart @@ -0,0 +1,53 @@ +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/demo/demo_seed.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:busymax/src/schedule/schedule_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('demo seed uses the current schema and current calendar date', () async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + final now = DateTime(2026, 7, 23, 10, 15); + + await seedBusyMaxDemoData(database, now: now); + + final account = await database.select(database.accounts).getSingle(); + final sources = await database.select(database.calendarSources).get(); + final events = await database.select(database.calendarEvents).get(); + final lists = await database.select(database.taskLists).get(); + final tasks = await database.select(database.tasks).get(); + + expect(account.id, busyMaxDemoAccountId); + expect(account.authState, accountAuthStateSignedIn); + expect(account.provider, 'google'); + expect(sources, hasLength(2)); + expect(events, hasLength(6)); + expect(lists, hasLength(2)); + expect(tasks, hasLength(6)); + expect( + events.where((event) => event.startDate == '2026-07-23'), + isNotEmpty, + ); + expect(tasks.where((task) => task.dueUtc == '2026-07-23'), hasLength(2)); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(await database.select(database.notificationSchedule).get(), isEmpty); + }); + + test('demo data is immediately available through schedule queries', () async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + final today = DateTime(2026, 7, 23); + + await seedBusyMaxDemoData(database, now: today); + + final items = await ScheduleRepository( + database, + ).listItems(range: ScheduleRange.day(today)); + + expect(items.map((item) => item.title), contains('Product planning')); + expect(items.map((item) => item.title), contains('Focus day')); + expect(items.map((item) => item.title), contains('Send meeting notes')); + }); +} diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index c5f970c..7d70b15 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -95,6 +95,26 @@ void main() { expect(await database.select(database.accounts).get(), isEmpty); }); + test('revocation failure does not mask missing-scope guidance', () async { + oAuth.nextTokenSet = _tokenSet(scopes: {googleTasksReadOnlyScope}); + oAuth.revokeAndSignOutError = StateError('revocation unavailable'); + + await expectLater( + repository.signIn(), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'OAuthMissingRequiredScope', + ), + ), + ); + + expect(oAuth.revoked, isTrue); + expect(oAuth.revokedAccountId, 'account-1'); + expect(await database.select(database.accounts).get(), isEmpty); + }); + test( 'loadSession does not touch token storage on signed-out startup', () async { @@ -123,29 +143,6 @@ void main() { }, ); - test('revokeAndSignOut marks account signed out', () async { - await repository.signIn(); - - await repository.revokeAndSignOut(); - - final account = await database.select(database.accounts).getSingle(); - expect(account.authState, 'signed_out'); - expect(oAuth.revoked, isTrue); - expect(oAuth.revokedAccountId, 'account-1'); - }); - - test('signOut marks account signed out without revoking', () async { - await repository.signIn(); - - await repository.signOut(); - - final account = await database.select(database.accounts).getSingle(); - expect(account.authState, 'signed_out'); - expect(oAuth.signedOut, isTrue); - expect(oAuth.signedOutAccountId, 'account-1'); - expect(oAuth.revoked, isFalse); - }); - test( 'markReconnectRequired keeps account row visible but not syncable', () async { @@ -161,7 +158,7 @@ void main() { .watchVisibleAccounts() .first; - expect(oAuth.signedOutAccountId, 'google:g'); + expect(oAuth.clearedAccountId, 'google:g'); expect(account.authState, accountAuthStateReauthRequired); expect(signedInAccounts, isEmpty); expect(visibleAccounts.single.needsReconnect, isTrue); @@ -183,7 +180,27 @@ void main() { expect(notifications.single.title, 'Private google-b reminder'); }); - test('signOut Google account does not sign out Microsoft account', () async { + test( + 'markReconnectRequired selects credential storage by provider', + () async { + final microsoftOAuth = _FakeMicrosoftOAuthService(); + repository = AuthRepository( + oAuth: oAuth, + database: database, + microsoftOAuth: microsoftOAuth, + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + const opaqueAccountId = 'opaque-account-id'; + await _insertAccount(database, opaqueAccountId, TaskProvider.microsoft); + + await repository.markReconnectRequired(opaqueAccountId); + + expect(microsoftOAuth.signOutAccountIds, [opaqueAccountId]); + expect(oAuth.clearedAccountId, null); + }, + ); + + test('removeAccount removes only the selected Google account', () async { final microsoftOAuth = _FakeMicrosoftOAuthService(); repository = AuthRepository( oAuth: oAuth, @@ -193,161 +210,150 @@ void main() { ); await _insertAccount(database, 'google-a', TaskProvider.google); await _insertAccount(database, 'microsoft:m', TaskProvider.microsoft); + await _insertNotification(database, 'google-a'); + await _insertNotification(database, 'microsoft:m'); - await repository.signOut(accountId: 'google-a'); + final result = await repository.removeAccount(accountId: 'google-a'); final accounts = await database.select(database.accounts).get(); - expect(oAuth.signedOutAccountId, 'google-a'); + final notifications = await database + .select(database.notificationSchedule) + .get(); + expect( + result.authorizationRevocationStatus, + AccountAuthorizationRevocationStatus.notRequested, + ); + expect(oAuth.clearedAccountId, 'google-a'); + expect(oAuth.revoked, isFalse); expect(microsoftOAuth.signOutAccountIds, isEmpty); - expect(_authState(accounts, 'google-a'), 'signed_out'); - expect(_authState(accounts, 'microsoft:m'), 'signed_in'); + expect(accounts.map((account) => account.id), ['microsoft:m']); + expect(notifications.map((row) => row.accountId), ['microsoft:m']); }); - test('signOut removes only the target account notifications', () async { - await _insertAccount(database, 'google-a', TaskProvider.google); - await _insertAccount(database, 'google-b', TaskProvider.google); - await _insertNotification(database, 'google-a'); - await _insertNotification(database, 'google-b'); + test( + 'removeAccount clears Microsoft credentials without Google revocation', + () async { + final microsoftOAuth = _FakeMicrosoftOAuthService(); + repository = AuthRepository( + oAuth: oAuth, + database: database, + microsoftOAuth: microsoftOAuth, + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await _insertAccount(database, 'google:g', TaskProvider.google); + await _insertAccount(database, 'microsoft:m', TaskProvider.microsoft); - await repository.signOut(accountId: 'google-a'); + final result = await repository.removeAccount( + accountId: 'microsoft:m', + revokeAuthorization: true, + ); - final notifications = await database - .select(database.notificationSchedule) - .get(); - expect(notifications.map((row) => row.accountId), ['google-b']); - expect(notifications.single.title, 'Private google-b reminder'); - }); + final accounts = await database.select(database.accounts).get(); + expect( + result.authorizationRevocationStatus, + AccountAuthorizationRevocationStatus.notRequested, + ); + expect(oAuth.revoked, isFalse); + expect(oAuth.clearedAccountId, null); + expect(microsoftOAuth.signOutAccountIds, ['microsoft:m']); + expect(accounts.map((account) => account.id), ['google:g']); + }, + ); - test('signOut Microsoft account does not sign out Google account', () async { - final microsoftOAuth = _FakeMicrosoftOAuthService(); - repository = AuthRepository( - oAuth: oAuth, - database: database, - microsoftOAuth: microsoftOAuth, - nowUtc: () => DateTime.utc(2026, 6, 4), - ); - await _insertAccount(database, 'google:g', TaskProvider.google); - await _insertAccount(database, 'microsoft:m', TaskProvider.microsoft); + test( + 'removeAccount can revoke Google authorization before local cleanup', + () async { + await repository.signIn(); - await repository.signOut(accountId: 'microsoft:m'); + final result = await repository.removeAccount( + accountId: 'account-1', + revokeAuthorization: true, + ); - final accounts = await database.select(database.accounts).get(); - expect(oAuth.signedOut, isFalse); - expect(microsoftOAuth.signOutAccountIds, ['microsoft:m']); - expect(_authState(accounts, 'google:g'), 'signed_in'); - expect(_authState(accounts, 'microsoft:m'), 'signed_out'); - }); + expect(await database.select(database.accounts).get(), isEmpty); + expect( + result.authorizationRevocationStatus, + AccountAuthorizationRevocationStatus.succeeded, + ); + expect(oAuth.revokedAccountId, 'account-1'); + expect(oAuth.clearedAccountId, 'account-1'); + }, + ); - test('deleteLocalAccountData removes account row and local tokens', () async { - await repository.signIn(); + test( + 'removeAccount reports revocation failure but still removes locally', + () async { + await repository.signIn(); + oAuth.revocationError = const OAuthException( + 'OAuthRevocationFailed', + 'offline', + ); - await repository.deleteLocalAccountData(accountId: 'account-1'); + final result = await repository.removeAccount( + accountId: 'account-1', + revokeAuthorization: true, + ); - expect(await database.select(database.accounts).get(), isEmpty); - expect(oAuth.signedOutAccountId, 'account-1'); - }); + expect(result.authorizationRevocationFailed, isTrue); + expect(oAuth.clearedAccountId, 'account-1'); + expect(await database.select(database.accounts).get(), isEmpty); + }, + ); - test('deleteLocalAccountData removes only target account row', () async { - await repository.signIn(); + test('removeAccount cascades pending offline operations', () async { + await _insertAccount(database, 'google-a', TaskProvider.google); await database - .into(database.accounts) + .into(database.pendingOps) .insert( - AccountsCompanion.insert( - id: 'account-2', + PendingOpsCompanion.insert( + id: 'pending-1', + accountId: 'google-a', + entityType: 'task', + operation: 'patch_task', + requestJson: '{}', createdAtUtc: '2026-06-04T00:00:00.000Z', updatedAtUtc: '2026-06-04T00:00:00.000Z', - authState: const Value('signed_in'), ), ); - await repository.deleteLocalAccountData(accountId: 'account-1'); + await repository.removeAccount(accountId: 'google-a'); - final accounts = await database.select(database.accounts).get(); - expect(accounts.map((account) => account.id), ['account-2']); - expect(oAuth.signedOutAccountId, 'account-1'); + expect(await database.select(database.pendingOps).get(), isEmpty); }); test( - 'deleteLocalAccountData removes only target account notifications', + 'removeAccount keeps local data when credential cleanup fails', () async { - await _insertAccount(database, 'google-a', TaskProvider.google); - await _insertAccount(database, 'google-b', TaskProvider.google); - await _insertNotification(database, 'google-a'); - await _insertNotification(database, 'google-b'); - - await repository.deleteLocalAccountData(accountId: 'google-a'); - - final notifications = await database - .select(database.notificationSchedule) - .get(); - expect(notifications.map((row) => row.accountId), ['google-b']); - expect(notifications.single.title, 'Private google-b reminder'); - }, - ); - - test('revoking Google account does not sign out Microsoft account', () async { - final microsoftOAuth = _FakeMicrosoftOAuthService(); - repository = AuthRepository( - oAuth: oAuth, - database: database, - microsoftOAuth: microsoftOAuth, - nowUtc: () => DateTime.utc(2026, 6, 4), - ); - await _insertAccount(database, 'google-a', TaskProvider.google); - await _insertAccount(database, 'google-b', TaskProvider.google); - await _insertAccount(database, 'microsoft:m', TaskProvider.microsoft); - - await repository.revokeAndSignOut(accountId: 'google-a'); - - final accounts = await database.select(database.accounts).get(); - expect(oAuth.revokedAccountId, 'google-a'); - expect(microsoftOAuth.signOutAccountIds, isEmpty); - expect(_authState(accounts, 'google-a'), 'signed_out'); - expect(_authState(accounts, 'google-b'), 'signed_in'); - expect(_authState(accounts, 'microsoft:m'), 'signed_in'); - }); - - test('revoke removes only the target account notifications', () async { - await _insertAccount(database, 'google-a', TaskProvider.google); - await _insertAccount(database, 'google-b', TaskProvider.google); - await _insertNotification(database, 'google-a'); - await _insertNotification(database, 'google-b'); - - await repository.revokeAndSignOut(accountId: 'google-a'); + await repository.signIn(); + oAuth.clearError = const OAuthException( + 'SecureStorageFailure', + 'unavailable', + ); - final notifications = await database - .select(database.notificationSchedule) - .get(); - expect(notifications.map((row) => row.accountId), ['google-b']); - expect(notifications.single.title, 'Private google-b reminder'); - }); + await expectLater( + repository.removeAccount(accountId: 'account-1'), + throwsA(isA()), + ); - test('revoking Microsoft account does not revoke Google account', () async { - final microsoftOAuth = _FakeMicrosoftOAuthService(); - repository = AuthRepository( - oAuth: oAuth, - database: database, - microsoftOAuth: microsoftOAuth, - nowUtc: () => DateTime.utc(2026, 6, 4), - ); - await _insertAccount(database, 'google:g', TaskProvider.google); - await _insertAccount(database, 'microsoft:m', TaskProvider.microsoft); + expect(await database.select(database.accounts).get(), hasLength(1)); + }, + ); - await repository.revokeAndSignOut(accountId: 'microsoft:m'); + test('removeAccount is idempotent when the row is already absent', () async { + final result = await repository.removeAccount(accountId: 'missing'); - final accounts = await database.select(database.accounts).get(); - expect(oAuth.revoked, isFalse); - expect(microsoftOAuth.signOutAccountIds, ['microsoft:m']); - expect(_authState(accounts, 'google:g'), 'signed_in'); - expect(_authState(accounts, 'microsoft:m'), 'signed_out'); + expect(result.alreadyRemoved, isTrue); + expect(oAuth.clearedAccountId, null); }); } class FakeOAuthGateway implements OAuthGateway { var revoked = false; - var signedOut = false; String? revokedAccountId; - String? signedOutAccountId; + String? clearedAccountId; + Object? revocationError; + Object? revokeAndSignOutError; + Object? clearError; String? activeId; var activeAccountIdReads = 0; var readActiveTokenSetCalls = 0; @@ -380,21 +386,6 @@ class FakeOAuthGateway implements OAuthGateway { @override Future refreshActiveToken() async => nextTokenSet; - @override - Future signOutAccount(String accountId) async { - signedOut = true; - signedOutAccountId = accountId; - if (activeId == accountId) { - activeId = null; - } - } - - @override - Future signOut() async { - signedOut = true; - activeId = null; - } - @override Future revokeAndSignOutAccount(String accountId) async { revoked = true; @@ -402,17 +393,32 @@ class FakeOAuthGateway implements OAuthGateway { if (activeId == accountId) { activeId = null; } + final error = revokeAndSignOutError; + if (error != null) { + throw error; + } } @override - Future revokeAndSignOut() async { + Future revokeAuthorization(String accountId) async { + final error = revocationError; + if (error != null) { + throw error; + } revoked = true; - activeId = null; + revokedAccountId = accountId; } @override Future clearLocalSession({String? accountId}) async { - activeId = null; + final error = clearError; + if (error != null) { + throw error; + } + clearedAccountId = accountId; + if (accountId == null || activeId == accountId) { + activeId = null; + } } @override @@ -468,10 +474,6 @@ Future _insertNotification(AppDatabase database, String accountId) { ); } -String _authState(List accounts, String id) { - return accounts.singleWhere((account) => account.id == id).authState; -} - class _FakeMicrosoftOAuthService extends MicrosoftOAuthService { _FakeMicrosoftOAuthService() : super( diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index a0a9049..d2bb663 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -3,13 +3,14 @@ import 'dart:io'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_app.dart'; +import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; @@ -17,13 +18,17 @@ import 'package:busymax/src/features/auth/data/auth_repository.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/features/settings/presentation/settings_screen.dart'; import 'package:busymax/src/features/sync/sync_auth_error.dart'; -import 'package:busymax/src/features/tasks/presentation/tasks_workspace.dart'; import 'package:busymax/src/google_tasks/api/google_tasks_api_surface.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_models.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_token_store.dart'; +import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/platform/native_dialog_service.dart'; +import 'package:busymax/src/schedule/schedule_scope.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; +const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); + void main() { late AppDatabase database; late _FakeOAuthGateway oAuth; @@ -31,9 +36,13 @@ void main() { setUp(() { database = AppDatabase(NativeDatabase.memory()); oAuth = _FakeOAuthGateway(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, (_) async => null); }); tearDown(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, null); await database.close(); }); @@ -73,6 +82,98 @@ void main() { await _disposeApp(tester); }); + testWidgets('onboarding content and actions share one responsive rail', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1000, 720); + addTearDown(tester.view.reset); + + await _pumpApp(tester, database: database, oAuth: oAuth); + await tester.pumpAndSettle(); + + void expectAlignedActions({required double expectedRailWidth}) { + final rail = tester.getRect( + find.byKey(const ValueKey('onboarding-content-rail')), + ); + final back = tester.getRect( + find.byKey(const ValueKey('onboarding-back-button')), + ); + final continueButton = tester.getRect( + find.byKey(const ValueKey('onboarding-continue-button')), + ); + + expect(rail.width, closeTo(expectedRailWidth, 0.01)); + expect(back.left, closeTo(rail.left, 0.01)); + expect(continueButton.right, closeTo(rail.right, 0.01)); + } + + expectAlignedActions(expectedRailWidth: 480); + for (final key in const [ + ValueKey('onboarding-back-button'), + ValueKey('onboarding-continue-button'), + ]) { + final button = tester.widget(find.byKey(key)); + expect( + button.style?.padding?.resolve(const {}), + EdgeInsets.zero, + ); + expect( + button.style?.minimumSize?.resolve(const {}), + Size.zero, + ); + expect( + button.style?.backgroundColor?.resolve(const {}), + Colors.transparent, + ); + expect(button.style?.tapTargetSize, MaterialTapTargetSize.shrinkWrap); + } + + tester.view.physicalSize = const Size(420, 720); + await tester.pumpAndSettle(); + + expectAlignedActions(expectedRailWidth: 380); + expect(tester.takeException(), null); + await _disposeApp(tester); + }); + + testWidgets('system settings cards retain their complete shadow gutter', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1000, 720); + addTearDown(tester.view.reset); + + await _pumpApp(tester, database: database, oAuth: oAuth); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add Google account')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + final viewport = tester.getRect( + find.byKey(const ValueKey('onboarding-scroll-viewport')), + ); + final rail = tester.getRect( + find.byKey(const ValueKey('onboarding-content-rail')), + ); + expect(viewport.width, rail.width + BusyMaxSpacing.sm * 2); + expect(rail.left - viewport.left, BusyMaxSpacing.sm); + expect(viewport.right - rail.right, BusyMaxSpacing.sm); + + final cards = find.byType(BusyMaxGroupedSurface); + expect(cards, findsNWidgets(3)); + for (final card in cards.evaluate()) { + final rect = tester.getRect(find.byWidget(card.widget)); + expect(rect.left, rail.left); + expect(rect.right, rail.right); + expect(rect.left, greaterThan(viewport.left)); + expect(rect.right, lessThan(viewport.right)); + } + expect(tester.takeException(), null); + await _disposeApp(tester); + }); + test('setup provider actions use BusyMax row patterns', () { final source = File( 'lib/src/features/auth/presentation/sign_in_screen.dart', @@ -131,6 +232,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Choose system settings'), findsOneWidget); + expect(find.text('Notification detail level'), findsOneWidget); + expect(find.text('Detailed notification text'), findsNothing); await tester.tap(find.text('Finish setup')); await tester.pumpAndSettle(); @@ -149,13 +252,91 @@ void main() { await _completeOnboardingWithGoogle(tester); expect(find.byType(ScheduleWorkspace), findsOneWidget); - expect(find.byType(TasksWorkspace), findsNothing); GoRouter.of(tester.element(find.byType(ScheduleWorkspace))).go('/tasks'); await tester.pumpAndSettle(); expect(find.byType(ScheduleWorkspace), findsOneWidget); - expect(find.byType(TasksWorkspace), findsNothing); + + final accountId = (await database.select(database.accounts).getSingle()).id; + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: accountId, + id: 'list-1', + title: 'Route list', + rawJson: '{}', + createdLocalAtUtc: '2026-06-04T00:00:00.000Z', + updatedLocalAtUtc: '2026-06-04T00:00:00.000Z', + ), + ); + await database.tasksDao.upsertTask( + TasksCompanion.insert( + accountId: accountId, + taskListId: 'list-1', + id: 'task-1', + title: 'Route task', + status: const Value('needsAction'), + rawJson: '{}', + createdLocalAtUtc: '2026-06-04T00:00:00.000Z', + updatedLocalAtUtc: '2026-06-04T00:00:00.000Z', + ), + ); + final router = GoRouter.of(tester.element(find.byType(ScheduleWorkspace))); + router.go( + Uri( + pathSegments: ['', 'tasks', accountId, 'list-1', 'task-1'], + ).toString(), + ); + await tester.pumpAndSettle(); + + final deepLinkedWorkspace = tester.widget( + find.byType(ScheduleWorkspace), + ); + expect(deepLinkedWorkspace.initialScope, ScheduleScope.tasks); + expect(deepLinkedWorkspace.initialTaskAccountId, accountId); + expect(deepLinkedWorkspace.initialTaskListId, 'list-1'); + expect(deepLinkedWorkspace.initialTaskId, 'task-1'); + expect(find.text('Edit Task'), findsOneWidget); + + final routedWorkspaceState = tester.state(find.byType(ScheduleWorkspace)); + final titleField = find.byType(TextField).first; + await tester.enterText(titleField, 'Unsaved route title'); + await tester.pump(); + router.go( + Uri( + pathSegments: ['', 'tasks', accountId, 'list-1', 'missing-task'], + ).toString(), + ); + await tester.pump(); + expect( + tester.state(find.byType(ScheduleWorkspace)), + same(routedWorkspaceState), + ); + await tester.pumpAndSettle(); + + expect(find.text('Discard changes?'), findsOneWidget); + await tester.tap(find.text('Cancel').last); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.pathSegments, [ + 'tasks', + accountId, + 'list-1', + 'task-1', + ]); + expect(find.text('Edit Task'), findsOneWidget); + + router.go(Uri(pathSegments: ['', 'tasks', accountId, 'list-1']).toString()); + await tester.pumpAndSettle(); + expect(find.text('Discard changes?'), findsOneWidget); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + + expect(router.routeInformationProvider.value.uri.pathSegments, [ + 'tasks', + accountId, + 'list-1', + ]); + expect(find.text('Edit Task'), findsNothing); await _disposeApp(tester); }); @@ -228,6 +409,35 @@ void main() { await _disposeApp(tester); }); + testWidgets('Settings enters and exits without a page transition', ( + tester, + ) async { + await _insertAccount( + database, + id: 'google:existing', + provider: TaskProvider.google, + ); + await _pumpApp(tester, database: database, oAuth: oAuth); + await tester.pumpAndSettle(); + + final router = GoRouter.of(tester.element(find.byType(ScheduleWorkspace))); + unawaited(router.push('/settings')); + await tester.pump(); + + final settings = find.byType(SettingsScreen); + expect(settings, findsOneWidget); + final settingsRoute = ModalRoute.of(tester.element(settings))!; + expect(settingsRoute.transitionDuration, Duration.zero); + expect(settingsRoute.reverseTransitionDuration, Duration.zero); + + router.pop(); + await tester.pump(); + + expect(settings, findsNothing); + expect(find.byType(ScheduleWorkspace), findsOneWidget); + await _disposeApp(tester); + }); + testWidgets( 'adding an account keeps Settings open during and after cancellation', (tester) async { @@ -496,6 +706,11 @@ Future _pumpApp( onSignedIn ?? (accountId, initial) async {}, ), syncEngineProvider.overrideWithValue(null), + linuxHeaderBarServiceProvider.overrideWith((ref) { + final service = LinuxHeaderBarService(isLinux: false); + ref.onDispose(service.dispose); + return service; + }), ], child: const BusyMaxApp(), ), @@ -540,18 +755,6 @@ class _FakeOAuthGateway implements OAuthGateway { @override Future refreshActiveToken() async => nextTokenSet; - @override - Future signOutAccount(String accountId) async { - if (activeId == accountId) { - activeId = null; - } - } - - @override - Future signOut() async { - activeId = null; - } - @override Future revokeAndSignOutAccount(String accountId) async { if (activeId == accountId) { @@ -560,9 +763,7 @@ class _FakeOAuthGateway implements OAuthGateway { } @override - Future revokeAndSignOut() async { - activeId = null; - } + Future revokeAuthorization(String accountId) async {} @override Future clearLocalSession({String? accountId}) async { diff --git a/test/features/calendar/data/calendar_repository_test.dart b/test/features/calendar/data/calendar_repository_test.dart index 8ebd7f9..41fa6f8 100644 --- a/test/features/calendar/data/calendar_repository_test.dart +++ b/test/features/calendar/data/calendar_repository_test.dart @@ -99,6 +99,21 @@ void main() { expect(source.isDeleted, isTrue); }); + test('source stream removes locally tombstoned calendars', () async { + await _upsertSource(repository); + expect( + await repository.watchSourcesForAccounts(const ['google:g']).first, + hasLength(1), + ); + + await repository.deleteLocalSource(_sourceId); + + expect( + await repository.watchSourcesForAccounts(const ['google:g']).first, + isEmpty, + ); + }); + test('provider hidden state can return to visible', () async { await repository.upsertSource( accountId: 'google:g', @@ -143,6 +158,87 @@ void main() { expect(await database.select(database.notificationSchedule).get(), isEmpty); expect(schedulerCalls, 1); }); + + test( + 'read-only source rejects event creation before local mutation', + () async { + await repository.upsertSource( + accountId: 'google:g', + source: const CalendarSourceDto( + provider: TaskProvider.google, + providerCalendarId: 'calendar-1', + summary: 'Shared calendar', + readOnly: true, + ), + ); + + await expectLater( + repository.createLocalEvent(_newEventDraft()), + throwsA( + isA().having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.createEvent, + ), + ), + ); + + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + test('read-only source rejects event edits and deletes', () async { + await _upsertSource(repository); + await repository.createLocalEvent(_newEventDraft()); + final event = await database.select(database.calendarEvents).getSingle(); + await (database.update(database.calendarSources) + ..where((row) => row.id.equals(_sourceId))) + .write(const CalendarSourcesCompanion(readOnly: Value(true))); + final pendingBefore = await database.select(database.pendingOps).get(); + + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: event.id, + accountId: event.accountId, + sourceId: event.calendarSourceId, + providerCalendarId: event.providerCalendarId, + title: 'Updated title', + allDay: event.allDay, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + ), + throwsA( + isA().having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.editEvent, + ), + ), + ); + await expectLater( + repository.deleteLocalEvent(event.id), + throwsA( + isA().having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.deleteEvent, + ), + ), + ); + + final unchanged = await database + .select(database.calendarEvents) + .getSingle(); + expect(unchanged.title, ''); + expect(unchanged.isDeleted, isFalse); + expect( + await database.select(database.pendingOps).get(), + hasLength(pendingBefore.length), + ); + }); } const _sourceId = 'google:g|google|calendar-1'; @@ -158,6 +254,16 @@ Future _upsertSource(CalendarRepository repository) { ); } +EventEditorDraft _newEventDraft() { + return EventEditorDraft.newEvent( + accountId: 'google:g', + sourceId: _sourceId, + providerCalendarId: 'calendar-1', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ); +} + Future _seedScheduledEvent( CalendarRepository repository, AppDatabase database, diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index f6a3cdd..819bace 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -6,19 +6,48 @@ import 'package:busymax/src/features/calendar/presentation/event_editor.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:busymax/src/microsoft_calendar/microsoft_calendar_mapper.dart'; +import 'package:busymax/src/platform/native_dialog_service.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:ubuntu_widgets/ubuntu_widgets.dart'; +import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; +const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); + void main() { - testWidgets('header buttons use compact headerbar sizing', (tester) async { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, (_) async => null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); + }); + + testWidgets('editor actions use natural-width themed controls', ( + tester, + ) async { await tester.pumpWidget( localizedTestApp( + theme: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), child: Scaffold( body: EventEditor( initialDraft: EventEditorDraft.newEvent( @@ -36,36 +65,107 @@ void main() { ), ); - expect( - tester.getSize(_headerButtonFinder('Cancel')).width, - inInclusiveRange(100, 180), - ); - expect( - tester.getSize(_headerButtonFinder('Save')).width, - inInclusiveRange(100, 180), - ); expect( tester.getSize(_headerButtonFinder('Cancel')).height, - BusyMaxSizes.headerIconButton, + kYaruButtonHeight, ); expect( tester.getSize(_headerButtonFinder('Save')).height, - BusyMaxSizes.headerIconButton, + kYaruButtonHeight, ); expect( find.ancestor( of: find.text('Cancel'), - matching: find.byWidgetPredicate((widget) => widget is PushButton), + matching: find.byType(FilledButton), ), findsOneWidget, ); expect( find.ancestor( of: find.text('Save'), - matching: find.byWidgetPredicate((widget) => widget is PushButton), + matching: find.byType(ElevatedButton), ), findsOneWidget, ); + final cancelButton = tester.widget( + find.ancestor( + of: find.text('Cancel'), + matching: find.byType(FilledButton), + ), + ); + final saveButton = tester.widget( + find.ancestor( + of: find.text('Save'), + matching: find.byType(ElevatedButton), + ), + ); + expect(cancelButton.style?.fixedSize, isNull); + expect(cancelButton.style?.minimumSize, isNull); + expect(saveButton.style?.fixedSize, isNull); + expect(saveButton.style?.minimumSize, isNull); + final cancelContext = tester.element(find.text('Cancel')); + expect( + cancelButton.style?.textStyle?.resolve(const {})?.fontWeight, + Theme.of(cancelContext).textTheme.titleSmall?.fontWeight, + ); + }); + + testWidgets('event editor groups use the contextual semantic card layer', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + tester.view.physicalSize = const Size(1000, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: BusyMaxModalEditorSurface( + maxWidth: 640, + maxHeight: 720, + child: EventEditor( + initialDraft: EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + start: DateTime.utc(2026, 6, 8), + end: DateTime.utc(2026, 6, 8, 1), + ), + sources: _sources, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ), + ); + + final groupedMaterials = tester.widgetList( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == colors.card, + ), + ), + ); + expect(groupedMaterials, isNotEmpty); + expect(colors.groupedSurface.a, lessThan(1)); + expect(colors.card.a, 1); + expect( + groupedMaterials.every((material) => material.color?.a == 1), + isTrue, + ); + expect( + Color.alphaBlend(colors.groupedSurface, colors.window).toARGB32(), + colors.card.toARGB32(), + ); }); testWidgets('all-day event hides time rows and conference placeholder', ( @@ -90,10 +190,10 @@ void main() { ), ); - expect(find.text('Start time'), findsNothing); - expect(find.text('End Time'), findsNothing); - expect(_plainTextFinder('All Day'), findsOneWidget); - expect(_plainTextFinder('Time Slot'), findsOneWidget); + expect(_timeRowFinder('Start time'), findsNothing); + expect(_timeRowFinder('End Time'), findsNothing); + expect(_plainTextFinder('All day'), findsOneWidget); + expect(_plainTextFinder('Time slot'), findsOneWidget); expect(find.text('No conference'), findsNothing); expect(find.text('Delete Event'), findsNothing); }); @@ -124,7 +224,7 @@ void main() { ), ); - await tester.tap(_plainTextFinder('All Day')); + await tester.tap(_plainTextFinder('All day')); await tester.pump(); await tester.tap(_headerButtonFinder('Save')); @@ -139,7 +239,7 @@ void main() { ); }); - testWidgets('timed event shows separated end date and end time labels', ( + testWidgets('timed event gives date and time fields contextual labels', ( tester, ) async { await tester.pumpWidget( @@ -161,14 +261,80 @@ void main() { ), ); - expect(find.text('Start date'), findsOneWidget); - expect(find.text('Start time'), findsOneWidget); - expect(find.text('End Date'), findsOneWidget); - expect(find.text('End Time'), findsOneWidget); - expect(find.text('End date/time'), findsNothing); + expect(find.text('Start date/time'), findsOneWidget); + expect(find.text('End date/time'), findsOneWidget); + expect( + find.byWidgetPredicate( + (widget) => + widget is DesktopDateValueRow && widget.label == 'Start date', + ), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (widget) => + widget is DesktopTimeValueRow && widget.label == 'Start time', + ), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (widget) => widget is DesktopDateValueRow && widget.label == 'End Date', + ), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (widget) => widget is DesktopTimeValueRow && widget.label == 'End Time', + ), + findsOneWidget, + ); }); - testWidgets('event time popup opens with current time and requires a value', ( + testWidgets('existing timed event renders all date and time values', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + alwaysUse24HourFormat: true, + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'event-1', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Planning', + allDay: false, + start: DateTime(2026, 6, 8, 9, 15), + end: DateTime(2026, 6, 8, 10, 45), + ), + sources: _sources, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ); + + final startDate = _dateTextField(tester, 'Start date'); + final startTime = _timeTextField(tester, 'Start time'); + final endDate = _dateTextField(tester, 'End Date'); + final endTime = _timeTextField(tester, 'End Time'); + + expect(startDate.controller?.text, isNotEmpty); + expect(startTime.controller?.text, '09:15'); + expect(endDate.controller?.text, isNotEmpty); + expect(endTime.controller?.text, '10:45'); + expect(startDate.decoration?.labelText, 'Start date'); + expect(startTime.decoration?.labelText, 'Start time'); + expect(endDate.decoration?.labelText, 'End Date'); + expect(endTime.decoration?.labelText, 'End Time'); + expect(find.text('Enter date'), findsNothing); + expect(find.text('Enter time'), findsNothing); + }); + + testWidgets('event time entry reports an empty required value', ( tester, ) async { await tester.pumpWidget( @@ -190,31 +356,155 @@ void main() { ), ); - await tester.ensureVisible(find.text('Start time')); - await tester.tap(find.text('Start time')); + final fieldFinder = _timeEntryFinder('Start time'); + await tester.ensureVisible(fieldFinder); + var entry = tester.widget(fieldFinder); + expect( + parseDesktopTimeInput( + tester.element(fieldFinder), + entry.controller?.text ?? '', + ), + const TimeOfDay(hour: 9, minute: 0), + reason: 'visible value: ${entry.controller?.text}', + ); + + await tester.tap(fieldFinder); + await tester.pump(); + await _clearTimeEntry(tester, 'Start time'); + + expect(tester.takeException(), isNull); + entry = tester.widget(fieldFinder); + expect(entry.controller?.text, isEmpty); + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); + entry = tester.widget(fieldFinder); + expect(entry.controller?.text, isEmpty); + expect( + find.text( + MaterialLocalizations.of(tester.element(fieldFinder)).invalidTimeLabel, + ), + findsOneWidget, + ); + expect(find.text('OK'), findsNothing); + }); + + testWidgets( + 'invalid visible start time disables Save and Ctrl+S and makes Cancel confirm', + (tester) async { + var saveCalls = 0; + var cancelled = false; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'event-1', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + sources: _sources, + onCancel: () => cancelled = true, + onSave: (_) => saveCalls += 1, + ), + ), + ), + ); + + final startTime = _timeEntryFinder('Start time'); + await tester.enterText(startTime, 'not a time'); + await tester.pump(); + + final saveButton = tester.widget( + _headerButtonFinder('Save'), + ); + expect(saveButton.onPressed, isNull); + expect( + find.text( + MaterialLocalizations.of(tester.element(startTime)).invalidTimeLabel, + ), + findsOneWidget, + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(saveCalls, 0); + + await tester.tap(_headerButtonFinder('Cancel')); + await tester.pumpAndSettle(); + + expect(cancelled, isFalse); + expect(find.text('Discard changes?'), findsOneWidget); + + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + + expect(cancelled, isTrue); + expect(saveCalls, 0); + }, + ); + + testWidgets('switching an invalid timed event to all-day clears validity', ( + tester, + ) async { + EventEditorDraft? saved; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'event-1', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + sources: _sources, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ); + + await tester.enterText(_timeEntryFinder('Start time'), 'not a time'); + await tester.pump(); + expect( + tester.widget(_headerButtonFinder('Save')).onPressed, + isNull, + ); + + await tester.tap(_plainTextFinder('All day')); await tester.pumpAndSettle(); - final fieldFinder = _timeTextEntryFinder(); - final entry = tester.widget(fieldFinder); - expect(entry.controller?.text, '09:00'); + expect(_timeRowFinder('Start time'), findsNothing); + expect(_timeRowFinder('End Time'), findsNothing); expect( - tester - .widgetList(find.byType(EditableText)) - .any((entry) => entry.controller.text.contains('09:00')), - isTrue, + tester.widget(_headerButtonFinder('Save')).onPressed, + isNotNull, ); - await tester.enterText(fieldFinder, ''); + await tester.tap(_headerButtonFinder('Save')); await tester.pump(); - expect(tester.takeException(), isNull); - expect(tester.widget(fieldFinder).controller?.text, isEmpty); + expect(saved?.allDay, isTrue); }); - testWidgets('event time popup accepts midnight input', (tester) async { + testWidgets('event time field accepts midnight input', (tester) async { EventEditorDraft? saved; await tester.pumpWidget( localizedTestApp( + alwaysUse24HourFormat: true, child: Scaffold( body: EventEditor( initialDraft: EventEditorDraft.newEvent( @@ -232,12 +522,12 @@ void main() { ), ); - await tester.ensureVisible(find.text('Start time')); - await tester.tap(find.text('Start time')); - await tester.pumpAndSettle(); - await tester.enterText(_timeTextEntryFinder(), '00'); - await tester.tap(find.text('OK')); - await tester.pumpAndSettle(); + final fieldFinder = _timeEntryFinder('Start time'); + await tester.ensureVisible(fieldFinder); + await tester.tap(fieldFinder); + await tester.pump(); + await _clearTimeEntry(tester, 'Start time'); + await _enterTime(tester, label: 'Start time', hour: '00', minute: '00'); await tester.tap(_headerButtonFinder('Save')); @@ -904,13 +1194,69 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Add Reminder'), findsNothing); - expect(find.text('5 minutes before'), findsOneWidget); + expect( + find.descendant( + of: find.byType(BusyMaxComboRow), + matching: find.text('5 minutes before'), + ), + findsOneWidget, + ); }); - testWidgets('Microsoft event categories can be selected from suggestions', ( + testWidgets( + 'Microsoft event categories use Yaru autocomplete keyboard selection', + (tester) async { + EventEditorDraft? saved; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'event-1', + accountId: 'microsoft-account', + sourceId: 'microsoft-source', + providerCalendarId: 'ms-cal-1', + title: 'Planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + categories: const ['Home'], + ), + sources: _microsoftSources, + categorySuggestionsByAccount: const { + 'microsoft-account': ['Home', 'Work'], + }, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ); + + await tester.ensureVisible(find.text('Add category')); + expect(find.text('Home'), findsOneWidget); + + await tester.tap(find.text('Add category')); + await tester.pumpAndSettle(); + expect(find.byType(YaruAutocomplete), findsOneWidget); + + await tester.enterText( + find.byKey(const Key('event-category-input')), + 'work', + ); + await tester.pumpAndSettle(); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + await tester.tap(_headerButtonFinder('Save')); + + expect(saved?.categories, ['Home', 'Work']); + }, + ); + + testWidgets('Escape cancels category entry without closing event editor', ( tester, ) async { - EventEditorDraft? saved; + var editorCancelled = false; await tester.pumpWidget( localizedTestApp( child: Scaffold( @@ -924,31 +1270,33 @@ void main() { allDay: false, start: DateTime.utc(2026, 6, 8, 9), end: DateTime.utc(2026, 6, 8, 10), - categories: const ['Home'], ), sources: _microsoftSources, - categorySuggestionsByAccount: const { - 'microsoft-account': ['Home', 'Work'], - }, - onCancel: () {}, - onSave: (draft) => saved = draft, + onCancel: () => editorCancelled = true, + onSave: (_) {}, ), ), ), ); await tester.ensureVisible(find.text('Add category')); - expect(find.text('Home'), findsOneWidget); - await tester.tap(find.text('Add category')); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(const Key('event-category-input')), 'wo'); - await tester.pumpAndSettle(); - await tester.tap(find.text('Work').last); + expect(find.byKey(const Key('event-category-input')), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('event-category-input'))) + .focusNode + ?.hasFocus, + isTrue, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); - await tester.tap(_headerButtonFinder('Save')); - expect(saved?.categories, ['Home', 'Work']); + expect(find.byKey(const Key('event-category-input')), findsNothing); + expect(find.widgetWithText(ActionChip, 'Add category'), findsOneWidget); + expect(editorCancelled, isFalse); }); testWidgets('Google event editor does not show categories', (tester) async { @@ -1013,6 +1361,42 @@ void main() { }, ); + testWidgets('Escape routes dirty event cancellation through confirmation', ( + tester, + ) async { + var cancelled = false; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + start: DateTime.utc(2026, 6, 8), + end: DateTime.utc(2026, 6, 8, 1), + ), + sources: _sources, + onCancel: () => cancelled = true, + onSave: (_) {}, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextFormField).first, 'Changed event'); + _focusEditorShortcuts(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(cancelled, isFalse); + expect(find.text('Discard changes?'), findsOneWidget); + + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + expect(cancelled, isTrue); + }); + test('event editor is opened through the shared BusyMax dialog route', () { final editor = File( 'lib/src/features/calendar/presentation/event_editor.dart', @@ -1025,10 +1409,12 @@ void main() { expect(editor, contains('showBusyMaxEventEditorDialog')); expect(editor, contains('showBusyMaxModalEditorDialog')); expect(editor, isNot(contains('showDialog'))); - expect(dialogs, contains('setModalBarrierVisible(true)')); + expect(dialogs, contains('await acquireBusyMaxModalBarrier(')); + expect(dialogs, contains('await releaseBusyMaxModalBarrier(')); + expect(dialogs, contains('shadesHeader: shadesHeader')); expect( dialogs, - contains('barrierColor: busyMaxModalBarrierColor(context)'), + contains('barrierColor ?? busyMaxModalBarrierColor(context)'), ); expect(dialogs, contains('BusyMaxModalEditorSurface')); expect(workspace, contains('showBusyMaxEventEditorDialog')); @@ -1045,31 +1431,54 @@ void main() { expect(design, contains('class BusyMaxModalEditorScaffold')); expect(design, contains('BusyMaxEditorHeader(')); expect(design, contains('SingleChildScrollView')); - expect(design, contains('BusyMaxHeaderPushButton.outlined')); - expect(design, contains('BusyMaxHeaderPushButton.filled')); - expect( - design, - contains('EdgeInsets.symmetric(horizontal: BusyMaxSpacing.xl)'), - ); + final headerStart = design.indexOf('class BusyMaxEditorHeader'); + final headerEnd = design.indexOf('class BusyMaxModeSwitcher'); + final header = design.substring(headerStart, headerEnd); + expect(header, contains('child: Row(')); + expect(header, contains('AlignmentDirectional.centerStart')); + expect(header, contains('child: BusyMaxPushButton.standard(')); + expect(header, contains('AlignmentDirectional.centerEnd')); + expect(header, contains('child: BusyMaxPushButton.suggested(')); + expect(header, contains('heightFactor: 1')); + expect(header, contains('textTheme.titleSmall')); + expect(header, isNot(contains('child: FilledButton('))); + expect(header, isNot(contains('child: ElevatedButton('))); + expect(header, isNot(contains('NavigationToolbar('))); + expect(header, isNot(contains('ConstrainedBox('))); + expect(header, isNot(contains('kPushButtonSize'))); + expect(header, isNot(contains('kYaruButtonHeight'))); + expect(design, isNot(contains('BusyMaxHeaderPushButton'))); expect(design, contains('textAlign: TextAlign.center')); expect(design, contains('textTheme.titleMedium')); expect(editor, isNot(contains('BusyMaxDialogCloseButton'))); }); test( - 'editor row hover uses subtle foreground overlay, not selected color', + 'editor rows reuse the shared native hover role, not a control fill', () { final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); - final hoverStart = design.indexOf('Color busyMaxEditorRowHoverColor'); + final editor = File( + 'lib/src/features/calendar/presentation/event_editor.dart', + ).readAsStringSync(); + final hoverStart = design.indexOf('Color busyMaxRowHoverColor'); final hoverEnd = design.indexOf('Color busyMaxPanelBorder'); final hoverSource = design.substring(hoverStart, hoverEnd); - expect(hoverSource, contains('surfaceColors.foreground.withValues')); - expect(hoverSource, contains('Brightness.dark ? 0.045 : 0.055')); + expect(hoverSource, contains('final theme = Theme.of(context)')); + expect(hoverSource, contains('final hover = theme.hoverColor')); + expect( + hoverSource, + contains('BusyMaxAlpha.groupedRowLightHoverStrength'), + ); + expect(design, contains('groupedRowLightHoverStrength = 0.50')); + expect( + hoverSource, + contains('theme.colorScheme.brightness == Brightness.dark'), + ); expect(hoverSource, isNot(contains('.controlHover'))); - expect(hoverSource, isNot(contains('Theme.of(context).hoverColor'))); expect(hoverSource, isNot(contains('primaryContainer'))); expect(hoverSource, isNot(contains('colorScheme.primary'))); + expect(editor, isNot(contains('hoverColor:'))); }, ); @@ -1129,26 +1538,28 @@ void main() { ); }); - test('event editor text fields use plain borderless decoration', () { + test('event editor text fields use shared grouped-row decoration', () { final editor = File( 'lib/src/features/calendar/presentation/event_editor.dart', ).readAsStringSync(); + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); - expect(editor, contains('_plainEventFieldDecoration')); - expect(editor, contains('filled: false')); - expect(editor, contains('fillColor: Colors.transparent')); - expect(editor, contains('hoverColor: Colors.transparent')); - expect(editor, contains('labelText: labelText')); - expect(editor, contains('labelStyle: labelStyle')); - expect(editor, contains('floatingLabelStyle: labelStyle')); + expect(editor, contains('busyMaxGroupedTextFieldDecoration')); + expect(editor, isNot(contains('_plainEventFieldDecoration'))); + expect(design, contains('filled: false')); + expect(design, contains('fillColor: Colors.transparent')); + expect(design, contains('hoverColor: Colors.transparent')); + expect(design, contains('labelText: labelText')); + expect(design, contains('labelStyle: labelStyle')); + expect(design, contains('floatingLabelStyle: labelStyle')); expect( - editor, + design, contains('floatingLabelBehavior: FloatingLabelBehavior.auto'), ); - expect(editor, contains('enabledBorder: InputBorder.none')); - expect(editor, contains('focusedBorder: InputBorder.none')); - expect(editor, contains('errorBorder: InputBorder.none')); - expect(editor, contains('focusedErrorBorder: InputBorder.none')); + expect(design, contains('enabledBorder: InputBorder.none')); + expect(design, contains('focusedBorder: InputBorder.none')); + expect(design, contains('errorBorder: InputBorder.none')); + expect(design, contains('focusedErrorBorder: InputBorder.none')); }); test( @@ -1162,11 +1573,10 @@ void main() { expect(editor, contains('return BusyMaxComboRow')); expect(editor, contains('title: context.l10n.calendar')); expect(editor, contains('leading: const Icon(YaruIcons.calendar)')); - expect(editor, contains('menuItemBuilder: (context, value)')); - expect(editor, contains('selectedBuilder: (context, value)')); - expect(editor, contains('_calendarSourceSelectedChoice')); - expect(editor, contains('mainAxisAlignment: MainAxisAlignment.end')); - expect(editor, contains('textAlign: TextAlign.end')); + expect(editor, contains('selectorLeadingBuilder: (context, value)')); + expect(editor, isNot(contains('menuItemBuilder:'))); + expect(editor, isNot(contains('selectedBuilder:'))); + expect(editor, isNot(contains('_calendarSourceSelectedChoice'))); expect(editor, contains('class _CalendarSourceDot')); expect(editor, contains('source.backgroundColor')); expect(editor, contains('ScheduleProjection.deterministicSourceColor')); @@ -1217,18 +1627,14 @@ void main() { expect(editor, isNot(contains('title: l10n.delete,'))); }); - test('event combo selected values are right aligned', () { + test('event combos do not overlay custom selected-value rendering', () { final editor = File( 'lib/src/features/calendar/presentation/event_editor.dart', ).readAsStringSync(); - expect(editor, contains('Widget _eventEditorSelectedValue')); - expect(editor, contains('alignment: Alignment.centerRight')); - expect(editor, contains('textAlign: TextAlign.end')); - expect( - '_eventEditorSelectedValue'.allMatches(editor).length, - greaterThanOrEqualTo(4), - ); + expect(editor, isNot(contains('_eventEditorSelectedValue'))); + expect(editor, isNot(contains('selectedBuilder:'))); + expect(editor, contains('selectorLeadingBuilder:')); }); test('event editor prominent actions use semibold action style', () { @@ -1243,28 +1649,60 @@ void main() { expect(editor, contains('l10n.deleteEvent')); }); - testWidgets('combo dropdown trigger is transparent in all states', ( + testWidgets('combo selector uses a flat native-style row trigger', ( tester, ) async { - late BuildContext capturedContext; + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; await tester.pumpWidget( localizedTestApp( - child: Builder( - builder: (context) { - capturedContext = context; - return const SizedBox.shrink(); - }, + child: Theme( + data: theme, + child: SizedBox( + width: 480, + child: BusyMaxComboRow( + title: 'Calendar', + values: const ['Personal', 'Work'], + selected: 'Personal', + labelFor: (value) => value, + onSelected: (_) {}, + ), + ), ), ), ); - final background = busyMaxDropdownButtonStyle( - capturedContext, - ).backgroundColor!; - - expect(background.resolve(const {}), Colors.transparent); - expect(background.resolve({WidgetState.hovered}), Colors.transparent); - expect(background.resolve({WidgetState.pressed}), Colors.transparent); + expect(find.byType(BusyMaxMenuButton), findsOneWidget); + final triggerFinder = find.descendant( + of: find.byType(BusyMaxComboRow), + matching: find.byWidgetPredicate( + (widget) => widget is YaruListTile && widget.focusNode != null, + ), + ); + final trigger = tester.widget(triggerFinder); + expect(trigger.onTap, isNotNull); + expect( + tester.getSize(triggerFinder).height, + greaterThan(kYaruButtonHeight), + ); + expect( + find.descendant( + of: find.byType(BusyMaxComboRow), + matching: find.byWidgetPredicate( + (widget) => widget is ButtonStyleButton && widget is! IconButton, + ), + ), + findsNothing, + ); + final restingSurface = tester.widget( + find.descendant(of: triggerFinder, matching: find.byType(Material)).first, + ); + expect(restingSurface.type, MaterialType.canvas); + expect(restingSurface.color, Colors.transparent); + expect(restingSurface.color, isNot(colors.control)); }); } @@ -1287,12 +1725,53 @@ BusyMaxComboRow _comboRow(WidgetTester tester, String title) { ); } -Finder _timeTextEntryFinder() { +Finder _timeRowFinder(String label) { return find.byWidgetPredicate( - (widget) => widget is TextFormField && widget.controller != null, + (widget) => widget is DesktopTimeValueRow && widget.label == label, + ); +} + +TextField _dateTextField(WidgetTester tester, String label) { + final row = find.byWidgetPredicate( + (widget) => widget is DesktopDateValueRow && widget.label == label, + ); + return tester.widget( + find.descendant(of: row, matching: find.byType(TextField)), ); } +TextField _timeTextField(WidgetTester tester, String label) { + return tester.widget( + find.descendant( + of: _timeRowFinder(label), + matching: find.byType(TextField), + ), + ); +} + +Finder _timeEntryFinder(String label) { + return find.descendant( + of: _timeRowFinder(label), + matching: find.byType(TextFormField), + ); +} + +Future _clearTimeEntry(WidgetTester tester, String label) async { + await tester.enterText(_timeEntryFinder(label), ''); + await tester.pump(); +} + +Future _enterTime( + WidgetTester tester, { + required String label, + required String hour, + required String minute, +}) async { + final entry = _timeEntryFinder(label); + await tester.enterText(entry, '$hour:$minute'); + await tester.pump(); +} + Finder _plainTextFinder(String label) { return find.byWidgetPredicate( (widget) => widget is Text && widget.data == label, diff --git a/test/features/feedback/presentation/feedback_dialog_test.dart b/test/features/feedback/presentation/feedback_dialog_test.dart index 7aa5870..8189912 100644 --- a/test/features/feedback/presentation/feedback_dialog_test.dart +++ b/test/features/feedback/presentation/feedback_dialog_test.dart @@ -1,15 +1,78 @@ import 'dart:async'; +import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/features/feedback/data/feedback_api_client.dart'; import 'package:busymax/src/features/feedback/data/feedback_submission.dart'; import 'package:busymax/src/features/feedback/presentation/feedback_dialog.dart'; +import 'package:busymax/src/platform/native_dialog_service.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; +const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); + void main() { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, (_) async => null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); + }); + + testWidgets('text inputs delegate their surface to the Yaru grouped rows', ( + tester, + ) async { + final service = _FakeFeedbackService((_) async { + return const FeedbackReceipt(id: 'unused'); + }); + await _pumpDialog(tester, service); + + expect(find.text('Report an issue'), findsOneWidget); + for (final key in const [ + 'feedback-subject', + 'feedback-message', + 'feedback-reply-email', + ]) { + final field = tester.widget(find.byKey(Key(key))); + final decoration = field.decoration!; + + expect(decoration.filled, isFalse); + expect(decoration.fillColor, Colors.transparent); + expect(decoration.hoverColor, Colors.transparent); + expect(decoration.border, InputBorder.none); + expect(decoration.enabledBorder, InputBorder.none); + expect(decoration.focusedBorder, InputBorder.none); + expect(decoration.disabledBorder, InputBorder.none); + expect(decoration.errorBorder, InputBorder.none); + expect(decoration.focusedErrorBorder, InputBorder.none); + expect(decoration.contentPadding, EdgeInsets.zero); + + final tile = tester.widget( + find.ancestor( + of: find.byKey(Key(key)), + matching: find.byType(YaruListTile), + ), + ); + expect(tile.onTap, isNull); + expect(tile.hoverColor, isNull); + } + }); + testWidgets('shows required-field validation without sending', ( tester, ) async { @@ -33,6 +96,30 @@ void main() { expect(service.submissions, isEmpty); }); + testWidgets('category can return to the unselected placeholder', ( + tester, + ) async { + final service = _FakeFeedbackService((_) async { + return const FeedbackReceipt(id: 'unexpected'); + }); + await _pumpDialog(tester, service); + final selector = _feedbackCategoryTrigger(); + + await tester.tap(selector); + await tester.pumpAndSettle(); + await tester.tap(_feedbackCategoryMenuItem('Problem or bug')); + await tester.pumpAndSettle(); + await tester.tap(selector); + await tester.pumpAndSettle(); + await tester.tap(_feedbackCategoryMenuItem('Select a category')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Submit')); + await tester.pump(); + + expect(find.text('Select a category.'), findsOneWidget); + expect(service.submissions, isEmpty); + }); + testWidgets('rejects an invalid optional reply email', (tester) async { final service = _FakeFeedbackService((_) async { return const FeedbackReceipt(id: 'unexpected'); @@ -146,6 +233,68 @@ void main() { await tester.pumpAndSettle(); }); + testWidgets('Escape confirms before discarding a feedback draft', ( + tester, + ) async { + final service = _FakeFeedbackService((_) async { + return const FeedbackReceipt(id: 'unexpected'); + }); + var cancelCount = 0; + await _pumpDialog(tester, service, onCancel: () => cancelCount += 1); + await _enterValidRequiredFields(tester); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(cancelCount, 0); + expect(find.text('Discard changes?'), findsOneWidget); + + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + expect(cancelCount, 1); + }); + + testWidgets('Russian discard action is distinct from cancel', (tester) async { + final service = _FakeFeedbackService((_) async { + return const FeedbackReceipt(id: 'unexpected'); + }); + var cancelCount = 0; + await _pumpDialog( + tester, + service, + locale: const Locale('ru'), + onCancel: () => cancelCount += 1, + ); + await tester.enterText( + find.byKey(const Key('feedback-subject')), + 'Черновик', + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + final confirmation = find.byType(BusyMaxConfirmDialog); + expect( + find.descendant( + of: confirmation, + matching: find.text('Не сохранять изменения?'), + ), + findsOneWidget, + ); + expect( + find.descendant(of: confirmation, matching: find.text('Отмена')), + findsOneWidget, + ); + expect( + find.descendant(of: confirmation, matching: find.text('Не сохранять')), + findsOneWidget, + ); + + await tester.tap(find.text('Не сохранять')); + await tester.pumpAndSettle(); + expect(cancelCount, 1); + }); + testWidgets('clears the form and shows the server reference on success', ( tester, ) async { @@ -155,7 +304,7 @@ void main() { await _pumpDialog(tester, service); await _enterValidRequiredFields(tester); - final checkbox = tester.widget( + final checkbox = tester.widget( find.byKey(const Key('feedback-technical-details')), ); expect(checkbox.value, isFalse); @@ -322,10 +471,12 @@ Future _pumpDialog( FeedbackSubmissionService service, { FeedbackSubmissionIdGenerator? submissionIdGenerator, String osVersion = 'Test Linux', + Locale locale = const Locale('en'), VoidCallback? onCancel, }) async { await tester.pumpWidget( localizedTestApp( + locale: locale, child: Scaffold( body: Center( child: SizedBox( @@ -352,9 +503,9 @@ Future _pumpDialog( } Future _enterValidRequiredFields(WidgetTester tester) async { - await tester.tap(find.byKey(const Key('feedback-category'))); + await tester.tap(_feedbackCategoryTrigger()); await tester.pumpAndSettle(); - await tester.tap(find.text('Problem or bug').last); + await tester.tap(_feedbackCategoryMenuItem('Problem or bug')); await tester.pumpAndSettle(); await tester.enterText( find.byKey(const Key('feedback-subject')), @@ -366,6 +517,20 @@ Future _enterValidRequiredFields(WidgetTester tester) async { ); } +Finder _feedbackCategoryTrigger() { + return find.descendant( + of: find.byKey(const Key('feedback-category')), + matching: find.byType(BusyMaxMenuButton), + ); +} + +Finder _feedbackCategoryMenuItem(String label) { + return find.ancestor( + of: find.text(label).last, + matching: find.byWidgetPredicate((widget) => widget is PopupMenuItem), + ); +} + String _fieldText(WidgetTester tester, String key) { return tester.widget(find.byKey(Key(key))).controller!.text; } diff --git a/test/features/notifications/desktop_notification_service_test.dart b/test/features/notifications/desktop_notification_service_test.dart index 35fa515..903311e 100644 --- a/test/features/notifications/desktop_notification_service_test.dart +++ b/test/features/notifications/desktop_notification_service_test.dart @@ -21,7 +21,9 @@ void main() { final backend = _FakeNotificationBackend(); final service = DesktopNotificationService( backend: backend, - settings: AppSettings.defaults().copyWith(detailedNotifications: true), + settings: AppSettings.defaults().copyWith( + notificationDetailLevel: NotificationDetailLevel.normal, + ), ); await service.notifySyncFailure( @@ -47,17 +49,186 @@ void main() { expect(backend.notifications, isEmpty); }); - test('notification strings localize supported locales', () async { + test('notification strings use the Finnish ARB catalog', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: const Locale('fi'), + ); + + await service.notifyDueToday(2); + + expect(backend.notifications.single.summary, 'Tänään erääntyvät tehtävät'); + expect(backend.notifications.single.body, '2 tehtävää erääntyy tänään.'); + }); + + test('notification strings use the Estonian ARB catalog', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: const Locale('et'), + ); + + await service.notifyDueToday(2); + + expect(backend.notifications.single.summary, 'Täna tähtuvad ülesanded'); + expect(backend.notifications.single.body, '2 ülesannet tähtub täna.'); + }); + + test('notification strings use Russian plural rules', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: const Locale('ru'), + ); + + await service.notifyDueToday(22); + + expect(backend.notifications.single.summary, 'Задачи на сегодня'); + expect( + backend.notifications.single.body, + 'Сегодня нужно выполнить 22 задачи.', + ); + }); + + test('notification strings use the Portuguese ARB catalog', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: const Locale('pt'), + ); + + await service.notifyDueToday(2); + + expect(backend.notifications.single.summary, 'Tarefas com prazo para hoje'); + expect( + backend.notifications.single.body, + 'Há 2 tarefas com prazo para hoje.', + ); + }); + + test('notification strings use the Italian ARB catalog', () async { final backend = _FakeNotificationBackend(); final service = DesktopNotificationService( backend: backend, settings: AppSettings.defaults().copyWith(notifyDueToday: true), - locale: const Locale('es'), + locale: const Locale('it'), ); await service.notifyDueToday(2); - expect(backend.notifications.single.summary, 'Tareas que vencen hoy'); + expect(backend.notifications.single.summary, 'Attività in scadenza oggi'); + expect(backend.notifications.single.body, '2 attività scadono oggi.'); + }); + + test('notification strings use the Vietnamese ARB catalog', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: const Locale('vi'), + ); + + await service.notifyDueToday(2); + + expect(backend.notifications.single.summary, 'Công việc đến hạn hôm nay'); + expect( + backend.notifications.single.body, + 'Có 2 công việc đến hạn hôm nay.', + ); + }); + + test('notification strings use the new Asian ARB catalogs', () async { + final cases = <({Locale locale, String summary, String body})>[ + ( + locale: const Locale('hi'), + summary: 'आज देय कार्य', + body: 'आज 2 कार्य देय हैं।', + ), + ( + locale: const Locale('ja'), + summary: '今日が期限のタスク', + body: '今日が期限のタスクが2件あります。', + ), + ( + locale: const Locale('ko'), + summary: '오늘 마감인 할 일', + body: '오늘 마감인 할 일이 2개 있습니다.', + ), + ( + locale: const Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hans', + ), + summary: '今天到期的任务', + body: '今天有 2 项任务到期。', + ), + ( + locale: const Locale('zh', 'TW'), + summary: '今天到期的待辦事項', + body: '今天有 2 項待辦事項到期。', + ), + ]; + + for (final testCase in cases) { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: testCase.locale, + ); + + await service.notifyDueToday(2); + + expect(backend.notifications.single.summary, testCase.summary); + expect(backend.notifications.single.body, testCase.body); + } + }); + + test('notification strings use Arabic plural rules', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: const Locale('ar'), + ); + + for (final count in [1, 2, 3, 11]) { + await service.notifyDueToday(count); + } + + expect(backend.notifications.map((notification) => notification.body), [ + 'هناك مهمة واحدة مستحقة اليوم.', + 'هناك مهمتان مستحقتان اليوم.', + 'هناك \u20683\u2069 مهام مستحقة اليوم.', + 'هناك \u206811\u2069 مهمة مستحقة اليوم.', + ]); + }); + + test('notification strings use Persian plural rules', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: const Locale('fa'), + ); + + for (final count in [1, 2]) { + await service.notifyDueToday(count); + } + + expect( + backend.notifications.map((notification) => notification.summary), + everyElement('کارهای دارای سررسید امروز'), + ); + expect(backend.notifications.map((notification) => notification.body), [ + 'امروز یک کار سررسید دارد.', + 'امروز \u2068۲\u2069 کار سررسید دارند.', + ]); }); test('reminder notification details are visible by default', () async { @@ -126,23 +297,51 @@ void main() { }); test( - 'detailed notification switch overrides private reminder text', + 'private detail level also hides diagnostic notification text', () async { final backend = _FakeNotificationBackend(); final service = DesktopNotificationService( backend: backend, settings: AppSettings.defaults().copyWith( - detailedNotifications: true, notificationDetailLevel: NotificationDetailLevel.private, ), ); - await service.notifyEventReminder('Doctor', 'Clinic'); + await service.notifySyncFailure('Private server response'); - expect(backend.notifications.single.summary, 'Doctor'); - expect(backend.notifications.single.body, 'Clinic'); + expect(backend.notifications.single.body, isNot(contains('Private'))); + expect( + backend.notifications.single.body, + contains('Details are hidden by privacy settings.'), + ); }, ); + + test('configured overnight quiet hours use an end-exclusive range', () async { + final settings = AppSettings.defaults().copyWith( + quietHoursEnabled: true, + quietHoursStart: '22:00', + quietHoursEnd: '07:00', + ); + final quietBackend = _FakeNotificationBackend(); + final quietService = DesktopNotificationService( + backend: quietBackend, + settings: settings, + now: () => DateTime(2026, 1, 1, 23, 30), + ); + final awakeBackend = _FakeNotificationBackend(); + final awakeService = DesktopNotificationService( + backend: awakeBackend, + settings: settings, + now: () => DateTime(2026, 1, 2, 7), + ); + + await quietService.notifySyncFailure('Offline'); + await awakeService.notifySyncFailure('Offline'); + + expect(quietBackend.notifications, isEmpty); + expect(awakeBackend.notifications, hasLength(1)); + }); } class _FakeNotificationBackend implements DesktopNotificationBackend { diff --git a/test/features/schedule/application/compact_agenda_data_test.dart b/test/features/schedule/application/compact_agenda_data_test.dart deleted file mode 100644 index 6ec5e58..0000000 --- a/test/features/schedule/application/compact_agenda_data_test.dart +++ /dev/null @@ -1,183 +0,0 @@ -import 'package:busymax/src/app/app_bootstrap.dart'; -import 'package:busymax/src/features/schedule/application/compact_agenda_data.dart'; -import 'package:busymax/src/features/schedule/application/compact_agenda_snapshot.dart'; -import 'package:busymax/src/schedule/schedule_item.dart'; -import 'package:busymax/src/schedule/schedule_range.dart'; -import 'package:busymax/src/task_providers/task_provider.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('compact agenda bridge loader does not open the database', () async { - var loadedFromBridge = false; - final expected = _agendaData(); - final container = ProviderContainer( - overrides: [ - databaseProvider.overrideWith((ref) { - throw StateError('compact agenda opened databaseProvider'); - }), - compactAgendaDataLoaderProvider.overrideWithValue((ref, query) async { - loadedFromBridge = true; - expect(query, CompactAgendaQuery.initial); - return expected; - }), - ], - ); - addTearDown(container.dispose); - - final data = await container.read(compactAgendaDataProvider.future); - - expect(loadedFromBridge, isTrue); - expect(data.generatedAt, expected.generatedAt); - expect(data.items.single.title, 'Bridge event'); - }); - - test('compact agenda retries temporary SQLITE_BUSY failures', () async { - var attempts = 0; - final expected = _agendaData(); - final container = ProviderContainer(); - addTearDown(container.dispose); - final provider = FutureProvider((ref) { - return loadCompactAgendaDataWithRetry( - ref, - CompactAgendaQuery.initial, - retryDelays: const [Duration.zero, Duration.zero], - delay: (_) async {}, - loader: (ref, query) async { - attempts += 1; - if (attempts < 3) { - throw Exception('SQLite exception(5): database is locked'); - } - return expected; - }, - ); - }); - - final data = await container.read(provider.future); - - expect(attempts, 3); - expect(data.items.single.title, 'Bridge event'); - }); - - test('compact agenda snapshot round-trips schedule data', () { - final expected = _agendaData( - items: [ - _event( - 'Planning', - start: DateTime(2026, 6, 10, 6), - editorStart: DateTime(2026, 6, 10, 9), - ), - _task('Review notes', start: DateTime(2026, 6, 10, 11)), - ], - ); - - final decoded = decodeCompactAgendaData(encodeCompactAgendaData(expected)); - - expect(decoded.today, expected.today); - expect(decoded.range.end, expected.range.end); - expect(decoded.items, hasLength(2)); - expect(decoded.items[0], isA()); - expect(decoded.items[0].title, 'Planning'); - final event = decoded.items[0] as CalendarScheduleItem; - expect(event.providerRecurringEventId, 'series-master'); - expect(event.recurrence, ['RRULE:FREQ=WEEKLY']); - expect(event.attendees, [ - {'email': 'guest@example.com'}, - ]); - expect(event.start, DateTime(2026, 6, 10, 6)); - expect(event.end, DateTime(2026, 6, 10, 7)); - expect(event.editorStart, DateTime(2026, 6, 10, 9)); - expect(event.editorEnd, DateTime(2026, 6, 10, 10)); - expect(decoded.items[1], isA()); - expect(decoded.items[1].title, 'Review notes'); - expect(decoded.hasSignedInAccounts, isTrue); - expect(decoded.hasSources, isTrue); - }); - - test('compact agenda query snapshot uses stable primitive fields', () { - const query = CompactAgendaQuery( - futureDays: 60, - overdueLimit: 16, - noDateLimit: 24, - ); - - final decoded = decodeCompactAgendaQuery(encodeCompactAgendaQuery(query)); - - expect(decoded.futureDays, 60); - expect(decoded.overdueLimit, 16); - expect(decoded.noDateLimit, 24); - }); -} - -CompactAgendaData _agendaData({List? items}) { - final today = DateTime(2026, 6, 10); - return CompactAgendaData( - today: today, - range: ScheduleRange( - start: today, - end: today.add(const Duration(days: 30)), - ), - items: items ?? [_event('Bridge event', start: today)], - hasMoreOverdueTasks: false, - hasMoreNoDateTasks: false, - hasSignedInAccounts: true, - hasSources: true, - generatedAt: today.add(const Duration(minutes: 5)), - ); -} - -CalendarScheduleItem _event( - String title, { - required DateTime start, - DateTime? editorStart, -}) { - return CalendarScheduleItem( - id: title, - accountId: 'account', - provider: TaskProvider.google, - sourceId: 'calendar', - providerCalendarId: 'provider-calendar', - providerRecurringEventId: 'series-master', - title: title, - allDay: false, - start: start, - end: start.add(const Duration(hours: 1)), - editorStart: editorStart, - editorEnd: editorStart?.add(const Duration(hours: 1)), - startTimeZone: 'America/Vancouver', - endTimeZone: 'America/Vancouver', - location: 'Room 1', - description: 'Description', - descriptionContentType: 'text/plain', - descriptionHtml: '

Description

', - recurrence: const ['RRULE:FREQ=WEEKLY'], - attendees: const [ - {'email': 'guest@example.com'}, - ], - colorHex: '#4477aa', - categories: const ['Work'], - reminderMinutesBeforeStart: const [10], - sourceName: 'Work', - accountDisplayName: 'Account', - accountEmail: 'account@example.com', - ); -} - -TaskScheduleItem _task(String title, {DateTime? start}) { - return TaskScheduleItem( - id: title, - accountId: 'account', - provider: TaskProvider.microsoft, - sourceId: 'tasks', - title: title, - completed: false, - allDay: true, - start: start, - notes: 'Notes', - categories: const ['Blue'], - reminder: start?.subtract(const Duration(minutes: 30)), - sourceName: 'Tasks', - accountDisplayName: 'Account', - accountEmail: 'account@example.com', - ); -} diff --git a/test/features/schedule/application/compact_agenda_sections_test.dart b/test/features/schedule/application/compact_agenda_sections_test.dart deleted file mode 100644 index 36d8033..0000000 --- a/test/features/schedule/application/compact_agenda_sections_test.dart +++ /dev/null @@ -1,218 +0,0 @@ -import 'dart:io'; - -import 'package:busymax/src/features/schedule/application/compact_agenda_sections.dart'; -import 'package:busymax/src/schedule/schedule_item.dart'; -import 'package:busymax/src/task_providers/task_provider.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - final today = DateTime(2026, 6, 10); - - test('overdue incomplete tasks appear in Overdue', () { - final sections = buildCompactAgendaSections( - today: today, - items: [_task('overdue', start: DateTime(2026, 6, 9))], - ); - - expect(sections, hasLength(1)); - expect(sections.single.kind, CompactAgendaSectionKind.overdue); - expect(sections.single.items.single.title, 'overdue'); - }); - - test('completed tasks are excluded before sectioning', () { - final sections = buildCompactAgendaSections( - today: today, - items: [_task('done', start: today, completed: true)], - ); - - expect(sections, isEmpty); - }); - - test('today items appear under Today day section', () { - final sections = buildCompactAgendaSections( - today: today, - items: [ - _event('standup', start: today.add(const Duration(hours: 9))), - _task('submit', start: today), - ], - ); - - expect(sections.single.kind, CompactAgendaSectionKind.day); - expect(sections.single.day, today); - expect(sections.single.items.map((item) => item.title), [ - 'submit', - 'standup', - ]); - }); - - test('tomorrow items appear under Tomorrow day section', () { - final tomorrow = today.add(const Duration(days: 1)); - final sections = buildCompactAgendaSections( - today: today, - items: [_task('tomorrow', start: tomorrow)], - ); - - expect(sections.single.day, tomorrow); - expect(sections.single.items.single.title, 'tomorrow'); - }); - - test('future items group by day', () { - final friday = today.add(const Duration(days: 2)); - final saturday = today.add(const Duration(days: 3)); - final sections = buildCompactAgendaSections( - today: today, - items: [ - _event('friday event', start: friday), - _task('saturday task', start: saturday), - ], - ); - - expect(sections.map((section) => section.day), [friday, saturday]); - expect( - sections.expand((section) => section.items).map((item) => item.title), - ['friday event', 'saturday task'], - ); - }); - - test('future items are not capped to seven days by sectioning', () { - final later = today.add(const Duration(days: 14)); - final sections = buildCompactAgendaSections( - today: today, - items: [_task('later', start: later)], - ); - - expect(sections.single.kind, CompactAgendaSectionKind.day); - expect(sections.single.day, later); - expect(sections.single.items.single.title, 'later'); - }); - - test('more than 8 overdue tasks are capped', () { - final sections = buildCompactAgendaSections( - today: today, - items: [ - for (var index = 0; index < 10; index += 1) - _task( - 'overdue $index', - start: today.subtract(Duration(days: index + 1)), - ), - ], - ); - - expect(sections.single.items, hasLength(8)); - expect(sections.single.hasMore, isTrue); - }); - - test('overdue limit can be expanded', () { - final sections = buildCompactAgendaSections( - today: today, - overdueLimit: 16, - items: [ - for (var index = 0; index < 10; index += 1) - _task( - 'overdue $index', - start: today.subtract(Duration(days: index + 1)), - ), - ], - ); - - expect(sections.single.items, hasLength(10)); - expect(sections.single.hasMore, isFalse); - }); - - test('no-date tasks are capped and can be expanded', () { - final cappedSections = buildCompactAgendaSections( - today: today, - items: [ - for (var index = 0; index < 10; index += 1) _task('someday $index'), - ], - ); - final expandedSections = buildCompactAgendaSections( - today: today, - noDateLimit: 16, - items: [ - for (var index = 0; index < 10; index += 1) _task('someday $index'), - ], - ); - - expect(cappedSections.single.items, hasLength(8)); - expect(cappedSections.single.hasMore, isTrue); - expect(expandedSections.single.items, hasLength(10)); - expect(expandedSections.single.hasMore, isFalse); - }); - - test('no-date tasks appear in No date section', () { - final sections = buildCompactAgendaSections( - today: today, - items: [_task('someday')], - ); - - expect(sections.single.kind, CompactAgendaSectionKind.noDate); - expect(sections.single.items.single.title, 'someday'); - }); - - test('no-date tasks appear after overdue and before dated sections', () { - final sections = buildCompactAgendaSections( - today: today, - items: [ - _task('dated', start: today), - _task('someday'), - _task('overdue', start: today.subtract(const Duration(days: 1))), - ], - ); - - expect(sections.map((section) => section.kind), [ - CompactAgendaSectionKind.overdue, - CompactAgendaSectionKind.noDate, - CompactAgendaSectionKind.day, - ]); - expect(sections[1].items.single.title, 'someday'); - }); - - test('compact agenda data includes no-date tasks without old events', () { - final source = File( - 'lib/src/features/schedule/application/compact_agenda_data.dart', - ).readAsStringSync(); - - expect(source, contains('showNoDateTasks: false')); - expect(source, contains('compactAgendaInitialDays = 30')); - expect(source, contains('compactAgendaPageDays = 30')); - expect(source, contains('compactAgendaDataForQueryProvider')); - expect(source, contains('repository.listOverdueTasks')); - expect(source, contains('repository.listNoDateTasks')); - expect(source, contains('limit: query.overdueLimit')); - expect(source, contains('limit: query.noDateLimit')); - }); -} - -TaskScheduleItem _task( - String title, { - DateTime? start, - bool completed = false, -}) { - return TaskScheduleItem( - id: title, - accountId: 'account', - provider: TaskProvider.google, - sourceId: 'tasks', - title: title, - completed: completed, - allDay: true, - start: start, - sourceName: 'Inbox', - ); -} - -CalendarScheduleItem _event(String title, {required DateTime start}) { - return CalendarScheduleItem( - id: title, - accountId: 'account', - provider: TaskProvider.google, - sourceId: 'calendar', - providerCalendarId: 'calendar', - title: title, - allDay: false, - start: start, - end: start.add(const Duration(hours: 1)), - sourceName: 'Work', - ); -} diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart deleted file mode 100644 index 1c55573..0000000 --- a/test/features/schedule/presentation/compact_agenda_panel_test.dart +++ /dev/null @@ -1,425 +0,0 @@ -import 'dart:io'; - -import 'package:busymax/src/app/busymax_design.dart'; -import 'package:busymax/src/features/schedule/application/compact_agenda_data.dart'; -import 'package:busymax/src/features/schedule/presentation/compact_agenda_panel.dart'; -import 'package:busymax/src/schedule/schedule_item.dart'; -import 'package:busymax/src/schedule/schedule_range.dart'; -import 'package:busymax/src/task_providers/task_provider.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:yaru/yaru.dart'; - -import '../../../test_localized_app.dart'; - -void main() { - final today = DateTime(2026, 6, 10); - - testWidgets('signed-out state shows sign-in and open app message', ( - tester, - ) async { - await tester.pumpWidget( - _testPanel( - data: _data(today, hasSignedInAccounts: false, hasSources: false), - ), - ); - - expect(find.text('Sign in to show agenda.'), findsOneWidget); - expect(find.text('Open BusyMax'), findsWidgets); - }); - - testWidgets('no sources state shows no sources message', (tester) async { - await tester.pumpWidget(_testPanel(data: _data(today, hasSources: false))); - - expect(find.text('No visible calendars or task lists.'), findsOneWidget); - expect(find.text('Open BusyMax'), findsWidgets); - }); - - testWidgets('long error state scrolls without overflowing', (tester) async { - final message = List.filled(160, 'Failure details').join('\n'); - - await tester.pumpWidget( - _testPanel( - data: AsyncError(message, StackTrace.empty), - size: const Size(420, 520), - ), - ); - - expect(tester.takeException(), isNull); - expect(find.text('Agenda unavailable'), findsOneWidget); - }); - - testWidgets('empty state shows positive empty message', (tester) async { - await tester.pumpWidget(_testPanel(data: _data(today))); - - expect(find.text('Clear for now'), findsOneWidget); - expect(find.text('No events or tasks'), findsOneWidget); - }); - - test('compact agenda panel loads more days as the list is scrolled', () { - final source = File( - 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', - ).readAsStringSync(); - - expect( - source, - contains('ref.watch(compactAgendaDataForQueryProvider(_query))'), - ); - expect(source, contains('_loadedDays += compactAgendaPageDays')); - expect(source, contains('metrics.extentAfter > 1')); - expect(source, contains('end: data.range.end')); - }); - - test('new task action stays inside the compact agenda window', () { - final source = File( - 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', - ).readAsStringSync(); - - expect(source, contains('NewTaskEditorPanel')); - expect(source, isNot(contains('MainWindowCommandClient().newTask'))); - expect(source, contains('_creatingTask = true')); - expect(source, isNot(contains('useNativeDatePicker: false'))); - }); - - test('compact agenda uses in-window editors for task and event actions', () { - final source = File( - 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', - ).readAsStringSync(); - - expect(source, contains('NewTaskEditorPanel')); - expect(source, contains('TaskDetailsPane')); - expect(source, contains('EventEditor(')); - expect(source, contains('_openTaskEditor(item)')); - expect(source, contains('_openEventEditor(item)')); - expect(source, contains('_creatingEvent = true')); - expect(source, contains('dialogBarrierColor: Colors.transparent')); - expect(source, isNot(contains('MainWindowCommandClient().newTask'))); - }); - - testWidgets('no-date tasks render in a No date section', (tester) async { - await tester.pumpWidget( - _testPanel(data: _data(today, items: [_task('Plan someday')])), - ); - - expect(find.text('No date'), findsOneWidget); - expect(find.text('Plan someday'), findsOneWidget); - }); - - testWidgets('compact shell has rounded corners', (tester) async { - await tester.pumpWidget(_testPanel(data: _data(today))); - - final clip = tester.widget(find.byType(ClipRRect).first); - - expect(clip.borderRadius, isA()); - }); - - testWidgets('header uses native close control and no open-app chrome', ( - tester, - ) async { - await tester.pumpWidget( - _testPanel( - data: _data(today, items: [_event('Team sync', start: today)]), - ), - ); - - expect(find.byType(YaruWindowControl), findsOneWidget); - expect(find.byIcon(Icons.open_in_full), findsNothing); - expect(find.text('Open BusyMax'), findsNothing); - expect(find.text('New task'), findsOneWidget); - }); - - testWidgets('more overdue row loads overdue tasks in place', (tester) async { - final overdueDay = today.subtract(const Duration(days: 1)); - final items = [ - for (var index = 0; index < 10; index += 1) - _task('Overdue task $index', start: overdueDay), - ]; - - await tester.pumpWidget( - _testPanel( - data: _data(today, items: items), - size: const Size(420, 680), - ), - ); - - expect(find.text('Load more overdue tasks'), findsOneWidget); - expect(find.text('Overdue task 8'), findsNothing); - - await tester.ensureVisible(find.text('Load more overdue tasks')); - await tester.pump(); - await tester.tap(find.text('Load more overdue tasks')); - await tester.pump(); - - expect(find.text('Load more overdue tasks'), findsNothing); - expect(find.text('Overdue task 8'), findsOneWidget); - expect(find.text('Overdue task 9'), findsOneWidget); - }); - - testWidgets('more no-date row loads no-date tasks in place', (tester) async { - final items = [ - for (var index = 0; index < 10; index += 1) _task('Someday task $index'), - ]; - - await tester.pumpWidget( - _testPanel( - data: _data(today, items: items), - size: const Size(420, 680), - ), - ); - - expect(find.text('Load more no-date tasks'), findsOneWidget); - expect(find.text('Someday task 8'), findsNothing); - - await tester.ensureVisible(find.text('Load more no-date tasks')); - await tester.pump(); - await tester.tap(find.text('Load more no-date tasks')); - await tester.pump(); - - expect(find.text('Load more no-date tasks'), findsNothing); - expect(find.text('Someday task 8'), findsOneWidget); - expect(find.text('Someday task 9'), findsOneWidget); - }); - - testWidgets('task row renders checkbox and calls completion callback', ( - tester, - ) async { - var completed = false; - final task = _task('Submit report', start: today); - - await tester.pumpWidget( - _testPanel( - data: _data(today, items: [task]), - onTaskCompletionChanged: (_, value) async { - completed = value; - }, - ), - ); - - expect(find.byType(YaruCheckbox), findsOneWidget); - await tester.tap(find.byType(YaruCheckbox)); - await tester.pump(); - - expect(completed, isTrue); - }); - - testWidgets('event row does not render checkbox', (tester) async { - await tester.pumpWidget( - _testPanel( - data: _data(today, items: [_event('Team sync', start: today)]), - ), - ); - - expect(find.text('Team sync'), findsOneWidget); - expect(find.byType(YaruCheckbox), findsNothing); - }); - - testWidgets('rows use native grouped action row pattern', (tester) async { - final event = _event('Team sync', start: today); - - await tester.pumpWidget(_testPanel(data: _data(today, items: [event]))); - - expect(find.byType(BusyMaxGroupedList), findsWidgets); - expect(find.byType(BusyMaxActionRow), findsWidgets); - expect(find.text('Team sync'), findsOneWidget); - }); - - testWidgets('chrome uses scroll shadows instead of static borders', ( - tester, - ) async { - final items = [ - for (var index = 0; index < 24; index += 1) - _event('Event $index', start: today.add(Duration(minutes: index))), - ]; - - await tester.pumpWidget( - _testPanel( - data: _data(today, items: items), - size: const Size(420, 520), - ), - ); - - final header = tester.widget( - find.byKey(const ValueKey('compactAgendaHeader')), - ); - final footer = tester.widget( - find.byKey(const ValueKey('compactAgendaFooter')), - ); - final headerDecoration = header.decoration! as BoxDecoration; - final footerDecoration = footer.decoration! as BoxDecoration; - - expect(headerDecoration.border, isNull); - expect(footerDecoration.border, isNull); - expect( - tester - .widget( - find.byKey(const ValueKey('compactAgendaTopScrollShadow')), - ) - .opacity, - 0, - ); - expect( - tester - .widget( - find.byKey(const ValueKey('compactAgendaBottomScrollShadow')), - ) - .opacity, - 0, - ); - - await tester.drag(find.byType(ListView), const Offset(0, -180)); - await tester.pumpAndSettle(); - - expect( - tester - .widget( - find.byKey(const ValueKey('compactAgendaTopScrollShadow')), - ) - .opacity, - 1, - ); - expect( - tester - .widget( - find.byKey(const ValueKey('compactAgendaBottomScrollShadow')), - ) - .opacity, - 1, - ); - }); - - testWidgets('row tap calls open-item callback', (tester) async { - ScheduleItem? opened; - final event = _event('Team sync', start: today); - - await tester.pumpWidget( - _testPanel( - data: _data(today, items: [event]), - onOpenItem: (item) async { - opened = item; - }, - ), - ); - - await tester.tap(find.text('Team sync')); - await tester.pump(); - - expect(opened, event); - }); - - testWidgets('default row tap shows details popover in compact window', ( - tester, - ) async { - final event = _event('Team sync', start: today); - - await tester.pumpWidget(_testPanel(data: _data(today, items: [event]))); - - await tester.tap(find.text('Team sync')); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.download_outlined), findsOneWidget); - expect(find.byIcon(Icons.edit_outlined), findsOneWidget); - expect(find.text('Work'), findsWidgets); - }); - - testWidgets('loading state renders progress and skeleton rows', ( - tester, - ) async { - await tester.pumpWidget( - _testPanel(data: const AsyncLoading()), - ); - - expect(find.byType(LinearProgressIndicator), findsOneWidget); - expect(find.byType(Container), findsWidgets); - }); - - testWidgets('startup one-pixel allocation does not overflow', (tester) async { - await tester.pumpWidget( - _testPanel(data: _data(today), size: const Size(1, 1)), - ); - - expect(tester.takeException(), isNull); - expect(find.text('Agenda'), findsNothing); - }); -} - -Widget _testPanel({ - required AsyncValue data, - Size size = const Size(420, 680), - Future Function(ScheduleItem item)? onOpenItem, - CompactAgendaTaskCompletionCallback? onTaskCompletionChanged, -}) { - return ProviderScope( - child: localizedTestApp( - child: Scaffold( - body: SizedBox( - width: size.width, - height: size.height, - child: CompactAgendaPanel( - data: data, - onOpenBusyMax: () async {}, - onNewTask: () async {}, - onRefresh: () async {}, - onHide: () async {}, - onOpenItem: onOpenItem, - onTaskCompletionChanged: onTaskCompletionChanged, - ), - ), - ), - ), - ); -} - -AsyncValue _data( - DateTime today, { - List items = const [], - bool hasMoreOverdueTasks = false, - bool hasMoreNoDateTasks = false, - bool hasSignedInAccounts = true, - bool hasSources = true, -}) { - return AsyncData( - CompactAgendaData( - today: today, - range: ScheduleRange( - start: today, - end: today.add(const Duration(days: 30)), - ), - items: items, - hasMoreOverdueTasks: hasMoreOverdueTasks, - hasMoreNoDateTasks: hasMoreNoDateTasks, - hasSignedInAccounts: hasSignedInAccounts, - hasSources: hasSources, - generatedAt: today, - ), - ); -} - -TaskScheduleItem _task(String title, {DateTime? start}) { - return TaskScheduleItem( - id: title, - accountId: 'account', - provider: TaskProvider.google, - sourceId: 'tasks', - title: title, - completed: false, - allDay: true, - start: start, - sourceName: 'Inbox', - ); -} - -CalendarScheduleItem _event(String title, {required DateTime start}) { - return CalendarScheduleItem( - id: title, - accountId: 'account', - provider: TaskProvider.google, - sourceId: 'calendar', - providerCalendarId: 'calendar', - title: title, - allDay: false, - start: start, - end: start.add(const Duration(hours: 1)), - sourceName: 'Work', - ); -} diff --git a/test/features/schedule/presentation/schedule_create_menu_test.dart b/test/features/schedule/presentation/schedule_create_menu_test.dart new file mode 100644 index 0000000..dc8f6d4 --- /dev/null +++ b/test/features/schedule/presentation/schedule_create_menu_test.dart @@ -0,0 +1,394 @@ +import 'package:busymax/src/features/schedule/presentation/schedule_create_menu.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../test_localized_app.dart'; + +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); + }); + + testWidgets('create chooser delegates selection to the native menu host', ( + tester, + ) async { + MethodCall? nativeCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (call) async { + nativeCall = call; + return 1; + }); + late BuildContext hostContext; + late BuildContext anchorContext; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.all(80), + child: Builder( + builder: (context) { + anchorContext = context; + return const SizedBox.square(dimension: 32); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + final result = await showScheduleCreateMenu( + context: hostContext, + anchorContext: anchorContext, + anchorPoint: const Offset(96, 96), + ); + + expect(result, ScheduleCreateChoice.task); + expect(nativeCall?.method, 'show'); + final arguments = nativeCall?.arguments as Map; + expect(arguments['sessionId'], isA()); + expect(arguments['anchor'], { + 'x': 96.0, + 'y': 96.0, + 'width': 0.0, + 'height': 0.0, + }); + expect(arguments['entries'], [ + { + 'label': 'Event', + 'icon': 'x-office-calendar-symbolic', + 'enabled': true, + 'selected': false, + 'shortcut': 'E', + }, + { + 'label': 'Task', + 'icon': 'checkbox-checked-symbolic', + 'enabled': true, + 'selected': false, + 'shortcut': 'T', + }, + ]); + expect(arguments['focusFirst'], isFalse); + expect(arguments['preferredPosition'], 'bottom'); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); + }); + + testWidgets('create chooser can request placement above its anchor', ( + tester, + ) async { + MethodCall? nativeCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (call) async { + nativeCall = call; + return null; + }); + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox.square(dimension: 32); + }, + ), + ), + ); + + await showScheduleCreateMenu(context: hostContext, preferAbove: true); + + expect( + (nativeCall?.arguments as Map)['preferredPosition'], + 'top', + ); + }); + + testWidgets('unavailable native host uses an anchored popup-menu fallback', ( + tester, + ) async { + late BuildContext hostContext; + late BuildContext anchorContext; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.all(80), + child: Builder( + builder: (context) { + anchorContext = context; + return const SizedBox.square(dimension: 32); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + final result = showScheduleCreateMenu( + context: hostContext, + anchorContext: anchorContext, + anchorPoint: const Offset(96, 96), + ); + await tester.pumpAndSettle(); + + expect(find.byType(Dialog), findsNothing); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), + ); + expect(find.text('Event'), findsOneWidget); + expect(find.text('Task'), findsOneWidget); + + await tester.tap(find.text('Task')); + await tester.pumpAndSettle(); + + expect(await result, ScheduleCreateChoice.task); + }); + + testWidgets('popup-menu fallback disables unavailable creation kinds', ( + tester, + ) async { + late BuildContext hostContext; + late BuildContext anchorContext; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return Center( + child: Builder( + builder: (context) { + anchorContext = context; + return const SizedBox.square(dimension: 32); + }, + ), + ); + }, + ), + ), + ), + ); + + final result = showScheduleCreateMenu( + context: hostContext, + anchorContext: anchorContext, + canCreateEvent: false, + canCreateTask: true, + ); + await tester.pumpAndSettle(); + + final eventItem = tester.widget>( + find.ancestor( + of: find.text('Event'), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), + ), + ); + final taskItem = tester.widget>( + find.ancestor( + of: find.text('Task'), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), + ), + ); + expect(eventItem.enabled, isFalse); + expect(taskItem.enabled, isTrue); + expect(Focus.of(tester.element(find.text('Task'))).hasFocus, isTrue); + + await tester.tap(find.text('Event')); + await tester.pump(); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), + ); + + await tester.tap(find.text('Task')); + await tester.pumpAndSettle(); + expect(await result, ScheduleCreateChoice.task); + }); + + testWidgets('Escape dismisses the fallback and restores anchor focus', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + late BuildContext hostContext; + late BuildContext anchorContext; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return Builder( + builder: (context) { + anchorContext = context; + return TextButton( + focusNode: focusNode, + onPressed: () {}, + child: const Text('Anchor'), + ); + }, + ); + }, + ), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + final result = showScheduleCreateMenu( + context: hostContext, + anchorContext: anchorContext, + ); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), + ); + expect(Focus.of(tester.element(find.text('Event'))).hasFocus, isTrue); + expect(focusNode.hasFocus, isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(await result, isNull); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); + expect(focusNode.hasFocus, isTrue); + }); + + testWidgets('native dismissal does not open the Flutter fallback', ( + tester, + ) async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (_) async => null); + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final result = await showScheduleCreateMenu(context: hostContext); + await tester.pump(); + + expect(result, isNull); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); + }); + + testWidgets('create chooser does not open without an available choice', ( + tester, + ) async { + var nativeCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (_) async { + nativeCalls += 1; + return null; + }); + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final result = await showScheduleCreateMenu( + context: hostContext, + canCreateEvent: false, + canCreateTask: false, + ); + await tester.pump(); + + expect(result, isNull); + expect(nativeCalls, 0); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); + }); + + test('single available creation kind is resolved for direct creation', () { + expect( + singleAvailableScheduleCreateChoice( + canCreateEvent: true, + canCreateTask: false, + ), + ScheduleCreateChoice.event, + ); + expect( + singleAvailableScheduleCreateChoice( + canCreateEvent: false, + canCreateTask: true, + ), + ScheduleCreateChoice.task, + ); + expect( + singleAvailableScheduleCreateChoice( + canCreateEvent: true, + canCreateTask: true, + ), + isNull, + ); + expect( + singleAvailableScheduleCreateChoice( + canCreateEvent: false, + canCreateTask: false, + ), + isNull, + ); + }); +} diff --git a/test/features/schedule/presentation/schedule_toolbar_test.dart b/test/features/schedule/presentation/schedule_toolbar_test.dart new file mode 100644 index 0000000..2775bcf --- /dev/null +++ b/test/features/schedule/presentation/schedule_toolbar_test.dart @@ -0,0 +1,648 @@ +import 'dart:async'; + +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_toolbar.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:busymax/src/schedule/schedule_view_mode.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../test_localized_app.dart'; + +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); + }); + + testWidgets('fallback toolbar uses the semantic header title style', ( + tester, + ) async { + const inheritedTitleStyle = TextStyle( + color: Color(0xFF123456), + fontSize: 17, + fontWeight: FontWeight.normal, + ); + + await tester.pumpWidget( + localizedTestApp( + theme: ThemeData( + textTheme: const TextTheme(titleMedium: inheritedTitleStyle), + ), + child: Scaffold( + body: SizedBox( + width: 1200, + child: ScheduleToolbar( + mode: ScheduleViewMode.agenda, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + ), + ), + ), + ), + ); + + final titleFinder = find.byWidgetPredicate( + (widget) => widget is Text && widget.data == 'Agenda', + ); + expect(titleFinder, findsOneWidget); + final title = tester.widget(titleFinder); + expect(title.style?.color, inheritedTitleStyle.color); + expect(title.style?.fontSize, inheritedTitleStyle.fontSize); + expect(title.style?.fontWeight, FontWeight.bold); + }); + + testWidgets('toolbar delegates create selection to the native menu host', ( + tester, + ) async { + MethodCall? nativeCall; + var events = 0; + var tasks = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (call) async { + nativeCall = call; + return 1; + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () => events++, + onCreateTask: () => tasks++, + onRefresh: () {}, + ), + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + + expect(events, 0); + expect(tasks, 1); + expect(nativeCall?.method, 'show'); + expect((nativeCall?.arguments as Map)['entries'], [ + { + 'label': 'Event', + 'icon': 'x-office-calendar-symbolic', + 'enabled': true, + 'selected': false, + 'shortcut': 'E', + }, + { + 'label': 'Task', + 'icon': 'checkbox-checked-symbolic', + 'enabled': true, + 'selected': false, + 'shortcut': 'T', + }, + ]); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); + }); + + testWidgets('fallback toolbar hides a range title that does not fit', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 600, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + ), + ), + ), + ), + ); + + expect( + find.byWidgetPredicate( + (widget) => widget is Text && (widget.data?.contains('2026') ?? false), + ), + findsNothing, + ); + expect( + find.byWidgetPredicate( + (widget) => widget is Text && widget.overflow == TextOverflow.ellipsis, + ), + findsNothing, + ); + }); + + testWidgets('fallback toolbar exposes the complete shell command set', ( + tester, + ) async { + var sidebarToggles = 0; + var searches = 0; + var events = 0; + var tasks = 0; + var sidebarVisible = true; + ScheduleViewMode? selectedMode; + ScheduleToolbarMenuAction? selectedMenuAction; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + child: StatefulBuilder( + builder: (context, setToolbarState) => ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (value) => selectedMode = value, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () => events++, + onCreateTask: () => tasks++, + onRefresh: () {}, + canShowSidebar: true, + sidebarVisible: sidebarVisible, + onToggleSidebar: () { + sidebarToggles++; + setToolbarState(() => sidebarVisible = !sidebarVisible); + }, + onSearch: () => searches++, + onMenuSelected: (value) => selectedMenuAction = value, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Hide sidebar panel (F9)')); + await tester.pump(); + expect(find.byTooltip('Show sidebar panel (F9)'), findsOneWidget); + await tester.tap(find.byTooltip('Search (Ctrl+F)')); + expect(sidebarToggles, 1); + expect(searches, 1); + + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), + ); + expect(find.byType(YaruRadio), findsNothing); + expect(find.text('Event'), findsOneWidget); + expect(find.text('Task'), findsOneWidget); + await tester.tap(find.text('Event')); + await tester.pumpAndSettle(); + expect(events, 1); + expect(tasks, 0); + + await tester.tap(find.byTooltip('Week (2)')); + await tester.pumpAndSettle(); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(ScheduleViewMode.values.length), + ); + expect( + find.byType(YaruRadio), + findsNWidgets(ScheduleViewMode.values.length), + ); + expect(find.text('Compact'), findsNothing); + await tester.tap( + find.ancestor( + of: find.text('Month'), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), + ), + ); + await tester.pumpAndSettle(); + expect(selectedMode, ScheduleViewMode.month); + + await tester.tap(find.byTooltip('Main Menu')); + await tester.pumpAndSettle(); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(4), + ); + expect(find.byType(YaruRadio), findsNothing); + expect(find.text('Report an issue'), findsOneWidget); + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + expect(selectedMenuAction, ScheduleToolbarMenuAction.settings); + + await tester.tap(find.byTooltip('Main Menu')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Report an issue')); + await tester.pumpAndSettle(); + expect(selectedMenuAction, ScheduleToolbarMenuAction.reportIssue); + }); + + testWidgets('compact fallback moves refresh into the main menu', ( + tester, + ) async { + var refreshes = 0; + ScheduleToolbarMenuAction? selectedMenuAction; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 700, + child: ScheduleToolbar( + mode: ScheduleViewMode.agenda, + range: ScheduleRange.day(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () => refreshes++, + onMenuSelected: (value) { + selectedMenuAction = value; + if (value == ScheduleToolbarMenuAction.refresh) { + refreshes++; + } + }, + ), + ), + ), + ), + ); + + expect(find.byTooltip('Refresh all'), findsNothing); + await tester.tap(find.byTooltip('Main Menu')); + await tester.pumpAndSettle(); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(5), + ); + await tester.tap(find.text('Refresh all')); + await tester.pumpAndSettle(); + + expect(selectedMenuAction, ScheduleToolbarMenuAction.refresh); + expect(refreshes, 1); + }); + + testWidgets('fallback create menu keeps actions capability-aware', ( + tester, + ) async { + var events = 0; + var tasks = 0; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: false, + canCreateTask: true, + onCreateEvent: () => events++, + onCreateTask: () => tasks++, + onRefresh: () {}, + ), + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), + ); + expect(find.byType(YaruRadio), findsNothing); + final eventItem = tester.widget>( + find.ancestor( + of: find.text('Event'), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), + ), + ); + final taskItem = tester.widget>( + find.ancestor( + of: find.text('Task'), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), + ), + ); + expect(eventItem.enabled, isFalse); + expect(taskItem.enabled, isTrue); + + await tester.tap(find.text('Task')); + await tester.pumpAndSettle(); + expect(events, 0); + expect(tasks, 1); + }); + + testWidgets( + 'keyboard controller opens and focuses the fallback create menu', + (tester) async { + final controller = BusyMaxMenuController(); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + createMenuController: controller, + ), + ), + ), + ), + ); + + expect(controller.openForKeyboard(), isTrue); + expect(controller.isOpen, isTrue); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), + ); + expect(find.text('Event'), findsOneWidget); + expect(find.text('Task'), findsOneWidget); + + final trigger = tester.widget( + find.ancestor( + of: find.byTooltip('Create'), + matching: find.byType(YaruIconButton), + ), + ); + expect(trigger.focusNode, isNotNull); + expect(Focus.of(tester.element(find.text('Event'))).hasFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(controller.isOpen, isFalse); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); + expect(find.text('Event'), findsNothing); + expect(find.text('Task'), findsNothing); + }, + ); + + testWidgets('controller close dismisses a pending native menu', ( + tester, + ) async { + final controller = BusyMaxMenuController(); + final nativeSelection = Completer(); + var showCalls = 0; + var dismissCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (call) async { + switch (call.method) { + case 'show': + showCalls += 1; + return nativeSelection.future; + case 'dismiss': + dismissCalls += 1; + if (!nativeSelection.isCompleted) { + nativeSelection.complete(); + } + return true; + } + throw MissingPluginException(); + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + createMenuController: controller, + ), + ), + ), + ), + ); + + expect(controller.openForKeyboard(), isTrue); + await tester.pump(); + + expect(controller.isOpen, isTrue); + expect(showCalls, 1); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); + + controller.close(); + await tester.pumpAndSettle(); + + expect(dismissCalls, 1); + expect(controller.isOpen, isFalse); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); + }); + + testWidgets('keyboard controller follows a responsive toolbar replacement', ( + tester, + ) async { + final controller = BusyMaxMenuController(); + final nestToolbar = ValueNotifier(false); + addTearDown(nestToolbar.dispose); + + Widget buildToolbar() { + return ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + createMenuController: controller, + ); + } + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + child: ValueListenableBuilder( + valueListenable: nestToolbar, + builder: (context, nested, child) { + final toolbar = buildToolbar(); + return nested + ? Row(children: [Expanded(child: toolbar)]) + : toolbar; + }, + ), + ), + ), + ), + ); + expect(controller.isAttached, isTrue); + + nestToolbar.value = true; + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(controller.isAttached, isTrue); + expect(controller.openForKeyboard(), isTrue); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), + ); + expect(find.text('Event'), findsOneWidget); + expect(find.text('Task'), findsOneWidget); + expect(Focus.of(tester.element(find.text('Event'))).hasFocus, isTrue); + + controller.close(); + await tester.pumpAndSettle(); + expect(controller.isOpen, isFalse); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); + await tester.pumpWidget(const SizedBox.shrink()); + + expect(controller.isAttached, isFalse); + expect(controller.openForKeyboard(), isFalse); + }); + + testWidgets('create trigger disables when no creation kind is available', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: false, + canCreateTask: false, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + ), + ), + ), + ), + ); + + final trigger = tester.widget( + find.ancestor( + of: find.byTooltip('Create'), + matching: find.byType(YaruIconButton), + ), + ); + expect(trigger.onPressed, isNull); + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + expect(find.text('Event'), findsNothing); + expect(find.text('Task'), findsNothing); + }); +} diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 14cb258..bd69cc5 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -1,7 +1,11 @@ import 'dart:io'; +import 'dart:ui' as ui; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/features/schedule/presentation/calendar_day_semantics.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_agenda_view.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_anchored_popover.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_day_week_view.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_event_block.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_item_chip.dart'; @@ -9,16 +13,122 @@ import 'package:busymax/src/features/schedule/presentation/schedule_item_details import 'package:busymax/src/features/schedule/presentation/schedule_item_exporter.dart'; import 'package:busymax/src/features/schedule/presentation/mini_calendar.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_month_view.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_year_view.dart'; +import 'package:busymax/src/platform/gtk_font_service.dart'; import 'package:busymax/src/schedule/schedule_item.dart'; import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:infinite_calendar_view/infinite_calendar_view.dart' as icv; +import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; void main() { + test('day and week modes use separate planner state identities', () { + final workspace = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + + expect(workspace, contains("ValueKey('schedule-day-planner')")); + expect(workspace, contains("ValueKey('schedule-week-planner')")); + }); + + testWidgets('year view uses one month column in compact narrow layouts', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 420, + height: 720, + child: ScheduleYearView( + selectedDate: selectedDate, + items: const [], + firstWeekday: DateTime.monday, + onDaySelected: (_) {}, + onMonthSelected: (_) {}, + onWeekSelected: (_) {}, + onCreateAtDay: (_) {}, + compact: true, + ), + ), + ), + ), + ); + await tester.pump(); + + final months = find.byType(YearMonthMiniCalendar); + expect(months, findsNWidgets(DateTime.monthsPerYear)); + final januaryPosition = tester.getTopLeft(months.at(0)); + final februaryPosition = tester.getTopLeft(months.at(1)); + expect(februaryPosition.dx, januaryPosition.dx); + expect(februaryPosition.dy, greaterThan(januaryPosition.dy)); + }); + + testWidgets( + 'year view highlights a selected spillover date only in its own month', + (tester) async { + final semantics = tester.ensureSemantics(); + final selectedDate = DateTime(2026, 7, 30); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleYearView( + selectedDate: selectedDate, + items: const [], + firstWeekday: DateTime.monday, + onDaySelected: (_) {}, + onMonthSelected: (_) {}, + onWeekSelected: (_) {}, + onCreateAtDay: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final months = find.byType(YearMonthMiniCalendar); + final selectedDateKey = ValueKey(( + selectedDate.year, + selectedDate.month, + selectedDate.day, + )); + final julyDate = find.descendant( + of: months.at(DateTime.july - 1), + matching: find.byKey(selectedDateKey), + ); + final augustSpilloverDate = find.descendant( + of: months.at(DateTime.august - 1), + matching: find.byKey(selectedDateKey), + ); + + expect(julyDate, findsOneWidget); + expect(augustSpilloverDate, findsOneWidget); + expect( + tester.getSemantics(julyDate).flagsCollection.isSelected, + ui.Tristate.isTrue, + ); + expect( + tester.getSemantics(augustSpilloverDate).flagsCollection.isSelected, + ui.Tristate.isFalse, + ); + semantics.dispose(); + }, + ); + testWidgets('day view uses package planner with custom BusyMax items', ( tester, ) async { @@ -53,6 +163,306 @@ void main() { expect(find.byType(icv.EventsList), findsNothing); }); + for (final configuration in const [ + (label: 'day', daysShowed: 1), + (label: 'week', daysShowed: 7), + ]) { + testWidgets( + 'dark ${configuration.label} grid stays visible with a recessed GTK divider', + (tester) async { + final selectedDate = DateTime(2026, 1, 15); + final theme = _darkCalendarGridTestTheme(); + final expectedGridColor = theme.colorScheme.onSurface.withValues( + alpha: BusyMaxAlpha.calendarGridDark, + ); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleDayWeekView( + range: configuration.daysShowed == 1 + ? ScheduleRange.day(selectedDate) + : ScheduleRange.week(selectedDate), + selectedDate: selectedDate, + daysShowed: configuration.daysShowed, + items: _itemsFor(selectedDate), + onDaySelected: (_) {}, + onEmptySlot: (_) {}, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + + final viewContext = tester.element(find.byType(ScheduleDayWeekView)); + final planner = tester.widget( + find.byType(icv.EventsPlanner), + ); + final painter = + planner.dayParam.dayCustomPainter!(1, false) as icv.LinesPainter; + final workspaceColor = theme.extension()!.window; + final effectiveGridColor = Color.alphaBlend( + painter.lineColor, + workspaceColor, + ); + + // GTK's generic separator remains a distinct, recessed native role. + expect(theme.colorScheme.outlineVariant, _recessedGtkDivider); + expect(planner.daysShowed, configuration.daysShowed); + expect(busyMaxCalendarGridColor(viewContext), expectedGridColor); + expect(painter.lineColor, expectedGridColor); + expect(painter.lineColor, isNot(_recessedGtkDivider)); + expect( + effectiveGridColor.computeLuminance(), + greaterThan(workspaceColor.computeLuminance()), + ); + }, + ); + } + + testWidgets('planner canvas matches the semantic window surface', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final workspaceColor = theme.extension()!.window; + expect(workspaceColor, isNot(theme.colorScheme.surface)); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleDayWeekView( + range: ScheduleRange.week(selectedDate), + selectedDate: selectedDate, + daysShowed: 7, + items: _itemsFor(selectedDate), + onDaySelected: (_) {}, + onEmptySlot: (_) {}, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + + final planner = tester.widget( + find.byType(icv.EventsPlanner), + ); + expect(planner.daysHeaderParam.daysHeaderColor, workspaceColor); + expect(planner.fullDayParam.fullDayBackgroundColor, workspaceColor); + expect( + (planner.fullDayParam.fullDayEventsBarDecoration as BoxDecoration).color, + workspaceColor, + ); + expect(planner.dayParam.dayColor, workspaceColor); + }); + + testWidgets('calendar and agenda panes match the semantic window surface', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final workspaceColor = theme.extension()!.window; + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleMonthView( + range: ScheduleRange.month(selectedDate), + selectedDate: selectedDate, + items: _itemsFor(selectedDate), + firstWeekday: DateTime.monday, + onDaySelected: (_) {}, + onCreateAtDay: (_, {anchorContext}) {}, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + final monthHeader = tester.widget( + find + .descendant( + of: find.byType(ScheduleMonthView), + matching: find.byWidgetPredicate( + (widget) => + widget is ColoredBox && + widget.child is SizedBox && + (widget.child as SizedBox).height == 34, + ), + ) + .first, + ); + expect(monthHeader.color, workspaceColor); + expect( + tester + .widgetList( + find.descendant( + of: find.byType(ScheduleMonthView), + matching: find.byType(Material), + ), + ) + .any((material) => material.color == workspaceColor), + isTrue, + ); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleYearView( + selectedDate: selectedDate, + items: _itemsFor(selectedDate), + firstWeekday: DateTime.monday, + onDaySelected: (_) {}, + onMonthSelected: (_) {}, + onWeekSelected: (_) {}, + onCreateAtDay: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + expect( + find.descendant( + of: find.byType(ScheduleYearView), + matching: find.byType(YearMonthMiniCalendar), + ), + findsNWidgets(DateTime.monthsPerYear), + ); + expect( + find.descendant( + of: find.byType(ScheduleYearView), + matching: find.byType(BusyMaxGroupedSurface), + ), + findsNWidgets(DateTime.monthsPerYear), + ); + final yearCanvas = tester.widget( + find + .descendant( + of: find.byType(ScheduleYearView), + matching: find.byWidgetPredicate( + (widget) => + widget is ColoredBox && + widget.color == workspaceColor && + widget.child is SingleChildScrollView, + ), + ) + .first, + ); + expect(yearCanvas.color, workspaceColor); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleAgendaView( + range: ScheduleRange( + start: selectedDate, + end: selectedDate.add(const Duration(days: 7)), + ), + items: _itemsFor(selectedDate), + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + final agendaCanvas = tester.widget( + find + .descendant( + of: find.byType(ScheduleAgendaView), + matching: find.byWidgetPredicate( + (widget) => widget is ColoredBox && widget.child is ListView, + ), + ) + .first, + ); + expect(agendaCanvas.color, workspaceColor); + }); + + testWidgets('dark month grid uses the shared neutral grid color', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + final theme = _darkCalendarGridTestTheme(); + final expectedGridColor = theme.colorScheme.onSurface.withValues( + alpha: BusyMaxAlpha.calendarGridDark, + ); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleMonthView( + range: ScheduleRange.month(selectedDate), + selectedDate: selectedDate, + firstWeekday: DateTime.monday, + items: const [], + onDaySelected: (_) {}, + onCreateAtDay: (_, {anchorContext}) {}, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + + final monthCell = tester.widget( + find + .descendant( + of: find.byType(GridView), + matching: find.byType(DecoratedBox), + ) + .first, + ); + final cellBorder = + (monthCell.decoration as BoxDecoration).border! as Border; + expect(cellBorder.top.color, expectedGridColor); + expect(cellBorder.left.color, expectedGridColor); + }); + testWidgets('day view applies configured display hours to planner scroll', ( tester, ) async { @@ -130,6 +540,105 @@ void main() { expect(find.text('Design review'), findsOneWidget); }); + testWidgets('event block is semantically labeled and keyboard-activatable', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + final item = _itemsFor( + selectedDate, + ).whereType().first; + var activationCount = 0; + Offset? activationPosition = Offset.zero; + + await tester.pumpWidget( + localizedTestApp( + alwaysUse24HourFormat: true, + child: Scaffold( + body: Center( + child: ScheduleEventBlock( + item: item, + width: 180, + height: 54, + onTap: (_, [globalPosition]) { + activationCount += 1; + activationPosition = globalPosition; + }, + ), + ), + ), + ), + ); + + final eventSemantics = find.descendant( + of: find.byType(ScheduleEventBlock), + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label?.startsWith('Design review') == true, + ), + ); + expect(eventSemantics, findsOneWidget); + final semantics = tester.widget(eventSemantics); + expect(semantics.properties.button, isTrue); + expect(semantics.properties.enabled, isTrue); + expect(semantics.properties.label, contains('09:00-10:00')); + expect(semantics.properties.label, contains('Work')); + expect(semantics.properties.onTap, isNotNull); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + + final focusedSurface = find.descendant( + of: find.byType(ScheduleEventBlock), + matching: find.byWidgetPredicate((widget) { + if (widget is! Container || widget.decoration is! BoxDecoration) { + return false; + } + final border = (widget.decoration! as BoxDecoration).border; + return border is Border && border.top.width == 2; + }), + ); + expect(focusedSurface, findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + + expect(activationCount, 2); + expect(activationPosition, isNull); + }); + + testWidgets('interactive task chip uses the event click cursor', ( + tester, + ) async { + final task = _itemsFor( + DateTime(2026, 1, 15), + ).whereType().first; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Center( + child: ScheduleItemChip( + item: task, + width: 180, + height: 54, + onTap: (_, [_]) {}, + ), + ), + ), + ), + ); + + final taskInkWell = find.descendant( + of: find.byType(ScheduleItemChip), + matching: find.byType(InkWell), + ); + expect( + tester.widget(taskInkWell).mouseCursor, + SystemMouseCursors.click, + ); + }); + testWidgets('same-slot day items render in a horizontal strip', ( tester, ) async { @@ -271,24 +780,260 @@ void main() { ); }); - testWidgets('month view is a custom BusyMax grid with merged chips', ( - tester, - ) async { + testWidgets('month view is a custom BusyMax grid with merged chips', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleMonthView( + range: ScheduleRange.month(selectedDate), + selectedDate: selectedDate, + firstWeekday: DateTime.monday, + items: _itemsFor(selectedDate), + onDaySelected: (_) {}, + onCreateAtDay: (_, {anchorContext}) {}, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + + expect(find.byType(icv.EventsMonths), findsNothing); + expect(find.text('Design review'), findsOneWidget); + expect(find.text('Submit report'), findsOneWidget); + }); + + testWidgets('month create button reports its own popover anchor', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + DateTime? createdAt; + BuildContext? createAnchor; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleMonthView( + range: ScheduleRange.month(selectedDate), + selectedDate: selectedDate, + firstWeekday: DateTime.monday, + items: const [], + onDaySelected: (_) {}, + onCreateAtDay: (day, {anchorContext}) { + createdAt = day; + createAnchor = anchorContext; + }, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + + final createButton = find.byType(YaruIconButton); + expect(createButton, findsOneWidget); + tester.widget(createButton).onPressed!(); + await tester.pump(); + + expect(createdAt, selectedDate); + expect(createAnchor, isNotNull); + final renderObject = createAnchor!.findRenderObject()! as RenderBox; + final anchorRect = + renderObject.localToGlobal(Offset.zero) & renderObject.size; + expect(anchorRect, tester.getRect(createButton)); + + createdAt = null; + createAnchor = null; + final selectedCell = find + .ancestor( + of: find.byKey( + ValueKey('month-day-marker-${selectedDate.toIso8601String()}'), + ), + matching: find.byType(InkWell), + ) + .first; + tester.widget(selectedCell).onDoubleTap!(); + await tester.pump(); + + expect(createdAt, selectedDate); + expect(createAnchor, isNotNull); + final cellRenderObject = createAnchor!.findRenderObject()! as RenderBox; + final cellAnchorRect = + cellRenderObject.localToGlobal(Offset.zero) & cellRenderObject.size; + expect(cellAnchorRect, tester.getRect(selectedCell)); + }); + + testWidgets('month and year days share accessible date semantics', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final selectedDate = DateTime(2026, 1, 15); + DateTime? activatedDay; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleMonthView( + range: ScheduleRange.month(selectedDate), + selectedDate: selectedDate, + firstWeekday: DateTime.monday, + items: const [], + onDaySelected: (day) => activatedDay = day, + onCreateAtDay: (_, {anchorContext}) {}, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + + var selectedDay = find.text('15'); + var selectedNode = tester.getSemantics(selectedDay); + expect(selectedNode.flagsCollection.isSelected, ui.Tristate.isTrue); + expect(selectedNode.label, contains('January 15, 2026')); + var selectedMarker = tester.widget( + find.byKey( + ValueKey('month-day-marker-${selectedDate.toIso8601String()}'), + ), + ); + expect( + (selectedMarker.decoration! as BoxDecoration).color, + Theme.of(tester.element(selectedDay)).colorScheme.primary, + ); + tester.semantics.tap( + find.semantics.byPredicate((node) => node.id == selectedNode.id), + ); + await tester.pump(); + expect(activatedDay, selectedDate); + + activatedDay = null; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleYearView( + selectedDate: selectedDate, + firstWeekday: DateTime.monday, + items: const [], + onDaySelected: (day) => activatedDay = day, + onMonthSelected: (_) {}, + onWeekSelected: (_) {}, + onCreateAtDay: (_) {}, + ), + ), + ), + ), + ); + + selectedDay = find.text('15').first; + selectedNode = tester.getSemantics(selectedDay); + expect(selectedNode.flagsCollection.isSelected, ui.Tristate.isTrue); + expect(selectedNode.label, contains('January 15, 2026')); + final yearMarker = tester.widget( + find + .ancestor( + of: selectedDay, + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).shape == BoxShape.circle, + ), + ) + .first, + ); + expect( + (yearMarker.decoration as BoxDecoration).color, + Theme.of(tester.element(selectedDay)).colorScheme.primary, + ); + tester.semantics.tap( + find.semantics.byPredicate((node) => node.id == selectedNode.id), + ); + await tester.pump(); + expect(activatedDay, selectedDate); + semantics.dispose(); + }); + + testWidgets('calendar date changes replace native tooltip state', ( + tester, + ) async { + var day = DateTime(2026, 1, 15); + late StateSetter updateDay; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + updateDay = setState; + return Center( + child: BusyMaxCalendarDaySemantics( + day: day, + selected: false, + onTap: () {}, + child: const SizedBox.square(dimension: 32), + ), + ); + }, + ), + ), + ), + ); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo( + tester.getCenter(find.byType(BusyMaxCalendarDaySemantics)), + ); + await tester.pump(const Duration(milliseconds: 200)); + + final originalState = tester.state( + find.byType(RawTooltip), + ); + updateDay(() => day = DateTime(2026, 2, 15)); + await tester.pump(); + final updatedState = tester.state(find.byType(RawTooltip)); + + expect(updatedState, isNot(same(originalState))); + expect(find.byType(Tooltip), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('month view avoids overflow in very short cells', (tester) async { final selectedDate = DateTime(2026, 1, 15); await tester.pumpWidget( localizedTestApp( child: Scaffold( body: SizedBox( - width: 1000, - height: 720, + width: 700, + height: 120, child: ScheduleMonthView( range: ScheduleRange.month(selectedDate), selectedDate: selectedDate, firstWeekday: DateTime.monday, - items: _itemsFor(selectedDate), + items: _sameSlotItemsFor(selectedDate), onDaySelected: (_) {}, - onCreateAtDay: (_) {}, + onCreateAtDay: (_, {anchorContext}) {}, onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), @@ -297,28 +1042,33 @@ void main() { ), ); - expect(find.byType(icv.EventsMonths), findsNothing); - expect(find.text('Design review'), findsOneWidget); - expect(find.text('Submit report'), findsOneWidget); + expect(tester.takeException(), isNull); }); - testWidgets('month view avoids overflow in very short cells', (tester) async { + testWidgets('month More waits for dismissal and returns a stable anchor', ( + tester, + ) async { final selectedDate = DateTime(2026, 1, 15); + BuildContext? selectedAnchor; + ScheduleItem? selectedItem; await tester.pumpWidget( localizedTestApp( child: Scaffold( body: SizedBox( width: 700, - height: 120, + height: 720, child: ScheduleMonthView( range: ScheduleRange.month(selectedDate), selectedDate: selectedDate, firstWeekday: DateTime.monday, - items: _sameSlotItemsFor(selectedDate), + items: _manyAllDayItemsFor(selectedDate), onDaySelected: (_) {}, - onCreateAtDay: (_) {}, - onItemSelected: (_, _, [_]) {}, + onCreateAtDay: (_, {anchorContext}) {}, + onItemSelected: (anchor, item, [_]) { + selectedAnchor = anchor; + selectedItem = item; + }, onTaskCompletionChanged: (_, _) {}, ), ), @@ -326,7 +1076,26 @@ void main() { ), ); - expect(tester.takeException(), isNull); + final moreButton = find.ancestor( + of: find.textContaining('more'), + matching: find.byType(TextButton), + ); + expect(moreButton, findsOneWidget); + tester.widget(moreButton).onPressed!(); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxPopoverSurface), findsOneWidget); + expect(find.byType(ListView), findsOneWidget); + final popoverChips = find.descendant( + of: find.byType(ListView), + matching: find.byType(ScheduleItemChip), + ); + expect(popoverChips, findsWidgets); + await tester.tap(popoverChips.at(1)); + await tester.pumpAndSettle(); + + expect(selectedItem?.id, 'all-day-task:1'); + expect(selectedAnchor, isNotNull); + expect(selectedAnchor!.mounted, isTrue); }); testWidgets('calendar schedule chip invokes calendar item tap', ( @@ -334,6 +1103,7 @@ void main() { ) async { final selectedDate = DateTime(2026, 1, 15); ScheduleItem? selectedItem; + Offset? pointerPosition; final event = _itemsFor( selectedDate, ).whereType().first; @@ -345,7 +1115,10 @@ void main() { child: ScheduleItemChip( item: event, height: 34, - onTap: (_, [_]) => selectedItem = event, + onTap: (_, [globalPosition]) { + selectedItem = event; + pointerPosition = globalPosition; + }, ), ), ), @@ -355,6 +1128,21 @@ void main() { await tester.tap(find.byType(ScheduleEventBlock).first); expect(selectedItem, isA()); + expect(pointerPosition, isNotNull); + + selectedItem = null; + pointerPosition = null; + final center = tester.getCenter(find.byType(ScheduleEventBlock).first); + final secondaryClick = await tester.createGesture( + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await secondaryClick.addPointer(location: center); + await secondaryClick.down(center); + await secondaryClick.up(); + + expect(selectedItem, isA()); + expect(pointerPosition, center); }); testWidgets('schedule item details popover offers export, edit, and delete', ( @@ -391,26 +1179,352 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Design review'), findsOneWidget); - expect(find.byIcon(Icons.download_outlined), findsOneWidget); + expect(find.byIcon(YaruIcons.share), findsOneWidget); expect(find.byIcon(Icons.edit_outlined), findsOneWidget); - expect(find.byIcon(Icons.delete_outline), findsOneWidget); - expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.byIcon(YaruIcons.trash), findsOneWidget); + expect(find.byIcon(YaruIcons.window_close), findsOneWidget); expect(find.text('Export'), findsNothing); expect(find.text('Edit event'), findsNothing); expect(find.text('Delete'), findsNothing); + expect(find.byType(BusyMaxPopoverIconButton), findsNWidgets(4)); + + final actionButtons = tester + .widgetList( + find.descendant( + of: find.byType(BusyMaxPopoverIconButton), + matching: find.byType(YaruIconButton), + ), + ) + .toList(); + final actionContext = tester.element( + find.byType(BusyMaxPopoverIconButton).first, + ); + final actionColors = BusyMaxSurfaceColors.of(actionContext); + expect(BusyMaxSizes.popoverActionButton, kYaruTitleBarItemHeight); + expect(BusyMaxSizes.popoverActionIcon, BusyMaxSizes.iconSm); + expect(actionButtons, hasLength(4)); + for (final button in actionButtons) { + expect(button.iconSize, BusyMaxSizes.popoverActionButton); + expect(button.style?.backgroundColor, isNull); + expect(button.style?.overlayColor, isNull); + expect(button.style?.tapTargetSize, MaterialTapTargetSize.shrinkWrap); + expect( + button.style?.minimumSize?.resolve({}), + const Size.square(BusyMaxSizes.popoverActionButton), + ); + expect( + button.style?.maximumSize?.resolve({}), + const Size.square(BusyMaxSizes.popoverActionButton), + ); + final style = button.defaultStyleOf( + tester.element(find.byWidget(button)), + ); + expect( + style.fixedSize?.resolve({}), + const Size.square(BusyMaxSizes.popoverActionButton), + ); + expect(style.overlayColor?.resolve({WidgetState.hovered}), isNotNull); + expect(style.overlayColor?.resolve({WidgetState.pressed}), isNotNull); + } + final restingSurfaces = tester + .widgetList( + find.descendant( + of: find.byType(BusyMaxPopoverIconButton), + matching: find.byWidgetPredicate( + (widget) => + widget is Material && + widget.color == actionColors.control && + widget.shape == const CircleBorder(), + ), + ), + ) + .toList(); + expect(restingSurfaces, hasLength(4)); + for (final button in find.byType(BusyMaxPopoverIconButton).evaluate()) { + expect( + tester.getSize(find.byWidget(button.widget)), + const Size.square(BusyMaxSizes.popoverActionButton), + ); + } + for (final icon in tester.widgetList( + find.descendant( + of: find.byType(BusyMaxPopoverIconButton), + matching: find.byType(Icon), + ), + )) { + expect(icon.size, BusyMaxSizes.popoverActionIcon); + } + expect( + tester.widget(find.byIcon(YaruIcons.trash)).color, + Theme.of(actionContext).colorScheme.error, + ); + expect(tester.widget(find.byIcon(YaruIcons.share)).color, isNull); + + final popoverSurfaceFinder = _popoverDecorationFinder( + find.byType(BusyMaxContentPopoverSurface), + ); + expect(popoverSurfaceFinder, findsOneWidget); + final popoverContext = tester.element(popoverSurfaceFinder); + final popoverRoute = ModalRoute.of(popoverContext)!; + expect( + popoverRoute.traversalEdgeBehavior, + TraversalEdgeBehavior.closedLoop, + ); + expect( + popoverRoute.directionalTraversalEdgeBehavior, + TraversalEdgeBehavior.stop, + ); + final contentSurface = tester.widget( + find.descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byType(BusyMaxPopoverSurface), + ), + ); + final contentColors = BusyMaxSurfaceColors.of(popoverContext); + expect(contentSurface.color, contentColors.popover); + expect(contentSurface.outlineColor, contentColors.floatingBorder); + expect(contentSurface.shadowRole, BusyMaxPopoverShadowRole.details); final editCenter = tester.getCenter(find.byIcon(Icons.edit_outlined)); - final deleteCenter = tester.getCenter(find.byIcon(Icons.delete_outline)); - final closeCenter = tester.getCenter(find.byIcon(Icons.close)); + final deleteCenter = tester.getCenter(find.byIcon(YaruIcons.trash)); + final closeCenter = tester.getCenter(find.byIcon(YaruIcons.window_close)); expect(editCenter.dx, lessThan(deleteCenter.dx)); expect(deleteCenter.dx, lessThan(closeCenter.dx)); - await tester.tap(find.byIcon(Icons.download_outlined)); + await tester.tap(find.byIcon(YaruIcons.share)); await tester.pumpAndSettle(); expect(await action, ScheduleItemDetailsAction.export); }); + for (final baseline in const [ + ( + brightness: Brightness.light, + interior: Color(0xFFFAFAFA), + outline: Color.fromRGBO(0, 0, 0, 0.14), + ), + ( + brightness: Brightness.dark, + interior: Color(0xFF3E3E3E), + outline: Color.fromRGBO(0, 0, 0, 0.14), + ), + ]) { + testWidgets( + 'details popover paints the reviewed ${baseline.brightness.name} ' + 'popover surface and native floating edge', + (tester) async { + tester.view + ..physicalSize = const Size(800, 600) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final selectedDate = DateTime(2026, 1, 15); + final event = _itemsFor( + selectedDate, + ).whereType().first; + final theme = BusyMaxYaruTheme.build( + brightness: baseline.brightness, + accentColor: const Color(0xFF3584E4), + ); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Builder( + builder: (context) { + final mediaQuery = MediaQuery.of( + context, + ).copyWith(disableAnimations: true); + return MediaQuery( + data: mediaQuery, + child: Scaffold( + body: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 40), + child: Builder( + builder: (anchorContext) { + return TextButton( + onPressed: () { + showScheduleItemDetailsPopover( + context: anchorContext, + anchorContext: anchorContext, + item: event, + ); + }, + child: const Text('Open details palette probe'), + ); + }, + ), + ), + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Open details palette probe')); + await tester.pumpAndSettle(); + + final shapeFinder = _popoverDecorationFinder( + find.byType(BusyMaxContentPopoverSurface), + ); + final popoverFinder = find.descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byType(BusyMaxPopoverSurface), + ); + expect(shapeFinder, findsOneWidget); + expect(popoverFinder, findsOneWidget); + + final shapeContext = tester.element(shapeFinder); + expect(ModalRoute.of(shapeContext), isNotNull); + final popover = tester.widget(popoverFinder); + expect(popover.color, baseline.interior); + expect(popover.outlineColor, baseline.outline); + }, + ); + } + + for (final brightness in Brightness.values) { + testWidgets( + 'popover action paints a contained circle and strengthens it on hover ' + 'in $brightness mode', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + final boundaryKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: RepaintBoundary( + key: boundaryKey, + child: ColoredBox( + color: colors.card, + child: SizedBox.square( + dimension: 50, + child: Center( + child: BusyMaxPopoverIconButton( + icon: YaruIcons.share, + tooltip: 'Export', + onPressed: () {}, + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final restingPixels = await _capturePixels(tester, boundaryKey); + final background = _pixelAt(restingPixels, x: 2, y: 2); + final restingFace = _pixelAt(restingPixels, x: 25, y: 10); + expect(restingFace, isNot(background)); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo( + tester.getCenter(find.byType(BusyMaxPopoverIconButton)), + ); + await tester.pumpAndSettle(); + + final hoveredPixels = await _capturePixels(tester, boundaryKey); + final hoveredBackground = _pixelAt(hoveredPixels, x: 2, y: 2); + final hoveredFace = _pixelAt(hoveredPixels, x: 25, y: 10); + expect(hoveredBackground, background); + expect(hoveredFace, isNot(restingFace)); + expect( + _luminanceDistance(hoveredFace, background), + greaterThan(_luminanceDistance(restingFace, background)), + ); + }, + ); + } + + testWidgets('popover action retains Yaru keyboard-focus treatment', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: YaruTheme( + data: const YaruThemeData(focusBorders: true), + child: Scaffold( + body: Center( + child: BusyMaxPopoverIconButton( + icon: YaruIcons.share, + tooltip: 'Export', + onPressed: () {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final action = find.byType(BusyMaxPopoverIconButton); + expect( + find.descendant(of: action, matching: find.byType(YaruFocusBorder)), + findsOneWidget, + ); + expect( + tester.getSize(action), + const Size.square(BusyMaxSizes.popoverActionButton), + ); + }); + + testWidgets('direct details popover registers for native-header dismissal', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + final event = _itemsFor( + selectedDate, + ).whereType().first; + final controller = ScheduleAnchoredPopoverController(); + + await tester.pumpWidget( + localizedTestApp( + child: ScheduleAnchoredPopoverScope( + controller: controller, + child: Scaffold( + body: Builder( + builder: (anchorContext) => TextButton( + onPressed: () { + showScheduleItemDetailsPopover( + context: anchorContext, + anchorContext: anchorContext, + item: event, + ); + }, + child: const Text('Open coordinated details'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open coordinated details')); + await tester.pumpAndSettle(); + expect(controller.isOpen, isTrue); + + final dismissal = controller.dismiss(); + await tester.pumpAndSettle(); + await dismissal; + + expect(controller.isOpen, isFalse); + expect(find.byIcon(YaruIcons.window_close), findsNothing); + }); + testWidgets('schedule item details popover delete button returns delete', ( tester, ) async { @@ -443,12 +1557,161 @@ void main() { await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.delete_outline)); + await tester.tap(find.byIcon(YaruIcons.trash)); await tester.pumpAndSettle(); expect(await action, ScheduleItemDetailsAction.delete); }); + testWidgets('read-only event details omit mutation actions', (tester) async { + final event = CalendarScheduleItem( + id: 'event:read-only', + accountId: 'google:g', + provider: TaskProvider.google, + sourceId: 'calendar:shared', + providerCalendarId: 'shared', + title: 'Shared calendar event', + allDay: false, + start: DateTime(2026, 1, 15, 9), + end: DateTime(2026, 1, 15, 10), + capabilities: ScheduleItemCapabilities.readOnly, + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showScheduleItemDetailsPopover( + context: context, + anchorContext: context, + item: event, + ), + child: const Text('Open details'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open details')); + await tester.pumpAndSettle(); + + expect(find.byIcon(YaruIcons.share), findsOneWidget); + expect(find.byIcon(YaruIcons.window_close), findsOneWidget); + expect(find.byIcon(Icons.edit_outlined), findsNothing); + expect(find.byIcon(YaruIcons.trash), findsNothing); + }); + + testWidgets('details popover constrains long content without animation', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(480, 300); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final event = CalendarScheduleItem( + id: 'event:long', + accountId: 'google:g', + provider: TaskProvider.google, + sourceId: 'calendar:primary', + providerCalendarId: 'primary', + title: 'A detailed event with a deliberately long title for sizing', + allDay: false, + start: DateTime(2026, 1, 15, 9), + end: DateTime(2026, 1, 15, 10), + location: 'A long location that still belongs inside the popover', + description: List.filled(20, 'Detailed agenda notes').join(' '), + categories: List.generate(20, (index) => 'Category $index'), + ); + + await tester.pumpWidget( + localizedTestApp( + child: MediaQuery( + data: const MediaQueryData( + size: Size(480, 300), + disableAnimations: true, + ), + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showScheduleItemDetailsPopover( + context: context, + anchorContext: context, + item: event, + ), + child: const Text('Open details'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open details')); + await tester.pump(); + + final popover = find + .descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byType(BusyMaxPopoverSurface), + ) + .first; + expect(popover, findsOneWidget); + expect(tester.getSize(popover).height, lessThanOrEqualTo(276)); + expect(find.byType(SingleChildScrollView), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('anchored popover keeps nonzero layout in a tiny viewport', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(20, 20); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return const SizedBox.expand(); + }, + ), + ), + ), + ); + + final completion = showScheduleAnchoredPopover( + context: hostContext, + anchorContext: hostContext, + anchorPoint: const Offset(10, 10), + semanticLabel: 'Tiny popover', + builder: (_, _, _) => + const SizedBox(key: Key('tiny-popover-child'), height: 1), + ); + await tester.pumpAndSettle(); + + final childRect = tester.getRect( + find.byKey(const Key('tiny-popover-child')), + ); + final viewport = tester.view.physicalSize / tester.view.devicePixelRatio; + expect(childRect.width, greaterThan(0)); + expect(childRect.height, greaterThan(0)); + expect(childRect.left, greaterThanOrEqualTo(0)); + expect(childRect.top, greaterThanOrEqualTo(0)); + expect(childRect.right, lessThanOrEqualTo(viewport.width)); + expect(childRect.bottom, lessThanOrEqualTo(viewport.height)); + expect(tester.takeException(), isNull); + + Navigator.of(hostContext, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + await completion; + }); + testWidgets('schedule item details popover shows categories', (tester) async { final selectedDate = DateTime(2026, 1, 15); final task = TaskScheduleItem( @@ -539,6 +1802,49 @@ void main() { expect(find.text('Categories: Blue category, Work'), findsOneWidget); }); + testWidgets('event reminder details use locale-aware labels', (tester) async { + final event = CalendarScheduleItem( + id: 'event:localized-reminders', + accountId: 'google:g', + provider: TaskProvider.google, + sourceId: 'calendar:primary', + providerCalendarId: 'primary', + title: 'Termin', + allDay: false, + start: DateTime(2026, 1, 15, 9), + end: DateTime(2026, 1, 15, 10), + reminderMinutesBeforeStart: const [0, 60, 1440], + ); + + await tester.pumpWidget( + localizedTestApp( + locale: const Locale('de'), + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showScheduleItemDetailsPopover( + context: context, + anchorContext: context, + item: event, + ), + child: const Text('Öffnen'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Öffnen')); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Erinnerung: Zum Startzeitpunkt, 1 Stunde vorher, 1 Tag vorher', + ), + findsOneWidget, + ); + }); + testWidgets('schedule task details popover shows reminder', (tester) async { final selectedDate = DateTime(2026, 1, 15); final task = TaskScheduleItem( @@ -614,19 +1920,41 @@ void main() { ), ), ); - - await tester.tap(find.text('Open details')); - await tester.pumpAndSettle(); - - final popover = find.byWidgetPredicate( - (widget) => - widget is PhysicalShape && - widget.elevation == BusyMaxElevation.tooltip, - ); - final topLeft = tester.getTopLeft(popover); - - expect(topLeft.dx, greaterThan(300)); - expect(topLeft.dy, greaterThan(100)); + + await tester.tap(find.text('Open details')); + await tester.pumpAndSettle(); + + final popover = find + .descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byType(BusyMaxPopoverSurface), + ) + .first; + final rect = tester.getRect(popover); + final viewport = tester.view.physicalSize / tester.view.devicePixelRatio; + + expect(rect.left, greaterThan(300)); + expect(rect.top, greaterThan(100)); + expect( + rect.left, + greaterThanOrEqualTo(BusyMaxShadow.nativePopoverPaintMargin), + ); + expect( + rect.right, + lessThanOrEqualTo( + viewport.width - BusyMaxShadow.nativePopoverPaintMargin, + ), + ); + expect( + rect.top, + greaterThanOrEqualTo(BusyMaxShadow.nativePopoverPaintMargin), + ); + expect( + rect.bottom, + lessThanOrEqualTo( + viewport.height - BusyMaxShadow.nativePopoverPaintMargin, + ), + ); }); testWidgets('schedule item details popover shown above stays near click', ( @@ -665,11 +1993,12 @@ void main() { await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); - final popover = find.byWidgetPredicate( - (widget) => - widget is PhysicalShape && - widget.elevation == BusyMaxElevation.tooltip, - ); + final popover = find + .descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byType(BusyMaxPopoverSurface), + ) + .first; final rect = tester.getRect(popover); expect(rect.bottom, greaterThan(500)); @@ -761,13 +2090,13 @@ void main() { expect(scheduleExportFileName(event), endsWith('.ics')); }); - test('month weekday header uses calendar surface background', () { + test('month weekday header uses the semantic window background', () { final source = File( 'lib/src/features/schedule/presentation/schedule_month_view.dart', ).readAsStringSync(); expect(source, contains('ColoredBox(')); - expect(source, contains('color: theme.colorScheme.surface')); + expect(source, contains('color: workspaceColor')); }); testWidgets('agenda view is custom and keeps no-date tasks', (tester) async { @@ -1066,8 +2395,10 @@ void main() { expect(source, contains('linuxHeaderBarServiceProvider')); expect(source, contains('final showFallbackHeader')); expect(source, contains('if (showFallbackHeader)')); - expect(source, contains('setTitleRange(headerBarState.titleRange)')); - expect(source, contains('setViewMode(headerBarState.viewMode)')); + expect(source, contains('final headerBarState = BusyMaxHeaderBarState(')); + expect(source, contains('.claimSession()')); + expect(source, contains('_headerBarSession.updateState(headerBarState)')); + expect(source, contains('onMenuSelected: _handleFallbackToolbarMenu')); }); test('native headerbar actions are wired to schedule commands', () { @@ -1082,12 +2413,13 @@ void main() { expect(source, contains('BusyMaxHeaderBarAction.viewModeMonth')); expect(source, contains('BusyMaxHeaderBarAction.viewModeYear')); expect(source, contains('BusyMaxHeaderBarAction.viewModeAgenda')); + expect(source, isNot(contains('BusyMaxHeaderBarAction.viewModeCompact'))); expect(source, contains('BusyMaxHeaderBarAction.refresh')); expect(source, contains('allAccountsSyncRunnerProvider')); expect(source, contains('context.l10n.allTasksRefreshed')); expect(source, contains('setScheduleViewMode(mode)')); expect(source, contains('settings.scheduleViewMode')); - expect(source, isNot(contains('ScheduleEmptyState'))); + expect(source, contains('ScheduleEmptyState')); expect(source, isNot(contains('BusyMaxHeaderBarAction.newItem'))); expect(source, isNot(contains('BusyMaxHeaderBarAction.openMenu'))); }); @@ -1097,7 +2429,15 @@ void main() { 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); - expect(source, contains('HardwareKeyboard.instance.addHandler')); + expect(source, isNot(contains('HardwareKeyboard.instance.addHandler'))); + expect(source, contains('return Shortcuts(')); + expect(source, contains('_ScheduleShortcutAction(this)')); + expect(source, contains('route != null && !route.isCurrent')); + expect(source, contains('BusyMaxShortcutActivators.search:')); + expect(source, contains('BusyMaxShortcutActivators.sidebar:')); + expect(source, contains('_ScheduleShortcut.sidebar')); + expect(source, contains('_sidebarCollapsed = !_sidebarCollapsed')); + expect(source, isNot(contains('BusyMaxShortcutActivators.create:'))); expect(source, contains('LogicalKeyboardKey.arrowRight')); expect(source, contains('_next();')); expect(source, contains('LogicalKeyboardKey.arrowLeft')); @@ -1108,26 +2448,32 @@ void main() { expect(source, isNot(contains('LogicalKeyboardKey.keyP'))); expect(source, contains('LogicalKeyboardKey.keyE')); expect(source, isNot(contains('LogicalKeyboardKey.keyC'))); - expect(source, contains('_openNewEvent(_latestVisibleSources')); + expect(source, contains('_openNewEvent(_latestWritableSources')); expect(source, contains('LogicalKeyboardKey.keyT')); expect(source, contains('_openNewTask(_latestAccounts')); - expect(source, contains('keyboard.isShiftPressed')); + expect( + source, + contains('SingleActivator(LogicalKeyboardKey.keyT, shift: true)'), + ); expect(source, contains('_goToToday();')); expect(source, contains('LogicalKeyboardKey.digit1')); - expect(source, contains('LogicalKeyboardKey.keyD')); + expect(source, isNot(contains('LogicalKeyboardKey.keyD'))); expect(source, contains('_setMode(ScheduleViewMode.day)')); expect(source, contains('LogicalKeyboardKey.digit2')); - expect(source, contains('LogicalKeyboardKey.keyW')); + expect(source, isNot(contains('LogicalKeyboardKey.keyW'))); expect(source, contains('_setMode(ScheduleViewMode.week)')); expect(source, contains('LogicalKeyboardKey.digit3')); - expect(source, contains('LogicalKeyboardKey.keyM')); + expect(source, isNot(contains('LogicalKeyboardKey.keyM'))); expect(source, contains('_setMode(ScheduleViewMode.month)')); expect(source, contains('LogicalKeyboardKey.digit4')); - expect(source, contains('LogicalKeyboardKey.keyY')); + expect(source, isNot(contains('LogicalKeyboardKey.keyY'))); expect(source, contains('_setMode(ScheduleViewMode.year)')); - expect(source, contains('LogicalKeyboardKey.digit0')); - expect(source, contains('LogicalKeyboardKey.keyA')); + expect(source, contains('LogicalKeyboardKey.digit5')); + expect(source, isNot(contains('LogicalKeyboardKey.keyA'))); expect(source, contains('_setMode(ScheduleViewMode.agenda)')); + expect(source, isNot(contains('LogicalKeyboardKey.digit0'))); + expect(source, isNot(contains('LogicalKeyboardKey.numpad0'))); + expect(source, isNot(contains('ScheduleViewMode.compact'))); expect(source, contains('focusContext.widget is! EditableText')); }); @@ -1140,7 +2486,7 @@ void main() { expect(source, contains('_requestCalendarMutationSync(draft.accountId)')); expect(source, contains('.deleteLocalEvent(eventId)')); expect(source, contains('_requestCalendarMutationSync(accountId)')); - expect(source, contains('calendarSyncEngineForAccountFactoryProvider')); + expect(source, contains('accountSyncOperationsProvider')); expect(source, isNot(contains('signedInSyncRunnerProvider)(accountId'))); }); @@ -1160,213 +2506,545 @@ void main() { expect(source, contains('reminders: _eventRemindersForEdit(')); }); - test('schedule item details actions use shared button styling', () { - final popover = File( - 'lib/src/features/schedule/presentation/schedule_item_details_popover.dart', - ).readAsStringSync(); - final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + test('month overflow uses the shared anchored popover route', () { + final month = File( + 'lib/src/features/schedule/presentation/schedule_month_view.dart', + ).readAsStringSync(); + final more = File( + 'lib/src/features/schedule/presentation/schedule_more_popover.dart', + ).readAsStringSync(); + + expect(month, contains('anchorContext: anchorContext')); + expect( + more, + contains('showScheduleAnchoredPopover('), + ); + expect(more, contains('BusyMaxPopoverSurface(')); + expect(more, isNot(contains('showDialog('))); + expect(more, isNot(contains('Dialog('))); + expect(more, isNot(contains('elevation:'))); + expect(more, isNot(contains('BoxShadow('))); + }); + + test('schedule item details actions use the shared contained Yaru role', () { + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final adapter = design.substring( + design.indexOf('class BusyMaxPopoverIconButton'), + design.indexOf('Color busyMaxSelectedBackground'), + ); + final popover = File( + 'lib/src/features/schedule/presentation/schedule_item_details_popover.dart', + ).readAsStringSync(); + + expect(adapter, contains('class BusyMaxPopoverIconButton')); + expect(adapter, contains('child: YaruIconButton(')); + expect(adapter, contains('shape: const CircleBorder()')); + expect(design, contains('class BusyMaxHeaderIconButton')); + expect(adapter, contains('iconSize: BusyMaxSizes.popoverActionButton')); + expect(adapter, isNot(contains('final button = IconButton('))); + expect(adapter, isNot(contains('busyMaxHeaderButtonBackground(context)'))); + expect(popover, contains('BusyMaxPopoverIconButton(')); + expect(popover, contains('BusyMaxContentPopoverSurface(')); + expect(popover, isNot(contains('surfaceColors.popover'))); + expect(popover, contains('destructive: true')); + expect(popover, isNot(contains('backgroundColor:'))); + expect(popover, isNot(contains('foregroundColor:'))); + expect(popover, isNot(contains('hoverColor:'))); + expect(popover, isNot(contains('elevation:'))); + expect(popover, isNot(contains('BoxShadow('))); + }); + + test( + 'schedule search renders query results instead of current range only', + () { + final workspace = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + final agenda = File( + 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', + ).readAsStringSync(); + final repository = File( + 'lib/src/schedule/schedule_repository.dart', + ).readAsStringSync(); + + expect( + workspace, + contains('final searchHasQuery = _searchQuery.trim().isNotEmpty'), + ); + expect(workspace, contains('_rangeForSearchResults(items, range)')); + expect(workspace, contains('? ScheduleViewMode.agenda')); + expect( + repository, + contains('final searching = filters.query.trim().isNotEmpty'), + ); + expect(repository, contains('!searching && !_intersects')); + expect(agenda, contains('groups.keys')); + expect(agenda, contains('ColoredBox(')); + expect( + agenda, + contains('color: BusyMaxSurfaceColors.of(context).window'), + ); + expect(agenda, isNot(contains('_daysInRange'))); + }, + ); + + test('agenda list groups use the shared grouped row surface', () { + final agenda = File( + 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', + ).readAsStringSync(); + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + + expect(agenda, contains('BusyMaxGroupedList(')); + expect(agenda, isNot(contains('surfaceColor:'))); + expect(agenda, contains('ScheduleProjection.colorForItem')); + expect( + agenda, + contains('BusyMaxSurfaceColors.of(context).mutedForeground'), + ); + expect(design, isNot(contains('final Color? surfaceColor;'))); + expect(design, isNot(contains('color: color ?? surfaceColors.control'))); + expect(design, contains('CardTheme.of(context)')); + expect(design, isNot(contains('lightSurfaceShadowMinimum'))); + expect(design, isNot(contains('class _BusyMaxRowTile'))); + }); + + test('sidebar does not render redundant provider group titles', () { + final sidebar = File( + 'lib/src/features/schedule/presentation/schedule_sidebar.dart', + ).readAsStringSync(); + final nativeRunner = File( + 'linux/runner/my_application.cc', + ).readAsStringSync(); + + expect(nativeRunner, contains('header_sidebar_brand_box')); + expect(nativeRunner, contains('gtk_label_new(kApplicationDisplayName)')); + expect(nativeRunner, isNot(contains('busymax-sidebar-header'))); + expect(nativeRunner, isNot(contains('GtkWidget* brand_box'))); + expect(sidebar, isNot(contains('title: account.provider.displayName'))); + expect(sidebar, isNot(contains('YaruIcons.globe'))); + expect(sidebar, isNot(contains('Icons.account_circle_outlined'))); + }); + + test('sidebar account rows are accordions', () { + final sidebar = File( + 'lib/src/features/schedule/presentation/schedule_sidebar.dart', + ).readAsStringSync(); + + expect(sidebar, contains('class _AccountSourcesGroupState')); + expect(sidebar, contains('var _expanded = true')); + expect(sidebar, contains('class _AccountHeaderRow')); + expect( + sidebar, + contains("key: ValueKey(('schedule-account', account.id))"), + ); + expect(sidebar, contains("'schedule-calendar'")); + expect(sidebar, contains("'schedule-task-list'")); + expect(sidebar, contains('AnimatedRotation')); + expect(sidebar, contains('BusyMaxGlyphs.collapsedFor')); + expect(sidebar, contains('if (_expanded)')); + expect(sidebar, contains('MiniCalendar(')); + expect(sidebar, isNot(contains('BusyMaxGroupedList('))); + expect(sidebar, isNot(contains('hoverColor: Colors.transparent'))); + }); + + test('mini calendar keeps surface behavior outside the shared grid', () { + final source = File( + 'lib/src/features/schedule/presentation/mini_calendar.dart', + ).readAsStringSync(); + + expect(source, contains('class MiniCalendar extends StatefulWidget')); + expect(source, contains('class YearMonthMiniCalendar')); + expect(source, contains('class MiniCalendarGrid extends StatelessWidget')); + expect(source, contains('class _MiniCalendarStepper')); + expect(source, contains('miniCalendarMarkerColorsForItems')); + expect( + source, + contains('final groupedItems = ScheduleProjection.groupByDay'), + ); + expect(source, contains('onWeekSelected == null')); + }); + + testWidgets('mini calendar exposes and activates the selected day', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + DateTime? activatedDay; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2026, 1, 15), + firstWeekday: DateTime.monday, + onSelected: (day) => activatedDay = day, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final selectedDay = find.text('15'); + final selectedSemantics = tester.getSemantics(selectedDay); + expect(selectedSemantics.flagsCollection.isSelected, ui.Tristate.isTrue); + expect(selectedSemantics.label, contains('January 15, 2026')); + + await tester.tap(selectedDay); + expect(activatedDay, DateTime(2026, 1, 15)); + semantics.dispose(); + }); + + testWidgets('mini calendar day hover is larger and clearly visible', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendarGrid( + displayedMonth: DateTime(2026, 1), + selectedDate: DateTime(2026, 1, 15), + firstWeekday: DateTime.monday, + onDaySelected: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final day = find.text('14'); + final dayInkWell = tester.widget( + find.ancestor(of: day, matching: find.byType(InkWell)), + ); + expect(dayInkWell.hoverColor, Colors.transparent); + final marker = find.ancestor( + of: day, + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).shape == BoxShape.circle, + ), + ); + expect(marker, findsOneWidget); + final restingSize = tester.getSize(marker); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(day)); + await tester.pumpAndSettle(); + + final hoveredSize = tester.getSize(marker); + final hoveredMarker = tester.widget(marker); + final decoration = hoveredMarker.decoration as BoxDecoration; + expect(hoveredSize.width, greaterThan(restingSize.width)); + expect(hoveredSize.width, 32); + expect( + (decoration.color!.computeLuminance() - colors.card.computeLuminance()) + .abs(), + greaterThan(0.05), + ); + }); + + testWidgets('mini calendar header controls are compact hover-only circles', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2026, 1, 15), + firstWeekday: DateTime.monday, + onSelected: (_) {}, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), + ), + ), + ), + ); + + final headerButtons = tester.widgetList( + find.byType(BusyMaxHeaderIconButton), + ); + expect(headerButtons, hasLength(4)); + for (final button in headerButtons) { + expect(button.fixedSize, const Size.square(28)); + expect(button.shape, const CircleBorder()); + expect(button.backgroundColor!.resolve({}), isNull); + expect(button.backgroundColor!.resolve({WidgetState.hovered}), isNotNull); + } + + final monthLabel = tester.widget(find.text('January')); + final yearLabel = tester.widget(find.text('2026')); + expect(monthLabel.style?.fontSize, lessThan(14)); + expect(monthLabel.style?.fontWeight, FontWeight.w600); + expect(yearLabel.style?.fontSize, lessThan(14)); + expect(yearLabel.style?.fontWeight, FontWeight.w600); + + final monthButton = find.ancestor( + of: find.text('January'), + matching: find.byType(TextButton), + ); + final yearButton = find.ancestor( + of: find.text('2026'), + matching: find.byType(TextButton), + ); + expect( + tester.getSize(monthButton).width, + greaterThan(tester.getSize(yearButton).width), + ); + + final weekButton = find.descendant( + of: find.byTooltip('Week 3'), + matching: find.byType(TextButton), + ); + expect( + tester.getSize(weekButton).height, + tester.getSize(monthButton).height, + ); + }); + + testWidgets('mini calendar uses a capitalized standalone Russian month', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + locale: const Locale('ru'), + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2026, 7, 15), + firstWeekday: DateTime.monday, + onSelected: (_) {}, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Июль'), findsOneWidget); + expect(find.text('июль'), findsNothing); + expect(find.text('июля'), findsNothing); + }); + + testWidgets('mini calendar header arrows page without selecting a date', ( + tester, + ) async { + final selectedDates = []; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2026, 1, 15), + firstWeekday: DateTime.monday, + onSelected: selectedDates.add, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Next month')); + await tester.pump(); + expect(find.text('February'), findsOneWidget); + expect(find.text('2026'), findsOneWidget); + + await tester.tap(find.byTooltip('Next year')); + await tester.pump(); + expect(find.text('February'), findsOneWidget); + expect(find.text('2027'), findsOneWidget); + + await tester.tap(find.byTooltip('Previous month')); + await tester.pump(); + expect(find.text('January'), findsOneWidget); + expect(find.text('2027'), findsOneWidget); + + await tester.tap(find.byTooltip('Previous year')); + await tester.pump(); + expect(find.text('January'), findsOneWidget); + expect(find.text('2026'), findsOneWidget); + expect(selectedDates, isEmpty); + }); + + testWidgets('mini calendar week number selects that week', (tester) async { + DateTime? selectedWeek; - expect(popover, contains('BusyMaxCircularAction(')); - expect(popover, contains('destructive: true')); - expect(popover, isNot(contains('backgroundColor:'))); - expect(popover, isNot(contains('foregroundColor:'))); - expect(popover, isNot(contains('hoverColor:'))); - expect( - design, - contains('final surfaceColors = BusyMaxSurfaceColors.of(context);'), + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2026, 1, 15), + firstWeekday: DateTime.monday, + onSelected: (_) {}, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (weekStart) => selectedWeek = weekStart, + ), + ), + ), + ), ); - expect(design, contains('color: surfaceColors.control')); - expect(design, contains('color: foregroundColor')); - }); - test( - 'schedule search renders query results instead of current range only', - () { - final workspace = File( - 'lib/src/features/schedule/presentation/schedule_workspace.dart', - ).readAsStringSync(); - final agenda = File( - 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', - ).readAsStringSync(); - final repository = File( - 'lib/src/schedule/schedule_repository.dart', - ).readAsStringSync(); + await tester.tap(find.byTooltip('Week 3')); - expect( - workspace, - contains('final searchHasQuery = _searchQuery.trim().isNotEmpty'), - ); - expect(workspace, contains('_rangeForSearchResults(items, range)')); - expect( - workspace, - contains( - 'searchHasQuery\n ? ScheduleViewMode.agenda', - ), - ); - expect( - repository, - contains('final searching = filters.query.trim().isNotEmpty'), - ); - expect(repository, contains('!searching && !_intersects')); - expect(agenda, contains('groups.keys')); - expect(agenda, contains('ColoredBox(')); - expect(agenda, contains('color: Theme.of(context).colorScheme.surface')); - expect(agenda, isNot(contains('_daysInRange'))); - }, - ); + expect(selectedWeek, DateTime(2026, 1, 12)); + }); - test('agenda list groups use the shared grouped row surface', () { - final agenda = File( - 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', - ).readAsStringSync(); - final compactAgenda = File( - 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', - ).readAsStringSync(); - final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + testWidgets('year view week number selects that week', (tester) async { + DateTime? selectedWeek; - expect(agenda, contains('BusyMaxGroupedList(')); - expect(compactAgenda, contains('BusyMaxGroupedList(')); - expect(agenda, isNot(contains('surfaceColor:'))); - expect(compactAgenda, isNot(contains('surfaceColor:'))); - expect(agenda, isNot(contains('ScheduleProjection.colorForItem'))); - expect(compactAgenda, isNot(contains('ScheduleProjection.colorForItem'))); - expect( - agenda, - contains('BusyMaxSurfaceColors.of(context).mutedForeground'), - ); - expect( - compactAgenda, - contains('BusyMaxSurfaceColors.of(context).mutedForeground'), + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleYearView( + selectedDate: DateTime(2026, 1, 15), + items: const [], + firstWeekday: DateTime.monday, + onDaySelected: (_) {}, + onMonthSelected: (_) {}, + onWeekSelected: (weekStart) => selectedWeek = weekStart, + onCreateAtDay: (_) {}, + ), + ), + ), + ), ); - expect(design, isNot(contains('final Color? surfaceColor;'))); - expect(design, isNot(contains('color: color ?? surfaceColors.control'))); - expect(design, contains('color: surfaceColors.control')); - }); - test('sidebar does not render redundant provider group titles', () { - final sidebar = File( - 'lib/src/features/schedule/presentation/schedule_sidebar.dart', - ).readAsStringSync(); - final nativeRunner = File( - 'linux/runner/my_application.cc', - ).readAsStringSync(); + final january = find.byType(YearMonthMiniCalendar).first; + await tester.tap( + find.descendant(of: january, matching: find.byTooltip('Week 3')), + ); - expect(nativeRunner, contains('header_sidebar_brand_box')); - expect(nativeRunner, contains('gtk_label_new(kApplicationDisplayName)')); - expect(nativeRunner, isNot(contains('busymax-sidebar-header'))); - expect(nativeRunner, isNot(contains('GtkWidget* brand_box'))); - expect(sidebar, isNot(contains('title: account.provider.displayName'))); - expect(sidebar, isNot(contains('YaruIcons.globe'))); - expect(sidebar, isNot(contains('Icons.account_circle_outlined'))); + expect(selectedWeek, DateTime(2026, 1, 12)); }); - test('sidebar account rows are accordions', () { - final sidebar = File( - 'lib/src/features/schedule/presentation/schedule_sidebar.dart', - ).readAsStringSync(); + testWidgets( + 'mini calendar numbers Sunday-first rows from their displayed start date', + (tester) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2027, 1, 15), + firstWeekday: DateTime.sunday, + onSelected: (_) {}, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), + ), + ), + ), + ); - expect(sidebar, contains('class _AccountSourcesGroupState')); - expect(sidebar, contains('var _expanded = true')); - expect(sidebar, contains('class _AccountHeaderRow')); - expect(sidebar, contains('AnimatedRotation')); - expect(sidebar, contains('YaruIcons.pan_end')); - expect(sidebar, contains('if (_expanded)')); - expect(sidebar, isNot(contains('BusyMaxGroupedList('))); - expect(sidebar, isNot(contains('hoverColor: Colors.transparent'))); - }); + expect(find.byTooltip('Week 52'), findsOneWidget); + expect(find.byTooltip('Week 53'), findsOneWidget); + expect(find.byTooltip('Week 1'), findsOneWidget); + }, + ); - test('mini calendar has separate month and year steppers', () { - final source = File( - 'lib/src/features/schedule/presentation/mini_calendar.dart', - ).readAsStringSync(); + testWidgets('mini calendar week buttons have hover-only backgrounds', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2026, 1, 15), + firstWeekday: DateTime.monday, + onSelected: (_) {}, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), + ), + ), + ), + ); - expect(source, contains('class _MiniCalendarStepper')); - expect(source, contains('required this.onMonthSelected')); - expect(source, contains('required this.onYearSelected')); - expect(source, contains('required this.onWeekSelected')); - expect(source, contains('required this.firstWeekday')); - expect(source, contains('_calendarStartForMonth(first, firstWeekday)')); - expect(source, contains('monthWeekdayFromMonday')); - expect(source, contains('firstWeekdayFromMonday')); - expect(source, contains('_addCalendarDays(')); - expect(source, contains('row * DateTime.daysPerWeek')); - expect(source, contains('_addCalendarDays(weekStart, column)')); - expect(source, isNot(contains('weekStart.add(Duration(days: column))'))); - expect(source, contains('final weekNumberExtent = math.min')); - expect(source, contains('constraints.maxWidth - weekNumberExtent')); - expect(source, isNot(contains('final calendarWidth ='))); - expect(source, contains('class _MiniCalendarWeekRow')); - expect(source, contains('class _MiniCalendarWeekNumberButton')); - expect(source, contains('class _MiniCalendarDayButton')); - expect(source, contains('class _MiniCalendarDayIndicators')); - expect( - source, - contains('final groupedItems = ScheduleProjection.groupByDay(items)'), + final weekButton = tester.widget( + find.descendant( + of: find.byTooltip('Week 3'), + matching: find.byType(TextButton), + ), ); - expect(source, contains('DateFormat.E(')); - expect(source, contains('_weekdays(firstWeekday)')); - expect(source, contains('DateFormat.yMMMMEEEEd(locale)')); - expect(source, contains('ScheduleProjection.colorForItem')); - expect(source, contains('height: dayExtent')); - expect(source, contains('width: double.infinity')); - expect(source, contains('crossAxisAlignment: CrossAxisAlignment.stretch')); - expect(source, contains('SizedBox(width: weekNumberExtent)')); + expect(weekButton.style?.backgroundColor?.resolve({}), isNull); expect( - source, - contains('for (var column = 0; column < DateTime.daysPerWeek; column++)'), + weekButton.style?.backgroundColor?.resolve({WidgetState.hovered}), + isNotNull, ); - expect(source, isNot(contains('GridView.builder'))); - expect(source, contains('const SizedBox(width: BusyMaxSpacing.xs)')); - expect(source, contains('label: _monthName(selectedDate)')); - expect(source, contains('BusyMaxSpacing.headerInset')); - expect( - source, - isNot(contains('padding: const EdgeInsets.all(BusyMaxSpacing.md)')), + }); + + testWidgets('mini calendar can render week numbers as labels', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendarGrid( + displayedMonth: DateTime(2026, 1), + selectedDate: DateTime(2026, 1, 15), + firstWeekday: DateTime.monday, + onDaySelected: (_) {}, + ), + ), + ), + ), ); - expect(source, contains("labelTooltip: 'Open month'")); - expect(source, contains('onMonthSelected(first)')); - expect(source, contains('busyMaxHeaderTextButtonStyle')); - expect(source, contains("label: '\${selectedDate.year}'")); - expect(source, contains("labelTooltip: 'Open year'")); - expect(source, contains('onYearSelected(')); - expect(source, contains('String _monthName(DateTime date)')); - expect(source, contains('return months[date.month - 1];')); - expect( - source, - isNot(contains("return '\${months[date.month - 1]} \${date.year}';")), - ); - expect(source, contains("'Previous month'")); - expect(source, contains("'Next month'")); - expect(source, contains("'Previous year'")); - expect(source, contains("'Next year'")); - expect(source, contains('selectedDate.year - 1')); - expect(source, contains('selectedDate.year + 1')); - expect(source, contains('busyMaxHeaderIconButtonStyle')); - expect(source, contains('miniCalendarWeekButton')); - expect(source, contains('busyMaxHeaderButtonBackground(context)')); - expect(source, isNot(contains('busyMaxSubtleButtonBackground(context)'))); - expect(source, contains('_isoWeekNumber')); - expect(source, contains('DateTime.daysPerWeek')); - expect(source, contains('TextButton(')); - expect(source, contains("message: 'Week \$weekNumber'")); - expect(source, contains('onSelected(weekStart)')); - expect(source, contains('BoxShape.circle')); - expect(source, contains('customBorder: const CircleBorder()')); - expect(source, contains('final markerSize = math.min')); - expect(source, contains('final highlightToday = today && currentMonth')); - expect(source, contains('color: highlightToday')); - expect(source, contains('selectedYear == DateTime.now().year')); - expect(source, contains('selectedMonth == DateTime.now().month')); + + expect(find.byTooltip('Week 3'), findsOneWidget); expect( - source, - isNot(contains('final selected = _sameDay(day, selectedDate)')), + find.descendant( + of: find.byTooltip('Week 3'), + matching: find.byType(TextButton), + ), + findsNothing, ); - expect(source, isNot(contains('YaruIcons.arrow_left'))); - expect(source, isNot(contains('YaruIcons.arrow_right'))); - expect(source, isNot(contains('BorderRadius.circular(BusyMaxRadius.sm)'))); + await tester.tap(find.byTooltip('Week 3')); }); - testWidgets('mini calendar week number selects that week', (tester) async { + testWidgets('mini calendar week number honors first weekday', (tester) async { DateTime? selectedWeek; await tester.pumpWidget( @@ -1376,7 +3054,7 @@ void main() { width: 300, child: MiniCalendar( selectedDate: DateTime(2026, 1, 15), - firstWeekday: DateTime.monday, + firstWeekday: DateTime.sunday, onSelected: (_) {}, onMonthSelected: (_) {}, onYearSelected: (_) {}, @@ -1387,13 +3065,15 @@ void main() { ), ); - await tester.tap(find.byTooltip('Week 3')); + await tester.tap(find.byTooltip('Week 1')); - expect(selectedWeek, DateTime(2026, 1, 12)); + expect(selectedWeek, DateTime(2026, 1, 4)); }); - testWidgets('mini calendar week number honors first weekday', (tester) async { - DateTime? selectedWeek; + testWidgets('January 2026 Sunday rows use ISO weeks 52 and 1', ( + tester, + ) async { + final selectedWeeks = []; await tester.pumpWidget( localizedTestApp( @@ -1406,16 +3086,23 @@ void main() { onSelected: (_) {}, onMonthSelected: (_) {}, onYearSelected: (_) {}, - onWeekSelected: (weekStart) => selectedWeek = weekStart, + onWeekSelected: selectedWeeks.add, ), ), ), ), ); + expect(find.byTooltip('Week 52'), findsOneWidget); + expect(find.byTooltip('Week 1'), findsOneWidget); + + await tester.tap(find.byTooltip('Week 52')); await tester.tap(find.byTooltip('Week 1')); - expect(selectedWeek, DateTime(2026, 1, 4)); + expect(selectedWeeks, [ + DateTime(2025, 12, 28), + DateTime(2026, 1, 4), + ]); }); testWidgets( @@ -1427,7 +3114,7 @@ void main() { body: SizedBox( width: 300, child: MiniCalendar( - selectedDate: DateTime(2027, 11, 1), + selectedDate: DateTime(2027, 11, 15), firstWeekday: DateTime.sunday, onSelected: (_) {}, onMonthSelected: (_) {}, @@ -1462,6 +3149,21 @@ void main() { ), findsOneWidget, ); + final previousMonthDay = tester.widget( + find.descendant( + of: find.byTooltip('Sunday, October 31, 2027'), + matching: find.text('31'), + ), + ); + final currentMonthDay = tester.widget( + find.descendant( + of: find.byTooltip('Monday, November 1, 2027'), + matching: find.text('1'), + ), + ); + expect(previousMonthDay.style?.color, isNotNull); + expect(previousMonthDay.style!.color!.a, lessThan(1)); + expect(currentMonthDay.style?.color, isNull); }, ); @@ -1555,7 +3257,7 @@ void main() { ), ); - await tester.tap(find.byTooltip('Open month')); + await tester.tap(find.byTooltip('Open month view')); expect(selectedMonth, DateTime(2026, 5)); }); @@ -1581,7 +3283,7 @@ void main() { ), ); - await tester.tap(find.byTooltip('Open year')); + await tester.tap(find.byTooltip('Open year view')); expect(selectedYear, DateTime(2026)); }); @@ -1625,7 +3327,7 @@ void main() { expect(workspace, contains('setScheduleViewMode(ScheduleViewMode.week)')); }); - test('year view day clicks open day mode', () { + test('year view day and week clicks open their matching modes', () { final workspace = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); @@ -1633,6 +3335,9 @@ void main() { expect(workspace, contains('required this.onYearDaySelected')); expect(workspace, contains('onYearDaySelected: _openDay')); expect(workspace, contains('onDaySelected: onYearDaySelected')); + expect(workspace, contains('required this.onWeekSelected')); + expect(workspace, contains('onWeekSelected: _setWeek')); + expect(workspace, contains('onWeekSelected: onWeekSelected')); }); test('sidebar source rows keep visibility actions on the right', () { @@ -1646,6 +3351,7 @@ void main() { expect(sidebar, contains('visibilityButton: _SourceVisibilityButton')); expect(sidebar, contains('menuButton: BusyMaxMenuButton')); expect(sidebar, contains('tooltip: context.l10n.options')); + expect('highlightWhenOpen: false'.allMatches(sidebar), hasLength(2)); expect(sidebar, contains('value ? context.l10n.hide : context.l10n.show')); expect(sidebar, isNot(contains('tooltip: context.l10n.sourceCalendar'))); expect(sidebar, isNot(contains('tooltip: context.l10n.sourceTaskList'))); @@ -1661,7 +3367,7 @@ void main() { expect(sidebar, isNot(contains('_SourceVisibilityIndicator'))); }); - test('schedule create action lives in the headerbar before refresh', () { + test('schedule Create uses a native popover before refresh', () { final workspace = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); @@ -1678,23 +3384,33 @@ void main() { expect(workspace, isNot(contains('floatingActionButtonLocation'))); expect(workspace, isNot(contains('FloatingActionButton('))); - expect(workspace, contains('BusyMaxHeaderBarAction.create')); - expect(workspace, contains('void _openCreateAtSelectedDate()')); - expect(headerService, contains('BusyMaxHeaderBarAction.create')); + expect(workspace, contains('BusyMaxHeaderBarAction.createEvent')); + expect(workspace, contains('BusyMaxHeaderBarAction.createTask')); + expect(workspace, isNot(contains('void _openCreateAtSelectedDate()'))); expect( headerService, - contains("'create' => BusyMaxHeaderBarAction.create"), + isNot(contains("'create' => BusyMaxHeaderBarAction.create")), + ); + expect( + headerBar, + contains('gtk_image_new_from_icon_name("list-add-symbolic"'), ); expect( headerBar, - contains('create_header_icon_button("list-add-symbolic"'), + contains( + 'append_header_action_item(menu, self->header_create_event_label,', + ), ); expect( headerBar, contains( - 'connect_header_bar_action(self, self->create_button, "create")', + 'append_header_action_item(menu, self->header_create_task_label,', ), ); + expect(headerBar, contains('gtk_menu_button_set_menu_model')); + expect(headerBar, contains('g_simple_action_set_enabled')); + expect(headerBar, contains('show_header_create_menu')); + expect(headerService, contains("'showCreateMenu'")); expect( headerBar.indexOf( 'gtk_box_pack_start(GTK_BOX(end_box), self->create_button', @@ -1707,6 +3423,7 @@ void main() { ); expect(sidebar, isNot(contains('context.l10n.create'))); expect(sidebar, isNot(contains('PushButton.filled'))); + expect(toolbar, isNot(contains('BusyMaxShortcutLabels.create'))); expect(toolbar, contains('tooltip: context.l10n.create')); expect(toolbar, contains('icon: const Icon(YaruIcons.plus)')); expect(toolbar, contains('tooltip: context.l10n.refreshAll')); @@ -1757,7 +3474,7 @@ void main() { expect(taskChip, isNot(contains('if (!compact)'))); }); - test('schedule item chips use neutral surfaces instead of blue tints', () { + test('schedule item chips keep neutral surfaces with source accents', () { final eventBlock = File( 'lib/src/features/schedule/presentation/schedule_event_block.dart', ).readAsStringSync(); @@ -1769,16 +3486,17 @@ void main() { ).readAsStringSync(); expect(eventBlock, contains('color: surfaceColors.control')); - expect(eventBlock, contains('color: surfaceColors.subtleBorder')); + expect(eventBlock, contains('sourceAccent')); expect(taskChip, contains('color: surfaceColors.control')); - expect(taskChip, contains('color: surfaceColors.subtleBorder')); + expect(taskChip, contains('color: surfaceColors.divider')); expect(taskChip, contains('YaruCheckbox(')); expect(taskChip, isNot(contains('selectedColor:'))); expect(taskChip, isNot(contains('checkmarkColor:'))); expect(taskChip, isNot(contains('YaruCheckboxTheme'))); expect(eventBlock, isNot(contains('Color.alphaBlend('))); expect(taskChip, isNot(contains('Color.alphaBlend('))); - expect(projection, isNot(contains('_colorFromHex(item.colorHex)'))); + expect(projection, contains('_colorFromHex(item.colorHex)')); + expect(projection, contains('deterministicSourceColor(item.sourceId')); expect(projection, isNot(contains('0xff4d7fa8'))); expect(projection, isNot(contains('0xff8db3d9'))); expect(projection, isNot(contains('0xff326b88'))); @@ -1934,11 +3652,12 @@ void main() { ); expect( workspace, - contains( - 'service.setNavigationVisible(headerBarState.navigationVisible)', - ), + contains('_headerBarSession.updateState(headerBarState)'), ); - expect(headerService, contains('Future setNavigationVisible')); + expect(headerService, contains('class BusyMaxHeaderBarState')); + expect(headerService, contains('class LinuxHeaderBarSession')); + expect(headerService, contains('Future updateState(')); + expect(nativeRunner, contains('set_header_bar_state')); expect(nativeRunner, contains('set_header_navigation_visible')); expect(nativeRunner, contains('setNavigationVisible')); }); @@ -1953,7 +3672,8 @@ void main() { expect( source, contains( - 'showNoDateTasks: searchHasQuery || _mode != ScheduleViewMode.agenda', + 'showNoDateTasks: searchHasQuery || ' + '_mode != ScheduleViewMode.agenda', ), ); expect( @@ -1981,22 +3701,13 @@ void main() { final agenda = File( 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', ).readAsStringSync(); - final compactAgenda = File( - 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', - ).readAsStringSync(); expect(agenda, contains('isTask ? YaruIcons.task_list')); - expect(compactAgenda, contains('isTask ? YaruIcons.task_list')); expect(agenda, contains('YaruCheckbox(')); - expect(compactAgenda, contains('YaruCheckbox(')); expect(agenda, isNot(contains('selectedColor:'))); - expect(compactAgenda, isNot(contains('selectedColor:'))); expect(agenda, isNot(contains('checkmarkColor:'))); - expect(compactAgenda, isNot(contains('checkmarkColor:'))); expect(agenda, isNot(contains('YaruCheckboxTheme'))); - expect(compactAgenda, isNot(contains('YaruCheckboxTheme'))); expect(agenda, isNot(contains('YaruIcons.checkbox'))); - expect(compactAgenda, isNot(contains('YaruIcons.checkbox'))); }); test( @@ -2040,24 +3751,32 @@ void main() { expect(workspace, contains('ScheduleRange.year(_selectedDate)')); expect(workspace, contains('DateFormat.y(locale).format(selectedDate)')); expect(workspace, contains('ScheduleYearView(')); - expect(yearView, contains('ScheduleProjection.groupByDay(items)')); - expect(yearView, contains('ScheduleProjection.colorForItem')); expect(yearView, contains('final monthWidth =')); - expect(yearView, contains('_monthPanelHeight(monthWidth)')); - expect(yearView, contains('mainAxisExtent: monthHeight')); + expect(yearView, contains('Wrap(')); + expect(yearView, contains('children: [')); + expect(yearView, contains('spacing: BusyMaxSpacing.md')); + expect(yearView, contains('runSpacing: BusyMaxSpacing.md')); expect(yearView, contains('ColoredBox(')); - expect(yearView, contains('color: Theme.of(context).colorScheme.surface')); - expect(yearView, contains('double _monthPanelHeight(double width)')); + expect( + yearView, + contains('backgroundColor ?? BusyMaxSurfaceColors.of(context).window'), + ); expect(yearView, contains('BusyMaxGroupedSurface(')); - expect(yearView, isNot(contains('BusyMaxSurfaceColors.of(context).card'))); - expect(yearView, contains('BusyMaxActionRow(')); - expect(yearView, contains('class _YearMonthGrid')); - expect(yearView, contains('mainAxisExtent: rowHeight')); - expect(yearView, contains('final markerSize = math.min')); + expect(yearView, contains('YearMonthMiniCalendar(')); + expect(yearView, contains('displayedMonth: DateTime(')); + expect(yearView, contains('onDayDoubleTap: onCreateAtDay')); expect(yearView, contains('firstWeekday')); - expect(yearView, contains('onMonthSelected(month)')); + expect(yearView, contains('onMonthSelected: onMonthSelected')); + expect(yearView, contains('onWeekSelected: onWeekSelected')); + expect( + yearView, + contains('miniCalendarMarkerColorsForItems(context, items)'), + ); expect(yearView, isNot(contains('height: 142'))); - expect(yearView, isNot(contains('availableHeight'))); + expect(yearView, contains('SingleChildScrollView(')); + expect(yearView, isNot(contains('class _YearMonthGrid'))); + expect(yearView, isNot(contains('class _YearDayCell'))); + expect(yearView, isNot(contains('ScheduleProjection.'))); expect(yearView, isNot(contains('borderColor'))); expect(yearView, isNot(contains('RoundedRectangleBorder('))); expect(yearView, isNot(contains('TextButton('))); @@ -2068,14 +3787,82 @@ void main() { 'lib/src/features/schedule/presentation/schedule_month_view.dart', ).readAsStringSync(); - expect(source, contains('colorScheme.onSurface.withValues')); - expect(source, contains('Brightness.dark ? 0.06 : 0.10')); + expect(source, contains('busyMaxCalendarGridColor(context)')); expect(source, contains('position: DecorationPosition.foreground')); expect(source, contains('left: BorderSide(color: border)')); expect(source, contains('bottom: row == rows - 1')); }); } +Finder _popoverDecorationFinder(Finder ancestor) { + return find + .descendant( + of: ancestor, + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is ShapeDecoration && + ((widget.decoration as ShapeDecoration).shadows?.isNotEmpty ?? + false), + ), + ) + .first; +} + +Future<({Uint8List bytes, int width})> _capturePixels( + WidgetTester tester, + GlobalKey key, +) async { + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = (await tester.binding.runAsync(boundary.toImage))!; + try { + final byteData = (await tester.binding.runAsync( + () => image.toByteData(format: ui.ImageByteFormat.rawStraightRgba), + ))!; + return ( + bytes: byteData.buffer.asUint8List( + byteData.offsetInBytes, + byteData.lengthInBytes, + ), + width: image.width, + ); + } finally { + image.dispose(); + } +} + +Color _pixelAt( + ({Uint8List bytes, int width}) pixels, { + required int x, + required int y, +}) { + final offset = (y * pixels.width + x) * 4; + return Color.fromARGB( + pixels.bytes[offset + 3], + pixels.bytes[offset], + pixels.bytes[offset + 1], + pixels.bytes[offset + 2], + ); +} + +double _luminanceDistance(Color first, Color second) { + return (first.computeLuminance() - second.computeLuminance()).abs(); +} + +const _recessedGtkDivider = Color.fromRGBO(0, 0, 6, 0.56); + +ThemeData _darkCalendarGridTestTheme() { + return BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + gtkThemeColors: const GtkThemeColors( + brightness: Brightness.dark, + divider: _recessedGtkDivider, + ), + ); +} + List _itemsFor(DateTime day) { return [ CalendarScheduleItem( diff --git a/test/features/schedule/presentation/schedule_workspace_states_test.dart b/test/features/schedule/presentation/schedule_workspace_states_test.dart new file mode 100644 index 0000000..1371447 --- /dev/null +++ b/test/features/schedule/presentation/schedule_workspace_states_test.dart @@ -0,0 +1,249 @@ +import 'dart:async'; + +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_surface_colors.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_empty_states.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_sidebar.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; +import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../test_localized_app.dart'; + +void main() { + testWidgets('schedule exposes a labeled loading state', (tester) async { + final accounts = StreamController>(); + addTearDown(accounts.close); + + await _pumpWorkspace(tester, accountsFactory: () => accounts.stream); + await tester.pump(); + + expect(find.byType(ScheduleLoadingState), findsOneWidget); + expect(find.text('Loading schedule...'), findsOneWidget); + final loadingContext = tester.element(find.byType(ScheduleLoadingState)); + expect( + tester.widget(find.byType(Scaffold)).backgroundColor, + BusyMaxSurfaceColors.of(loadingContext).window, + ); + }); + + testWidgets('schedule errors are generic and retry the data pipeline', ( + tester, + ) async { + var attempts = 0; + await _pumpWorkspace( + tester, + accountsFactory: () { + attempts += 1; + if (attempts == 1) { + return Stream>.error( + StateError('private account database details'), + ); + } + return Stream.value(const []); + }, + ); + await tester.pumpAndSettle(); + + expect(find.byType(ScheduleUnavailableState), findsOneWidget); + expect(find.text('Schedule unavailable'), findsOneWidget); + expect( + find.textContaining('private account database details'), + findsNothing, + ); + + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + + expect(attempts, 2); + expect(find.byType(ScheduleUnavailableState), findsNothing); + expect(find.byType(ScheduleNoSourcesState), findsOneWidget); + expect(find.text('Connect an account'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); + }); + + testWidgets('standard search shortcut opens and dismisses schedule search', ( + tester, + ) async { + await _pumpWorkspace( + tester, + accountsFactory: () => Stream.value(const []), + ); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxSearchField), findsOneWidget); + expect(find.byType(YaruSearchField), findsOneWidget); + expect(_searchFieldHasPrimaryFocus(tester), isTrue); + + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(_searchFieldHasPrimaryFocus(tester), isTrue); + + await tester.enterText(find.byType(TextField), 'planning'); + await tester.pump(); + await tester.tap(find.byIcon(YaruIcons.edit_clear)); + await tester.pump(); + + expect(find.byType(BusyMaxSearchField), findsOneWidget); + expect( + tester.widget(find.byType(TextField)).controller!.text, + '', + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + expect(find.byType(BusyMaxSearchField), findsNothing); + }); + + testWidgets('F9 hides and shows the schedule sidebar', (tester) async { + await _pumpWorkspace( + tester, + accountsFactory: () => Stream.value(const []), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ScheduleSidebar), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pumpAndSettle(); + expect(find.byType(ScheduleSidebar), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pumpAndSettle(); + expect(find.byType(ScheduleSidebar), findsOneWidget); + }); + + testWidgets( + 'native search owns Linux entry state without a Flutter duplicate', + (tester) async { + const channel = MethodChannel('busymax_test/schedule_native_search'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return call.method == 'initialize' ? true : null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + final headerBarService = LinuxHeaderBarService( + channel: channel, + isLinux: true, + ); + + await _pumpWorkspace( + tester, + accountsFactory: () => Stream.value(const []), + headerBarService: headerBarService, + ); + await tester.pumpAndSettle(); + + await headerBarService.handleNativeMethodCall(const MethodCall('search')); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxSearchField), findsNothing); + expect( + calls.where((call) => call.method == 'setState').last.arguments, + containsPair('searchActive', true), + ); + + await headerBarService.handleNativeMethodCall( + const MethodCall('searchQueryChanged', 'planning'), + ); + await tester.pumpAndSettle(); + expect( + calls.where((call) => call.method == 'setState').last.arguments, + containsPair('searchQuery', 'planning'), + ); + + await headerBarService.handleNativeMethodCall( + const MethodCall('searchCleared'), + ); + await tester.pumpAndSettle(); + final clearedState = calls + .where((call) => call.method == 'setState') + .last + .arguments; + expect(clearedState, containsPair('searchActive', true)); + expect(clearedState, containsPair('searchQuery', '')); + + await headerBarService.handleNativeMethodCall( + const MethodCall('searchEscapePressed'), + ); + await tester.pumpAndSettle(); + expect( + calls.where((call) => call.method == 'setState').last.arguments, + containsPair('searchActive', false), + ); + }, + ); +} + +bool _searchFieldHasPrimaryFocus(WidgetTester tester) { + final searchElement = tester.element(find.byType(BusyMaxSearchField)); + final focusContext = FocusManager.instance.primaryFocus?.context; + if (identical(focusContext, searchElement)) { + return true; + } + var found = false; + if (focusContext is Element) { + focusContext.visitAncestorElements((ancestor) { + found = identical(ancestor, searchElement); + return !found; + }); + } + return found; +} + +Future _pumpWorkspace( + WidgetTester tester, { + required Stream> Function() accountsFactory, + LinuxHeaderBarService? headerBarService, +}) async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + final resolvedHeaderBarService = + headerBarService ?? LinuxHeaderBarService(isLinux: false); + addTearDown(resolvedHeaderBarService.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(database), + accountsStreamProvider.overrideWith((ref) => accountsFactory()), + localTimeZoneProvider.overrideWithValue('UTC'), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + linuxHeaderBarServiceProvider.overrideWithValue( + resolvedHeaderBarService, + ), + ], + child: localizedTestApp(child: const ScheduleWorkspace()), + ), + ); +} + +class _MemorySettingsStore implements LocalSettingsStore { + @override + Future> load() async => {}; + + @override + Future save(Map json) async {} +} diff --git a/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart b/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart index 8ab8c49..02dfb9b 100644 --- a/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart @@ -1,21 +1,45 @@ import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/platform/native_dialog_service.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/schedule/schedule_scope.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:drift/drift.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; +const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); + void main() { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, (_) async => null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); + }); + testWidgets('creating a task from Schedule refreshes the visible items', ( tester, ) async { @@ -46,6 +70,45 @@ void main() { expect(find.text('Created from Schedule'), findsOneWidget); }); + testWidgets('task-list route defaults new tasks to that account and list', ( + tester, + ) async { + final harness = await _pumpScheduleWorkspace( + tester, + initialTaskAccountId: _accountId, + initialTaskListId: _projectListId, + ); + + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Task')); + await tester.pumpAndSettle(); + final titleField = find.byWidgetPredicate( + (widget) => + widget is TextField && widget.decoration?.labelText == 'Title', + ); + await tester.enterText(titleField, 'Created in Projects'); + await tester.pump(); + expect( + tester + .widget(find.byType(BusyMaxEditorHeader)) + .onSave == + null, + isFalse, + ); + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + final allTasks = await harness.database + .select(harness.database.tasks) + .get(); + expect(allTasks, isNotEmpty); + final createdTask = allTasks.single; + expect(createdTask.title, 'Created in Projects'); + expect(createdTask.accountId, _accountId); + expect(createdTask.taskListId, _projectListId); + }); + testWidgets('completing a task from Schedule refreshes its checkbox', ( tester, ) async { @@ -75,11 +138,71 @@ void main() { expect(task.status, 'completed'); expect(tester.widget(checkbox).value, isTrue); }); + + testWidgets('dirty deep-linked task confirms before Escape closes it', ( + tester, + ) async { + final headerBarService = _RecordingHeaderBarService(); + addTearDown(headerBarService.dispose); + await _pumpScheduleWorkspace( + tester, + taskTitle: 'Opened from route', + initialTaskAccountId: _accountId, + initialTaskListId: _taskListId, + initialTaskId: 'task-1', + headerBarService: headerBarService, + ); + + expect(find.text('Edit Task'), findsOneWidget); + expect(find.text('Opened from route'), findsWidgets); + expect(headerBarService.modalBarrierStates, [ + (visible: true, shadeDepth: 1), + ]); + + await tester.enterText(find.byType(TextField).first, 'Unsaved route edit'); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('Discard changes?'), findsOneWidget); + final modalBarrierColors = tester + .widgetList(find.byType(ModalBarrier)) + .map((barrier) => barrier.color) + .toList(); + expect(modalBarrierColors.where((color) => color != null && color.a != 0), [ + busyMaxModalBarrierColor(tester.element(find.byType(ModalBarrier).first)), + ]); + expect( + headerBarService.modalBarrierStates, + [(visible: true, shadeDepth: 1)], + reason: 'the nested confirmation must not repaint the native headerbar', + ); + await tester.tap(find.text('Cancel').last); + await tester.pumpAndSettle(); + expect(find.text('Edit Task'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + + expect(find.text('Edit Task'), findsNothing); + expect(find.text('Opened from route'), findsOneWidget); + expect(headerBarService.modalBarrierStates, [ + (visible: true, shadeDepth: 1), + (visible: false, shadeDepth: 0), + ]); + }); } Future<_ScheduleHarness> _pumpScheduleWorkspace( WidgetTester tester, { String? taskTitle, + String? initialTaskAccountId, + String? initialTaskListId, + String? initialTaskId, + LinuxHeaderBarService? headerBarService, }) async { final database = AppDatabase.memoryForTests(); addTearDown(database.close); @@ -105,6 +228,16 @@ Future<_ScheduleHarness> _pumpScheduleWorkspace( updatedLocalAtUtc: _nowUtc, ), ); + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: _accountId, + id: _projectListId, + title: 'Projects', + rawJson: '{}', + createdLocalAtUtc: _nowUtc, + updatedLocalAtUtc: _nowUtc, + ), + ); if (taskTitle != null) { await database.tasksDao.upsertTask( TasksCompanion.insert( @@ -127,9 +260,11 @@ Future<_ScheduleHarness> _pumpScheduleWorkspace( authState: accountAuthStateSignedIn, displayName: 'Schedule test', ); - final headerBarService = LinuxHeaderBarService(isLinux: false); - addTearDown(headerBarService.dispose); - + final effectiveHeaderBarService = + headerBarService ?? LinuxHeaderBarService(isLinux: false); + if (headerBarService == null) { + addTearDown(effectiveHeaderBarService.dispose); + } await tester.pumpWidget( ProviderScope( overrides: [ @@ -138,7 +273,9 @@ Future<_ScheduleHarness> _pumpScheduleWorkspace( activeAccountProvider.overrideWithValue(_accountId), localTimeZoneProvider.overrideWithValue('UTC'), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + linuxHeaderBarServiceProvider.overrideWithValue( + effectiveHeaderBarService, + ), taskListsRepositoryForAccountProvider.overrideWith((ref, accountId) { return TaskListsRepository(database: database, accountId: accountId); }), @@ -147,7 +284,12 @@ Future<_ScheduleHarness> _pumpScheduleWorkspace( }), ], child: localizedTestApp( - child: const ScheduleWorkspace(initialScope: ScheduleScope.tasks), + child: ScheduleWorkspace( + initialScope: ScheduleScope.tasks, + initialTaskAccountId: initialTaskAccountId, + initialTaskListId: initialTaskListId, + initialTaskId: initialTaskId, + ), ), ), ); @@ -174,6 +316,21 @@ class _MemorySettingsStore implements LocalSettingsStore { Future save(Map json) async {} } +class _RecordingHeaderBarService extends LinuxHeaderBarService { + _RecordingHeaderBarService() : super(isLinux: false); + + final modalBarrierStates = <({bool visible, int shadeDepth})>[]; + + @override + Future setModalBarrierState({ + required bool visible, + required int shadeDepth, + }) async { + modalBarrierStates.add((visible: visible, shadeDepth: shadeDepth)); + } +} + const _accountId = 'google:schedule-test'; const _taskListId = 'inbox'; +const _projectListId = 'projects'; const _nowUtc = '2026-07-19T00:00:00.000Z'; diff --git a/test/features/schedule/schedule_dst_test.dart b/test/features/schedule/schedule_dst_test.dart index 291ca36..b182ea9 100644 --- a/test/features/schedule/schedule_dst_test.dart +++ b/test/features/schedule/schedule_dst_test.dart @@ -87,7 +87,7 @@ void main() { items: const [], firstWeekday: DateTime.monday, onDaySelected: selectedDays.add, - onCreateAtDay: (_) {}, + onCreateAtDay: (_, {anchorContext}) {}, onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index 8dfca0c..5b64ead 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -563,18 +563,22 @@ void main() { final repository = ScheduleRepository(database); final firstPage = await repository.listNoDateTasks( limit: 8, - filters: const ScheduleFilters( + filters: ScheduleFilters( accountIds: {'account'}, taskListFilterActive: true, - taskListIds: {'inbox'}, + taskListKeys: { + ScheduleTaskListKey(accountId: 'account', taskListId: 'inbox'), + }, ), ); final expandedPage = await repository.listNoDateTasks( limit: 12, - filters: const ScheduleFilters( + filters: ScheduleFilters( accountIds: {'account'}, taskListFilterActive: true, - taskListIds: {'inbox'}, + taskListKeys: { + ScheduleTaskListKey(accountId: 'account', taskListId: 'inbox'), + }, ), ); @@ -611,19 +615,23 @@ void main() { final firstPage = await repository.listOverdueTasks( before: DateTime(2026, 6, 10), limit: 8, - filters: const ScheduleFilters( + filters: ScheduleFilters( accountIds: {'account'}, taskListFilterActive: true, - taskListIds: {'inbox'}, + taskListKeys: { + ScheduleTaskListKey(accountId: 'account', taskListId: 'inbox'), + }, ), ); final expandedPage = await repository.listOverdueTasks( before: DateTime(2026, 6, 10), limit: 12, - filters: const ScheduleFilters( + filters: ScheduleFilters( accountIds: {'account'}, taskListFilterActive: true, - taskListIds: {'inbox'}, + taskListKeys: { + ScheduleTaskListKey(accountId: 'account', taskListId: 'inbox'), + }, ), ); @@ -644,6 +652,138 @@ void main() { expect(expandedPage.hasMore, isFalse); }); + test( + 'task-list filters keep equal provider list IDs account-qualified', + () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + for (final accountId in ['account-a', 'account-b']) { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: accountId, + provider: const Value('google'), + authState: const Value('signed_in'), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: accountId, + id: 'inbox', + title: 'Inbox', + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + await database.tasksDao.upsertTask( + TasksCompanion.insert( + accountId: accountId, + taskListId: 'inbox', + id: 'shared-id', + title: 'Task from $accountId', + status: const Value('needsAction'), + dueUtc: const Value('2026-06-12'), + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + } + + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 12)), + filters: ScheduleFilters( + accountIds: const {'account-a', 'account-b'}, + taskListFilterActive: true, + taskListKeys: { + ScheduleTaskListKey(accountId: 'account-a', taskListId: 'inbox'), + }, + includeCalendarEvents: false, + showNoDateTasks: false, + ), + ); + + expect(items.map((item) => item.title), ['Task from account-a']); + }, + ); + + test('deep-link target waits for a live account-qualified task', () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account-a', + provider: const Value('google'), + authState: const Value('signed_in'), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: 'account-a', + id: 'inbox', + title: 'Inbox', + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + final repository = ScheduleRepository(database); + final targetFuture = repository + .watchTaskTarget( + accountId: 'account-a', + taskListId: 'inbox', + taskId: 'late-task', + ) + .where((target) => target != null) + .cast() + .first; + + await database.tasksDao.upsertTask( + TasksCompanion.insert( + accountId: 'account-a', + taskListId: 'inbox', + id: 'late-task', + title: 'Synced later', + status: const Value('needsAction'), + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + + expect( + await targetFuture, + const ScheduleTaskTarget( + accountId: 'account-a', + taskListId: 'inbox', + taskId: 'late-task', + ), + ); + await (database.update(database.tasks)..where( + (row) => + row.accountId.equals('account-a') & + row.taskListId.equals('inbox') & + row.id.equals('late-task'), + )) + .write(const TasksCompanion(hidden: Value(true))); + expect( + await repository.findTaskTarget( + accountId: 'account-a', + taskListId: 'inbox', + taskId: 'late-task', + ), + null, + ); + }); + test('repository hides unavailable tasks', () async { final database = AppDatabase(NativeDatabase.memory()); addTearDown(database.close); diff --git a/test/features/schedule/schedule_source_visibility_test.dart b/test/features/schedule/schedule_source_visibility_test.dart new file mode 100644 index 0000000..d61bdf8 --- /dev/null +++ b/test/features/schedule/schedule_source_visibility_test.dart @@ -0,0 +1,45 @@ +import 'package:busymax/src/app/app_settings.dart'; +import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; +import 'package:busymax/src/schedule/schedule_filters.dart'; +import 'package:busymax/src/schedule/schedule_source_visibility.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('equal task-list IDs retain per-account schedule visibility', () { + const accountAInbox = ScheduleTaskListKey( + accountId: 'account-a', + taskListId: 'inbox', + ); + const accountBInbox = ScheduleTaskListKey( + accountId: 'account-b', + taskListId: 'inbox', + ); + final visibility = ScheduleSourceVisibility.fromSources( + calendarSources: const [], + taskLists: const [ + TaskListEntity( + accountId: 'account-a', + id: 'inbox', + title: 'Inbox A', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + TaskListEntity( + accountId: 'account-b', + id: 'inbox', + title: 'Inbox B', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + ], + settings: AppSettings.defaults().copyWith( + taskListScheduleVisibility: const {'account-a::inbox': false}, + ), + ); + + expect(visibility.visibleTaskListKeys, isNot(contains(accountAInbox))); + expect(visibility.visibleTaskListKeys, contains(accountBInbox)); + }); +} diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index be79b2e..b3ef0d4 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -1,26 +1,69 @@ -import 'dart:io'; +import 'dart:async'; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:yaru/yaru.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/auth/data/auth_repository.dart'; import 'package:busymax/src/features/settings/presentation/settings_screen.dart'; import 'package:busymax/src/features/sync/sync_auth_error.dart'; +import 'package:busymax/src/platform/gtk_font_service.dart'; +import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; -import 'package:busymax/src/features/tasks/presentation/tasks_selection_state.dart'; +import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:ubuntu_localizations/ubuntu_localizations.dart'; import '../../../test_localized_app.dart'; +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); + void main() { - testWidgets('Settings sign out passes selected account id', (tester) async { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); + }); + + testWidgets('Settings fallback header uses the semantic title style', ( + tester, + ) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + useFlutterHeader: true, + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container); + + final emphasizedAccountTitles = tester + .widgetList(find.text('Accounts')) + .where((text) => text.style?.fontWeight == FontWeight.bold); + expect(emphasizedAccountTitles, hasLength(1)); + }); + + testWidgets('Settings removes the selected Microsoft account', ( + tester, + ) async { final auth = _FakeAuthRepository(); final container = _container( selectedAccountId: 'microsoft:m', @@ -31,16 +74,18 @@ void main() { await _pumpSettings(tester, container); - await tester.tap(find.text('Sign out this account').first); + await _openAccountRemovalDialog(tester); + expect(find.byKey(const Key('revoke-google-authorization')), findsNothing); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); await tester.pumpAndSettle(); - expect(auth.signOutAccountIds, ['microsoft:m']); + expect(auth.removalCalls, [ + const _AccountRemovalCall('microsoft:m', revokeAuthorization: false), + ]); expect(container.read(selectedAccountIdProvider), 'google:g'); - expect(container.read(selectedTaskListIdProvider), isNull); - expect(container.read(selectedTaskIdProvider), isNull); }); - testWidgets('Settings disconnect passes selected account id', (tester) async { + testWidgets('Settings keeps Google revocation opt-in', (tester) async { final auth = _FakeAuthRepository(); final container = _container( selectedAccountId: 'google:g', @@ -51,14 +96,22 @@ void main() { await _pumpSettings(tester, container); - await tester.tap(find.text('Disconnect this account').first); + await _openAccountRemovalDialog(tester); + final revoke = find.byKey(const Key('revoke-google-authorization')); + expect(revoke, findsOneWidget); + expect(tester.widget(revoke).value, isFalse); + await tester.tap(revoke); + await tester.pump(); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); await tester.pumpAndSettle(); - expect(auth.revokedAccountIds, ['google:g']); + expect(auth.removalCalls, [ + const _AccountRemovalCall('google:g', revokeAuthorization: true), + ]); expect(container.read(selectedAccountIdProvider), 'microsoft:m'); }); - testWidgets('Settings delete local data passes selected account id', ( + testWidgets('Settings cancels account removal without mutation', ( tester, ) async { final auth = _FakeAuthRepository(); @@ -71,16 +124,17 @@ void main() { await _pumpSettings(tester, container); - await tester.tap(find.text('Delete local data for this account').first); - await tester.pumpAndSettle(); - await tester.tap(find.text('Delete').last); + await _openAccountRemovalDialog(tester); + await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); - expect(auth.deletedAccountIds, ['google:g']); - expect(container.read(selectedAccountIdProvider), 'microsoft:m'); + expect(auth.removalCalls, isEmpty); + expect(container.read(selectedAccountIdProvider), 'google:g'); }); - testWidgets('Settings labels are provider-neutral', (tester) async { + testWidgets('Settings exposes one clear account-removal action', ( + tester, + ) async { final container = _container( selectedAccountId: 'google:g', authRepository: _FakeAuthRepository(), @@ -90,10 +144,104 @@ void main() { await _pumpSettings(tester, container); - expect(find.text('Sign out this account'), findsOneWidget); - expect(find.text('Disconnect this account'), findsOneWidget); - expect(find.text('Delete local data for this account'), findsOneWidget); - expect(find.text('Revoke Google authorization'), findsNothing); + expect(find.text('Remove account…'), findsOneWidget); + expect( + find.text( + 'Stop syncing and remove this account’s data from this device.', + ), + findsOneWidget, + ); + expect(find.text('Sign out this account'), findsNothing); + expect(find.text('Disconnect this account'), findsNothing); + expect(find.text('Delete local data for this account'), findsNothing); + }); + + testWidgets('Settings reports a local account-removal failure in place', ( + tester, + ) async { + final auth = _FakeAuthRepository() + ..removeError = StateError('local cleanup failed'); + final container = _container( + selectedAccountId: 'google:g', + authRepository: auth, + accounts: const [_googleAccount, _microsoftAccount], + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container); + await _openAccountRemovalDialog(tester); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); + await tester.pumpAndSettle(); + + expect(container.read(selectedAccountIdProvider), 'google:g'); + expect( + find.text('Could not finish removing the account. Try again.'), + findsOneWidget, + ); + }); + + testWidgets('Settings prevents duplicate account-removal submission', ( + tester, + ) async { + final removal = Completer(); + final auth = _FakeAuthRepository()..removalCompleter = removal; + final container = _container( + selectedAccountId: 'google:g', + authRepository: auth, + accounts: const [_googleAccount, _microsoftAccount], + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container); + await _openAccountRemovalDialog(tester); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); + await tester.pump(); + + expect(auth.removalCalls, hasLength(1)); + expect(find.text('Removing account…'), findsOneWidget); + await tester.tap(find.text('Removing account…'), warnIfMissed: false); + await tester.pump(); + expect(auth.removalCalls, hasLength(1)); + + removal.complete( + const AccountRemovalResult( + authorizationRevocationStatus: + AccountAuthorizationRevocationStatus.notRequested, + ), + ); + await tester.pumpAndSettle(); + expect(container.read(selectedAccountIdProvider), 'microsoft:m'); + }); + + testWidgets('Settings reports partial Google revocation failure', ( + tester, + ) async { + final auth = _FakeAuthRepository() + ..removalResult = const AccountRemovalResult( + authorizationRevocationStatus: + AccountAuthorizationRevocationStatus.failed, + ); + final container = _container( + selectedAccountId: 'google:g', + authRepository: auth, + accounts: const [_googleAccount, _microsoftAccount], + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container); + await _openAccountRemovalDialog(tester); + await tester.tap(find.byKey(const Key('revoke-google-authorization'))); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); + await tester.pumpAndSettle(); + + expect( + find.text( + 'The account was removed from this device, but BusyMax could not ' + 'revoke Google access. You can revoke it from your Google Account.', + ), + findsOneWidget, + ); + expect(container.read(selectedAccountIdProvider), 'microsoft:m'); }); testWidgets('Settings shows reconnect-required account state', ( @@ -111,7 +259,10 @@ void main() { expect(find.text(accountReconnectRequiredActionLabel), findsOneWidget); expect(find.text(accountReconnectRequiredSyncMessage), findsOneWidget); expect(find.text('New list'), findsNothing); - expect(find.text('Sign out this account'), findsNothing); + expect(find.text('Remove account…'), findsOneWidget); + + await _openAccountRemovalDialog(tester); + expect(find.byKey(const Key('revoke-google-authorization')), findsNothing); }); testWidgets('Settings exposes add account actions', (tester) async { @@ -139,7 +290,7 @@ void main() { ); addTearDown(container.dispose); - await _pumpSettings(tester, container); + await _pumpSettings(tester, container, logicalSize: const Size(1000, 700)); expect(find.text('Add Google account'), findsOneWidget); expect(find.text('Theme'), findsNothing); @@ -164,20 +315,113 @@ void main() { expect(find.text('Add Google account'), findsNothing); }); - test('Settings sidebar items have native-feeling side padding', () { - final source = File( - 'lib/src/features/settings/presentation/settings_screen.dart', - ).readAsStringSync(); + testWidgets('Settings workspace uses the native window surface', ( + tester, + ) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + buildConfig: _configuredBuildConfig, + activeAccountIdOverride: null, + ); + addTearDown(container.dispose); - expect(source, contains('const SizedBox(width: BusyMaxSpacing.md)')); - expect( - source, - isNot( - contains( - 'const SizedBox(width: BusyMaxSpacing.xs),\n Icon(', + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFF0F1F2), + view: Color(0xFFFFFFFF), + sidebar: Color(0xFFE5E6E7), + ); + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: YaruColors.orange, + gtkThemeColors: gtkColors, + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: localizedTestApp( + child: Theme(data: theme, child: const SettingsScreen()), ), ), ); + await tester.pumpAndSettle(); + + final scaffold = tester.widget(find.byType(Scaffold)); + expect(scaffold.backgroundColor, gtkColors.window); + expect(scaffold.backgroundColor, isNot(gtkColors.view)); + }); + + testWidgets('Settings uses Yaru master-detail rows with selected semantics', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container, logicalSize: const Size(1000, 700)); + + expect(find.byType(BusyMaxSidebarNavigation), findsOneWidget); + expect( + find.byType(YaruMasterTile), + findsNWidgets(SettingsPage.values.length), + ); + expect(find.byType(YaruNavigationRail), findsNothing); + expect(find.byType(BusyMaxSidebarSurface), findsOneWidget); + final accountsTile = tester.widget( + find.byKey(const ValueKey('settings-navigation-accounts')), + ); + final scheduleTile = tester.widget( + find.byKey(const ValueKey('settings-navigation-schedule')), + ); + expect(accountsTile.selected, isTrue); + expect(scheduleTile.selected, isFalse); + expect( + tester + .getSemantics( + find.byKey(const ValueKey('settings-navigation-accounts')), + ) + .flagsCollection + .isSelected, + ui.Tristate.isTrue, + ); + expect( + tester + .getSemantics( + find.byKey(const ValueKey('settings-navigation-schedule')), + ) + .flagsCollection + .isSelected, + ui.Tristate.isFalse, + ); + + await tester.tap(find.text('Schedule')); + await tester.pumpAndSettle(); + + expect( + tester + .widget( + find.byKey(const ValueKey('settings-navigation-schedule')), + ) + .selected, + isTrue, + ); + expect( + tester + .getSemantics( + find.byKey(const ValueKey('settings-navigation-schedule')), + ) + .flagsCollection + .isSelected, + ui.Tristate.isTrue, + ); + semantics.dispose(); }); testWidgets('Diagnostics stays inside Settings shell', (tester) async { @@ -190,7 +434,7 @@ void main() { ); addTearDown(container.dispose); - await _pumpSettings(tester, container); + await _pumpSettings(tester, container, logicalSize: const Size(1000, 700)); await tester.tap(find.text('Diagnostics')); await tester.pumpAndSettle(); @@ -204,6 +448,87 @@ void main() { await tester.pump(); }); + testWidgets('Settings uses single-pane navigation at narrow widths', ( + tester, + ) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + buildConfig: _configuredBuildConfig, + activeAccountIdOverride: null, + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container, logicalSize: const Size(640, 700)); + + expect(find.text('Accounts'), findsWidgets); + expect(find.text('Schedule'), findsNothing); + + await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); + await tester.pumpAndSettle(); + await tester.tap(_settingsMenuItemWithLabel('Schedule')); + await tester.pumpAndSettle(); + + expect(find.text('Day starts at'), findsOneWidget); + expect(find.text('Day ends at'), findsOneWidget); + }); + + testWidgets('Settings narrow layout supports large text', (tester) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + ); + addTearDown(container.dispose); + + await _pumpSettings( + tester, + container, + logicalSize: const Size(640, 700), + textScaler: const TextScaler.linear(2), + ); + + expect( + find.byKey(const ValueKey('settings-page-selector')), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('Quiet-hour times are exposed and follow the master switch', ( + tester, + ) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container, logicalSize: const Size(1000, 800)); + await tester.tap(find.text('Notifications')); + await tester.pumpAndSettle(); + + var timeRows = tester.widgetList( + find.byType(DesktopTimeValueRow), + ); + expect( + timeRows.map((row) => row.label), + containsAll(['Quiet hours start', 'Quiet hours end']), + ); + expect(timeRows.every((row) => !row.enabled), isTrue); + + await tester.ensureVisible(find.text('Quiet hours')); + await tester.tap(find.text('Quiet hours')); + await tester.pumpAndSettle(); + + timeRows = tester.widgetList( + find.byType(DesktopTimeValueRow), + ); + expect(timeRows.every((row) => row.enabled), isTrue); + }); + test('Schedule display hours persist and keep a valid range', () async { final store = _MemorySettingsStore(); final first = AppSettingsController(store); @@ -249,8 +574,14 @@ void main() { await tester.ensureVisible(newListButtons.at(1)); await tester.tap(newListButtons.at(1)); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Client work'); - await tester.tap(find.text('Create')); + + final promptField = find.descendant( + of: find.byType(BusyMaxPromptDialog), + matching: find.byType(TextField), + ); + expect(promptField, findsOneWidget); + await tester.enterText(promptField, 'Client work'); + await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); expect(googleLists.createdTitles, isEmpty); @@ -299,6 +630,38 @@ void main() { expect(find.text('schedule route'), findsOneWidget); }); + testWidgets('Settings back returns to the route that opened it', ( + tester, + ) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + ); + addTearDown(container.dispose); + + final router = await _pumpRoutedSettings( + tester, + container, + initialLocation: '/tasks', + ); + unawaited(router.push('/settings')); + await tester.pumpAndSettle(); + expect(find.byType(SettingsScreen), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); + await tester.pumpAndSettle(); + await tester.tap(_settingsMenuItemWithLabel('Notifications')); + await tester.pumpAndSettle(); + expect(router.state.uri.queryParameters['page'], 'notifications'); + + await container + .read(linuxHeaderBarServiceProvider) + .handleNativeMethodCall(const MethodCall('back')); + await tester.pumpAndSettle(); + + expect(find.text('tasks route'), findsOneWidget); + }); + testWidgets('Removing last account routes to sign in cleanly', ( tester, ) async { @@ -312,15 +675,31 @@ void main() { await _pumpRoutedSettings(tester, container); - await tester.tap(find.text('Sign out this account').first); + await _openAccountRemovalDialog(tester); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); await tester.pumpAndSettle(); - expect(auth.signOutAccountIds, ['google:g']); + expect(auth.removalCalls, [ + const _AccountRemovalCall('google:g', revokeAuthorization: false), + ]); expect(container.read(selectedAccountIdProvider), isNull); expect(find.text('sign in route'), findsOneWidget); }); } +Finder _settingsMenuItemWithLabel(String label) { + return find.ancestor( + of: find.text(label).last, + matching: find.byWidgetPredicate((widget) => widget is PopupMenuItem), + ); +} + +Future _openAccountRemovalDialog(WidgetTester tester) async { + await tester.tap(find.text('Remove account…').first); + await tester.pumpAndSettle(); + expect(find.textContaining('from BusyMax?'), findsOneWidget); +} + ProviderContainer _container({ required String selectedAccountId, required _FakeAuthRepository authRepository, @@ -328,6 +707,7 @@ ProviderContainer _container({ BuildConfig buildConfig = _emptyBuildConfig, Map? taskListRepositories, String? activeAccountIdOverride = _useDefaultActiveAccountId, + bool useFlutterHeader = false, }) { return ProviderContainer( overrides: [ @@ -345,6 +725,12 @@ ProviderContainer _container({ localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), syncEngineProvider.overrideWithValue(null), buildConfigProvider.overrideWithValue(buildConfig), + if (useFlutterHeader) + linuxHeaderBarServiceProvider.overrideWith((ref) { + final service = LinuxHeaderBarService(isLinux: false); + ref.onDispose(service.dispose); + return service; + }), taskListsRepositoryForAccountProvider.overrideWith((ref, accountId) { return taskListRepositories?[accountId] ?? _FakeTaskListsRepository(); }), @@ -356,23 +742,40 @@ const _useDefaultActiveAccountId = '__busymax_default_active_account__'; Future _pumpSettings( WidgetTester tester, - ProviderContainer container, -) async { + ProviderContainer container, { + Size? logicalSize, + TextScaler? textScaler, +}) async { + if (logicalSize != null) { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = logicalSize; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + } + final settings = textScaler == null + ? const SettingsScreen() + : Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: const SettingsScreen(), + ), + ); await tester.pumpWidget( UncontrolledProviderScope( container: container, - child: localizedTestApp(child: const SettingsScreen()), + child: localizedTestApp(child: settings), ), ); await tester.pumpAndSettle(); } -Future _pumpRoutedSettings( +Future _pumpRoutedSettings( WidgetTester tester, - ProviderContainer container, -) async { + ProviderContainer container, { + String initialLocation = '/settings', +}) async { final router = GoRouter( - initialLocation: '/settings', + initialLocation: initialLocation, routes: [ GoRoute(path: '/settings', builder: (_, _) => const SettingsScreen()), GoRoute( @@ -400,30 +803,56 @@ Future _pumpRoutedSettings( ), ); await tester.pumpAndSettle(); + return router; } class _FakeAuthRepository implements AuthRepository { - final signOutAccountIds = []; - final revokedAccountIds = []; - final deletedAccountIds = []; + final removalCalls = <_AccountRemovalCall>[]; + AccountRemovalResult removalResult = const AccountRemovalResult( + authorizationRevocationStatus: + AccountAuthorizationRevocationStatus.notRequested, + ); + Completer? removalCompleter; + Object? removeError; @override - Future signOut({String? accountId}) async { - signOutAccountIds.add(accountId ?? ''); + Future removeAccount({ + required String accountId, + bool revokeAuthorization = false, + }) async { + removalCalls.add( + _AccountRemovalCall(accountId, revokeAuthorization: revokeAuthorization), + ); + final error = removeError; + if (error != null) { + throw error; + } + final completer = removalCompleter; + return completer == null ? removalResult : completer.future; } @override - Future revokeAndSignOut({String? accountId}) async { - revokedAccountIds.add(accountId ?? ''); - } + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _AccountRemovalCall { + const _AccountRemovalCall( + this.accountId, { + required this.revokeAuthorization, + }); + + final String accountId; + final bool revokeAuthorization; @override - Future deleteLocalAccountData({String? accountId}) async { - deletedAccountIds.add(accountId ?? ''); + bool operator ==(Object other) { + return other is _AccountRemovalCall && + other.accountId == accountId && + other.revokeAuthorization == revokeAuthorization; } @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); + int get hashCode => Object.hash(accountId, revokeAuthorization); } class _FakeAccountsRepository implements AccountsRepository { diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart new file mode 100644 index 0000000..4302086 --- /dev/null +++ b/test/features/tasks/presentation/desktop_date_time_fields_test.dart @@ -0,0 +1,1210 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_surface_colors.dart'; +import 'package:busymax/src/core/time/time_zone_catalog.dart'; +import 'package:busymax/src/core/time/linux_gweather_location_source.dart'; +import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; +import 'package:busymax/src/features/schedule/presentation/mini_calendar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../test_localized_app.dart'; + +void main() { + test('provider time parsing accepts only exact HH:mm values', () { + expect(parseTimeOfDay('00:00'), const TimeOfDay(hour: 0, minute: 0)); + expect(parseTimeOfDay('09:30'), const TimeOfDay(hour: 9, minute: 30)); + expect(parseTimeOfDay('23:59'), const TimeOfDay(hour: 23, minute: 59)); + + expect(parseTimeOfDay('9:30'), isNull); + expect(parseTimeOfDay('09:3'), isNull); + expect(parseTimeOfDay('09x30'), isNull); + expect(parseTimeOfDay('09:30 UTC'), isNull); + }); + + testWidgets('localized time parsing is strict and encodes provider time', ( + tester, + ) async { + late BuildContext parserContext; + await tester.pumpWidget( + localizedTestApp( + locale: const Locale('en'), + alwaysUse24HourFormat: false, + child: Builder( + builder: (context) { + parserContext = context; + return const SizedBox(); + }, + ), + ), + ); + + expect( + encodeTimeOfDay(parseDesktopTimeInput(parserContext, '01:30 PM')!), + '13:30', + ); + expect(parseDesktopTimeInput(parserContext, '09x30'), isNull); + expect( + parseDesktopTimeInput(parserContext, '09:30 trailing garbage'), + isNull, + ); + }); + + testWidgets('calendar values render populated contextual fields before focus', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Column( + children: [ + DesktopDateValueRow( + label: 'Start date', + date: '2026-07-22', + onChanged: _ignoreString, + ), + DesktopTimeValueRow( + label: 'Start time', + time: '09:30', + timeZone: 'America/Vancouver', + onChanged: _ignoreNullableString, + ), + ], + ), + ), + ), + ); + + final dateTextField = tester.widget( + find + .descendant( + of: find.byType(DesktopDateValueRow), + matching: find.byType(TextField), + ) + .first, + ); + final timeTextField = tester.widget( + find + .descendant( + of: find.byType(DesktopTimeValueRow), + matching: find.byType(TextField), + ) + .first, + ); + final dateContext = tester.element(find.byType(DesktopDateValueRow)); + final timeContext = tester.element(find.byType(DesktopTimeValueRow)); + + expect( + dateTextField.controller?.text, + formatDesktopDate(dateContext, '2026-07-22'), + ); + expect( + timeTextField.controller?.text, + '${formatMaterialTime(timeContext, const TimeOfDay(hour: 9, minute: 30))} ' + '(${BusyMaxTimeZoneCatalog.location('America/Vancouver').code})', + ); + expect(dateTextField.decoration?.labelText, 'Start date'); + expect(timeTextField.decoration?.labelText, 'Start time'); + expect( + dateTextField.decoration?.floatingLabelBehavior, + FloatingLabelBehavior.auto, + ); + expect( + timeTextField.decoration?.floatingLabelBehavior, + FloatingLabelBehavior.auto, + ); + expect(find.text('Enter date'), findsNothing); + expect(find.text('Enter time'), findsNothing); + expect(find.byIcon(YaruIcons.calendar), findsOneWidget); + expect(find.byIcon(YaruIcons.clock), findsOneWidget); + expect(find.byIcon(Icons.edit_outlined), findsNothing); + }); + + testWidgets('disabled date and time entries cannot receive focus', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: const Scaffold( + body: Column( + children: [ + DesktopDateField( + label: 'Due date', + date: '2026-07-22', + enabled: false, + onChanged: _ignoreString, + ), + DesktopTimeField( + label: 'Due time', + time: '09:30', + enabled: false, + onChanged: _ignoreNullableString, + ), + ], + ), + ), + ), + ); + + final dateEntries = tester.widgetList( + find.descendant( + of: find.byType(DesktopDateField), + matching: find.byType(EditableText), + ), + ); + final timeEntries = tester.widgetList( + find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(EditableText), + ), + ); + + expect(dateEntries, isNotEmpty); + expect(timeEntries, isNotEmpty); + expect( + [ + ...dateEntries, + ...timeEntries, + ].every((entry) => !entry.focusNode.canRequestFocus), + isTrue, + ); + + await tester.tap(find.byType(EditableText).first, warnIfMissed: false); + await tester.pump(); + expect( + [ + ...dateEntries, + ...timeEntries, + ].every((entry) => !entry.focusNode.hasFocus), + isTrue, + ); + }); + + testWidgets('disposing a date field safely ignores an in-flight picker', ( + tester, + ) async { + const channel = MethodChannel(nativeDateTimePickerChannelName); + final response = Completer(); + final changes = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) => response.future); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopDateField( + label: 'Due date', + date: '2026-07-22', + onChanged: changes.add, + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.calendar)); + await tester.pump(); + await tester.pumpWidget(const SizedBox()); + + response.complete('2026-07-23'); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(changes, isEmpty); + }); + + testWidgets('time row uses native time picker channel', (tester) async { + const channel = MethodChannel(nativeDateTimePickerChannelName); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + expect(call.method, 'pickTime'); + expect(call.arguments, containsPair('initialTime', '09:30')); + return Future.value('10:45'); + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + + String? pickedTime; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopTimeValueRow( + label: 'Due time', + time: '09:30', + useNativePicker: true, + onChanged: (value) => pickedTime = value, + onValidityChanged: (_) {}, + ), + ), + ), + ); + + expect(find.byType(YaruIconButton), findsOneWidget); + await tester.tap(find.byType(YaruIconButton).first); + await tester.pumpAndSettle(); + expect(calls, hasLength(1)); + expect(pickedTime, '10:45'); + expect(calls, hasLength(1)); + expect(find.byType(BusyMaxDialogShell), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('fallback time picker opens as tooltip-style controls', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopTimeValueRow( + label: 'Due time', + time: '09:30', + onChanged: (_) {}, + onValidityChanged: (_) {}, + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.clock)); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + expect(find.byType(BusyMaxDialogShell), findsNothing); + expect(find.byIcon(Icons.add), findsNWidgets(2)); + expect(find.byIcon(Icons.remove), findsNWidgets(2)); + expect(find.text(':'), findsOneWidget); + expect(find.byIcon(Icons.public), findsOneWidget); + + final picker = find.byType(BusyMaxContentPopoverSurface); + final componentFields = find.descendant( + of: picker, + matching: find.byType(TextField), + ); + final editableFields = find.descendant( + of: picker, + matching: find.byType(EditableText), + ); + expect(componentFields, findsNothing); + expect(editableFields, findsNWidgets(2)); + expect( + tester + .widgetList(editableFields) + .map((field) => field.controller.text), + ['09', '30'], + ); + for (final field in tester.widgetList(editableFields)) { + expect(field.textAlign, TextAlign.center); + expect(field.style.fontWeight, FontWeight.normal); + } + final componentCenterOffsets = []; + for (final label in ['Hour', 'Minute']) { + final inputSection = find.byKey(ValueKey(('time-input-section', label))); + final inputCell = find.byKey(ValueKey(('time-input', label))); + final editableText = find.descendant( + of: inputCell, + matching: find.byType(EditableText), + ); + final sectionDecoration = + tester.widget(inputSection).decoration! as BoxDecoration; + expect(sectionDecoration.border, isNull); + expect( + find.descendant(of: inputCell, matching: find.byType(InputDecorator)), + findsNothing, + ); + final editable = tester.widget(editableText); + expect(editable.textAlign, TextAlign.center); + expect(editable.strutStyle.forceStrutHeight, isTrue); + final renderEditableFinder = find.descendant( + of: editableText, + matching: find.byElementPredicate( + (element) => element.renderObject is RenderEditable, + ), + ); + expect(renderEditableFinder, findsOneWidget); + final renderEditable = tester.renderObject( + renderEditableFinder, + ); + final editableOrigin = renderEditable.localToGlobal(Offset.zero); + final firstCaret = renderEditable.getLocalRectForCaret( + const TextPosition(offset: 0), + ); + final lastCaret = renderEditable.getLocalRectForCaret( + TextPosition(offset: editable.controller.text.length), + ); + final textRunCenterDx = + editableOrigin.dx + (firstCaret.left + lastCaret.left) / 2; + final dividers = tester.widgetList( + find.descendant(of: inputSection, matching: find.byType(Divider)), + ); + expect(dividers, hasLength(2)); + expect( + dividers.map((divider) => divider.color), + everyElement( + BusyMaxSurfaceColors.of(tester.element(inputSection)).divider, + ), + ); + final inputCellSize = tester.getSize(inputCell); + expect(inputCellSize.width, BusyMaxSizes.popoverActionButton); + expect(inputCellSize.height, BusyMaxSizes.popoverActionButton); + final componentCenterOffset = + textRunCenterDx - tester.getRect(inputCell).center.dx; + componentCenterOffsets.add(componentCenterOffset); + expect( + componentCenterOffset.abs(), + lessThanOrEqualTo(1 / tester.view.devicePixelRatio + 1e-9), + ); + expect( + (tester.getRect(inputCell).center.dy - + tester.getRect(editableText).center.dy) + .abs(), + lessThan(1), + ); + } + expect( + componentCenterOffsets.first, + closeTo(componentCenterOffsets.last, 0.01), + ); + + final timezoneButton = find.ancestor( + of: find.byIcon(Icons.public), + matching: find.byType(FilledButton), + ); + expect(timezoneButton, findsOneWidget); + expect(tester.widget(timezoneButton).style, isNull); + expect(tester.getSize(timezoneButton).width, greaterThan(100)); + expect(tester.takeException(), isNull); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + }); + + testWidgets('time picker searches and selects real timezone locations', ( + tester, + ) async { + BusyMaxTimeZoneCatalog.setSystemLocationsForTesting(const [ + BusyMaxSystemTimeZoneLocation( + name: 'Seattle', + englishName: 'Seattle', + countryCode: 'US', + timeZoneId: 'America/Los_Angeles', + ), + ]); + addTearDown( + () => BusyMaxTimeZoneCatalog.setSystemLocationsForTesting(null), + ); + await tester.runAsync(BusyMaxTimeZoneCatalog.prepareLocationSearch); + String? selectedTimeZone; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopTimeValueRow( + label: 'Due time', + time: '09:30', + timeZone: 'Etc/UTC', + onChanged: (_) {}, + onTimeZoneChanged: (value) => selectedTimeZone = value, + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.clock)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.public)); + await tester.pumpAndSettle(); + + expect(find.text('Select Timezone'), findsOneWidget); + final nativeSearchField = tester.widget( + find.byType(YaruSearchField), + ); + expect( + nativeSearchField.contentPadding, + const EdgeInsets.only( + left: BusyMaxSpacing.md, + right: kYaruTitleBarItemHeight, + ), + ); + final title = tester.widget(find.text('Select Timezone')); + expect(title.textAlign, isNull); + expect(title.style?.fontWeight, FontWeight.w600); + expect(find.byType(BusyMaxDialogTitleBar), findsOneWidget); + expect( + tester + .widget(find.byType(BusyMaxDialogTitleBar)) + .centerTitle, + isTrue, + ); + + final searchField = find.widgetWithText(TextField, 'Search locations'); + final content = find.byKey(const ValueKey('timezone-dialog-content')); + expect(searchField, findsOneWidget); + expect(content, findsOneWidget); + final initialContentSize = tester.getSize(content); + final initialSearchSize = tester.getSize(find.byType(BusyMaxSearchField)); + expect(initialSearchSize.height, kYaruTitleBarItemHeight); + final dialog = find.byType(Dialog); + expect(dialog, findsOneWidget); + final initialDialogSize = tester.getSize(dialog); + + await _enterTimeZoneSearch(tester, searchField, 'a'); + + expect(tester.getSize(content), initialContentSize); + expect(tester.getSize(dialog), initialDialogSize); + expect(tester.getSize(find.byType(BusyMaxSearchField)), initialSearchSize); + final resultsList = find.byKey(const ValueKey('timezone-results-list')); + expect(resultsList, findsOneWidget); + expect( + tester.widget(resultsList).controller!.position.maxScrollExtent, + greaterThan(0), + ); + + await _enterTimeZoneSearch(tester, searchField, 'Vancouver'); + + expect(tester.getSize(content), initialContentSize); + expect(tester.getSize(dialog), initialDialogSize); + expect(find.text('America'), findsOneWidget); + expect(find.textContaining('Vancouver ('), findsWidgets); + expect(find.text('America/Vancouver'), findsOneWidget); + + await _enterTimeZoneSearch(tester, searchField, 'Montreal'); + + expect(find.textContaining('Montreal ('), findsOneWidget); + expect(find.text('America/Montreal'), findsOneWidget); + + await _enterTimeZoneSearch(tester, searchField, 'Seattle'); + + expect(find.textContaining('Seattle ('), findsOneWidget); + expect(find.text('America/Los_Angeles - US'), findsOneWidget); + + await _enterTimeZoneSearch(tester, searchField, 'Vancouver'); + + await tester.tap( + find.ancestor( + of: find.text('America/Vancouver'), + matching: find.byType(BusyMaxActionRow), + ), + ); + await tester.pumpAndSettle(); + + expect(selectedTimeZone, 'America/Vancouver'); + expect(find.text('Select Timezone'), findsNothing); + expect(find.textContaining('America/Vancouver ('), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('fallback time picker closes when clicking outside', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Center( + child: DesktopTimeValueRow( + label: 'Due time', + time: '09:30', + onChanged: (_) {}, + onValidityChanged: (_) {}, + ), + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.clock)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + + final bodySize = tester.getSize(find.byType(Scaffold)); + await tester.tapAt(Offset(bodySize.width - 1, bodySize.height - 1)); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxContentPopoverSurface), findsNothing); + }); + + testWidgets('fallback date picker opens as an anchored popover', ( + tester, + ) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const Scaffold(body: SizedBox()); + }, + ), + ), + ); + + final result = showBusyMaxDateValueDialog( + hostContext, + label: 'Due date', + initialDate: '2026-07-22', + ); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + expect(find.byType(ModalBarrier), findsAtLeastNWidgets(1)); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(await result, isNull); + }); + + testWidgets('fallback date picker is positioned from the trigger', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Center( + child: DesktopDateValueRow( + label: 'Due date', + date: '2026-07-22', + onChanged: _ignoreString, + ), + ), + ), + ), + ); + + final trigger = find.byType(YaruIconButton).first; + final triggerRect = tester.getRect(trigger); + await tester.tap(trigger); + await tester.pumpAndSettle(); + + final popoverRect = tester.getRect( + find.byType(BusyMaxContentPopoverSurface).first, + ); + expect(popoverRect.topLeft.dx, greaterThan(0)); + expect(popoverRect.topLeft.dy, greaterThan(0)); + expect( + (triggerRect.center.dx - popoverRect.center.dx).abs(), + lessThan(popoverRect.width / 2 + 10), + ); + expect( + (triggerRect.center.dy - popoverRect.center.dy).abs(), + greaterThan(0), + ); + expect( + find.descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byType(SingleChildScrollView), + ), + findsNothing, + ); + expect( + find.descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byWidgetPredicate((widget) => widget is RawScrollbar), + ), + findsNothing, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + }); + + testWidgets('fallback date picker closes when tapping outside', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Center( + child: DesktopDateValueRow( + label: 'Due date', + date: '2026-07-22', + onChanged: _ignoreString, + ), + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.calendar)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + + final bodySize = tester.getSize(find.byType(Scaffold)); + await tester.tapAt(Offset(bodySize.width - 1, bodySize.height - 1)); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxContentPopoverSurface), findsNothing); + }); + + testWidgets('fallback date picker uses mini calendar and labels', ( + tester, + ) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const Scaffold(body: SizedBox()); + }, + ), + ), + ); + + final result = showBusyMaxDateValueDialog( + hostContext, + label: 'Due date', + initialDate: '2026-07-22', + ); + await tester.pumpAndSettle(); + + expect(find.byType(MiniCalendarGrid), findsOneWidget); + expect(find.text('July'), findsOneWidget); + expect(find.text('2026'), findsOneWidget); + expect(find.byTooltip('Wednesday, July 22, 2026'), findsOneWidget); + expect(find.byType(TextField), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(await result, isNull); + }); + + testWidgets('fallback date picker month and year headers are display-only', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Center( + child: DesktopDateValueRow( + label: 'Due date', + date: '2026-07-22', + onChanged: _ignoreString, + ), + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.calendar)); + await tester.pumpAndSettle(); + expect(find.byType(MiniCalendarGrid), findsOneWidget); + expect(find.text('July'), findsOneWidget); + expect(find.text('2026'), findsOneWidget); + final monthControlWidth = + tester.getRect(find.byTooltip('Next month')).right - + tester.getRect(find.byTooltip('Previous month')).left; + final yearControlWidth = + tester.getRect(find.byTooltip('Next year')).right - + tester.getRect(find.byTooltip('Previous year')).left; + expect(yearControlWidth, lessThan(monthControlWidth)); + final monthLabelSpace = find.ancestor( + of: find.text('July'), + matching: find.byType(FittedBox), + ); + final yearLabelSpace = find.ancestor( + of: find.text('2026'), + matching: find.byType(FittedBox), + ); + expect( + tester.getRect(monthLabelSpace).width / + tester.getRect(yearLabelSpace).width, + closeTo( + BusyMaxCalendarHeaderLayout.monthControlFlex / + BusyMaxCalendarHeaderLayout.yearControlFlex, + 0.01, + ), + ); + expect( + tester.getRect(find.byTooltip('Previous year')).size, + tester.getRect(find.byTooltip('Previous month')).size, + ); + expect( + tester.getRect(find.byTooltip('Next year')).size, + tester.getRect(find.byTooltip('Next month')).size, + ); + expect( + tester.getRect(find.text('2026')).height, + closeTo(tester.getRect(find.text('July')).height, 0.01), + ); + expect( + find.descendant( + of: find.byType(MiniCalendarGrid), + matching: find.byType(TextButton), + ), + findsNothing, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('fallback date picker stays open while paging months', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopDateValueRow( + label: 'Due date', + date: '2026-07-22', + onChanged: _ignoreString, + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.calendar)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + + await tester.tap(find.byIcon(YaruIcons.pan_end).at(0)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + final pagedGrid = tester.widget( + find.byType(MiniCalendarGrid), + ); + expect(pagedGrid.displayedMonth, DateTime(2026, 8)); + expect(pagedGrid.selectedDate, DateTime(2026, 7, 22)); + + await tester.tap(find.byIcon(YaruIcons.pan_start).at(0)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + }); + + testWidgets('fallback date picker safely pages while a tooltip is open', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopDateValueRow( + label: 'Due date', + date: '2026-07-22', + onChanged: _ignoreString, + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.calendar)); + await tester.pumpAndSettle(); + + final previousMonth = find.byTooltip('Previous month'); + final nextYear = find.byTooltip('Next year'); + expect(find.byType(RawTooltip), findsWidgets); + final mouse = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(previousMonth)); + await tester.pump(); + await mouse.down(tester.getCenter(previousMonth)); + await mouse.up(); + await tester.pump(); + await mouse.moveTo(tester.getCenter(nextYear)); + await tester.pump(); + await mouse.down(tester.getCenter(nextYear)); + await mouse.up(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + }); + + testWidgets('fallback date picker stays open while paging years', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopDateValueRow( + label: 'Due date', + date: '2026-07-22', + onChanged: _ignoreString, + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.calendar)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + + await tester.tap(find.byIcon(YaruIcons.pan_start).at(1)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + + await tester.tap(find.byIcon(YaruIcons.pan_end).at(1)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + }); + + testWidgets('fallback date dialog submits a selected date', (tester) async { + String? picked; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) { + return Center( + child: DesktopDateValueRow( + label: 'Due date', + date: '2026-07-22', + onChanged: (date) => picked = date, + ), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.calendar)); + await tester.pumpAndSettle(); + final dayCell = find.text('14'); + expect(dayCell, findsOneWidget); + await tester.tap(dayCell.first); + await tester.pumpAndSettle(); + + expect(picked, '2026-07-14'); + }); + + testWidgets( + 'fallback date dialog safely bounds an unsupported initial date', + (tester) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const Scaffold(body: SizedBox()); + }, + ), + ), + ); + + final result = showBusyMaxDateValueDialog( + hostContext, + label: 'Due date', + initialDate: '2200-01-01', + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Friday, December 31, 2100'), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(await result, isNull); + }, + ); + + testWidgets('invalid time reports validity and uses the native error label', ( + tester, + ) async { + final changes = []; + final validityChanges = []; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopTimeField( + label: 'Due time', + time: '09:30', + onChanged: changes.add, + onValidityChanged: validityChanges.add, + ), + ), + ), + ); + validityChanges.clear(); + + final entryFinder = find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(TextFormField), + ); + final localizations = MaterialLocalizations.of( + tester.element(find.byType(DesktopTimeField)), + ); + + await tester.enterText(entryFinder, '09x30'); + await tester.pump(); + expect(find.text(localizations.invalidTimeLabel), findsOneWidget); + expect(validityChanges, [false]); + expect(changes, isEmpty); + + await tester.enterText(entryFinder, '10:45'); + await tester.pump(); + expect(find.text(localizations.invalidTimeLabel), findsNothing); + expect(validityChanges, [false, true]); + expect(changes, ['10:45']); + }); + + testWidgets( + 'external time update replaces focused text without echoing the value', + (tester) async { + late StateSetter updateHost; + var suppliedTime = '09:30'; + final changes = []; + await tester.pumpWidget( + localizedTestApp( + child: StatefulBuilder( + builder: (context, setState) { + updateHost = setState; + return Scaffold( + body: DesktopTimeField( + label: 'Due time', + time: suppliedTime, + onChanged: changes.add, + ), + ); + }, + ), + ), + ); + + final entryFinder = find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(TextFormField), + ); + await tester.tap(entryFinder); + await tester.pump(); + await tester.enterText(entryFinder, 'stale invalid text'); + await tester.pump(); + changes.clear(); + + updateHost(() { + suppliedTime = '18:45'; + }); + await tester.pump(); + + final entry = tester.widget(entryFinder); + final editableText = tester.widget( + find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(EditableText), + ), + ); + final fieldContext = tester.element(find.byType(DesktopTimeField)); + expect( + entry.controller?.text, + formatMaterialTime(fieldContext, const TimeOfDay(hour: 18, minute: 45)), + ); + expect(editableText.focusNode.hasFocus, isTrue); + expect(changes, isEmpty); + expect( + find.text(MaterialLocalizations.of(fieldContext).invalidTimeLabel), + findsNothing, + ); + editableText.focusNode.unfocus(); + await tester.pump(); + expect(changes, isEmpty); + expect( + tester.widget(entryFinder).controller?.text, + formatMaterialTime(fieldContext, const TimeOfDay(hour: 18, minute: 45)), + ); + }, + ); + + testWidgets( + 'rejected controlled time restores the authoritative value on blur', + (tester) async { + final changes = []; + await tester.pumpWidget( + localizedTestApp( + alwaysUse24HourFormat: true, + child: Scaffold( + body: DesktopTimeField( + label: 'Quiet hours start', + time: '09:30', + onChanged: changes.add, + ), + ), + ), + ); + + final fieldFinder = find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(TextFormField), + ); + final editableText = tester.widget( + find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(EditableText), + ), + ); + + await tester.tap(fieldFinder); + await tester.enterText(fieldFinder, '10:45'); + await tester.pump(); + expect(changes, ['10:45']); + expect( + tester.widget(fieldFinder).controller?.text, + '10:45', + ); + + editableText.focusNode.unfocus(); + await tester.pump(); + + expect(changes, ['10:45']); + expect( + tester.widget(fieldFinder).controller?.text, + '09:30', + ); + }, + ); + + testWidgets('disabling a time field discards transient invalid input', ( + tester, + ) async { + late StateSetter updateHost; + var enabled = true; + final changes = []; + final validityChanges = []; + await tester.pumpWidget( + localizedTestApp( + alwaysUse24HourFormat: true, + child: StatefulBuilder( + builder: (context, setState) { + updateHost = setState; + return Scaffold( + body: DesktopTimeField( + label: 'Quiet hours start', + time: '09:30', + enabled: enabled, + allowEmpty: false, + onChanged: changes.add, + onValidityChanged: validityChanges.add, + ), + ); + }, + ), + ), + ); + validityChanges.clear(); + + final entryFinder = find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(TextFormField), + ); + await tester.enterText(entryFinder, 'invalid'); + await tester.pump(); + expect(validityChanges, [false]); + + updateHost(() => enabled = false); + await tester.pump(); + + final entry = tester.widget(entryFinder); + final fieldContext = tester.element(find.byType(DesktopTimeField)); + expect(entry.controller?.text, '09:30'); + expect( + find.text(MaterialLocalizations.of(fieldContext).invalidTimeLabel), + findsNothing, + ); + expect(validityChanges, [false, true]); + expect(changes, isEmpty); + }); + + testWidgets('malformed stored time is blank instead of becoming midnight', ( + tester, + ) async { + final changes = []; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopTimeField( + label: 'Due time', + time: 'not-a-provider-time', + onChanged: changes.add, + ), + ), + ), + ); + + final entry = tester.widget( + find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(TextFormField), + ), + ); + expect(entry.controller?.text, isEmpty); + expect(changes, isEmpty); + }); + + testWidgets( + 'same time field state follows locale and 24-hour preference changes', + (tester) async { + final changes = []; + + Future pumpEntry({ + required Locale locale, + required bool alwaysUse24HourFormat, + }) async { + await tester.pumpWidget( + localizedTestApp( + locale: locale, + alwaysUse24HourFormat: alwaysUse24HourFormat, + child: Scaffold( + body: DesktopTimeField( + label: 'Due time', + time: '14:30', + onChanged: changes.add, + ), + ), + ), + ); + await tester.pump(); + } + + String visibleText() => + tester + .widget( + find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(TextFormField), + ), + ) + .controller + ?.text ?? + ''; + + await pumpEntry(locale: const Locale('en'), alwaysUse24HourFormat: false); + final originalState = tester.state(find.byType(DesktopTimeField)); + expect(visibleText(), '2:30 PM'); + + await pumpEntry(locale: const Locale('de'), alwaysUse24HourFormat: false); + expect(tester.state(find.byType(DesktopTimeField)), same(originalState)); + expect(visibleText(), '14:30'); + + await pumpEntry(locale: const Locale('en'), alwaysUse24HourFormat: false); + expect(tester.state(find.byType(DesktopTimeField)), same(originalState)); + expect(visibleText(), '2:30 PM'); + + await pumpEntry(locale: const Locale('en'), alwaysUse24HourFormat: true); + expect(tester.state(find.byType(DesktopTimeField)), same(originalState)); + expect(visibleText(), '14:30'); + expect(changes, isEmpty); + }, + ); +} + +void _ignoreString(String value) {} + +void _ignoreNullableString(String? value) {} + +Future _enterTimeZoneSearch( + WidgetTester tester, + Finder searchField, + String query, +) async { + await tester.enterText(searchField, query); + await tester.pump(); + await tester.pump(); +} diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 123fbe1..3ac6a18 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -2,32 +2,59 @@ import 'dart:async'; import 'dart:io'; import 'dart:ui'; +import 'package:intl/intl.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/core/time/time_zone_catalog.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/features/tasks/presentation/task_details_editor.dart'; import 'package:busymax/src/features/tasks/presentation/task_details_pane.dart'; +import 'package:busymax/src/platform/native_dialog_service.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; -import 'package:ubuntu_widgets/ubuntu_widgets.dart'; import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; const _nativePickerChannel = MethodChannel(nativeDateTimePickerChannelName); +const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); +final _vancouverTimeZoneCode = BusyMaxTimeZoneCatalog.location( + 'America/Vancouver', +).code; + +String _withVancouverTimeZone(String time) { + return '$time ($_vancouverTimeZoneCode)'; +} void main() { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, (_) async => null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); + }); + tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_nativePickerChannel, (_) async { throw MissingPluginException(); }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); }); testWidgets('Task Details header shows Cancel and Save', (tester) async { @@ -37,45 +64,188 @@ void main() { expect(find.text('Edit Task'), findsOneWidget); expect(find.text('Task details'), findsNothing); expect(find.text('Save'), findsOneWidget); + expect( + find.descendant( + of: find.byType(TaskDetailsEditor), + matching: find.byType(YaruScrollViewUndershoot), + ), + findsOneWidget, + ); }); - testWidgets('Cancel and Save are compact PushButtons', (tester) async { + testWidgets('Cancel and Save use natural-width themed controls', ( + tester, + ) async { await _pumpDetails(tester, microsoftTaskProviderCapabilities); expect( find.ancestor( of: find.text('Cancel'), - matching: find.byWidgetPredicate((widget) => widget is PushButton), + matching: find.byType(FilledButton), ), findsOneWidget, ); expect( find.ancestor( of: find.text('Save'), - matching: find.byWidgetPredicate((widget) => widget is PushButton), + matching: find.byType(ElevatedButton), ), findsOneWidget, ); + final cancelButton = tester.widget( + find.ancestor( + of: find.text('Cancel'), + matching: find.byType(FilledButton), + ), + ); + final saveButton = tester.widget( + find.ancestor( + of: find.text('Save'), + matching: find.byType(ElevatedButton), + ), + ); + expect(cancelButton.style?.fixedSize, isNull); + expect(cancelButton.style?.minimumSize, isNull); + expect(saveButton.style?.fixedSize, isNull); + expect(saveButton.style?.minimumSize, isNull); }); - testWidgets('header buttons use compact headerbar sizing', (tester) async { - await _pumpDetails(tester, microsoftTaskProviderCapabilities); + testWidgets('task editor groups use the contextual semantic card layer', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + theme: theme, + modalEditorSurface: true, + ); + + final groupedMaterials = tester.widgetList( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == colors.card, + ), + ), + ); + expect(groupedMaterials, isNotEmpty); + expect(colors.groupedSurface.a, lessThan(1)); + expect(colors.card.a, 1); + expect( + groupedMaterials.every((material) => material.color?.a == 1), + isTrue, + ); + expect( + Color.alphaBlend(colors.groupedSurface, colors.window).toARGB32(), + colors.card.toARGB32(), + ); + }); + + testWidgets('task selectors use native combo-row triggers', (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + theme: theme, + modalEditorSurface: true, + ); + final comboRows = find.byType(BusyMaxComboRow); + final comboCount = comboRows.evaluate().length; + expect(comboCount, greaterThanOrEqualTo(2)); expect( - tester.getSize(_headerButtonFinder(tester, 'Cancel')).width, - inInclusiveRange(100, 180), + find.descendant( + of: comboRows, + matching: find.byType(BusyMaxMenuButton), + ), + findsNWidgets(comboCount), ); expect( - tester.getSize(_headerButtonFinder(tester, 'Save')).width, - inInclusiveRange(100, 180), + find.descendant( + of: comboRows, + matching: find.byWidgetPredicate( + (widget) => widget is ButtonStyleButton && widget is! IconButton, + ), + ), + findsNothing, ); + expect( + find.descendant(of: comboRows, matching: find.byType(OutlinedButton)), + findsNothing, + ); + expect( + find.descendant( + of: comboRows, + matching: find.byType(YaruPopupMenuButton), + ), + findsNothing, + ); + final triggers = find.descendant( + of: comboRows, + matching: find.byWidgetPredicate( + (widget) => widget is YaruListTile && widget.focusNode != null, + ), + ); + expect(triggers, findsNWidgets(comboCount)); + for (var index = 0; index < comboCount; index += 1) { + final triggerFinder = triggers.at(index); + final trigger = tester.widget(triggerFinder); + expect(trigger.onTap, isNotNull); + expect( + trigger.hoverColor, + busyMaxRowHoverColor(tester.element(triggerFinder)), + ); + final restingSurface = tester.widget( + find + .descendant(of: triggerFinder, matching: find.byType(Material)) + .first, + ); + expect(restingSurface.type, MaterialType.canvas); + expect(restingSurface.color, Colors.transparent); + expect(restingSurface.color, isNot(colors.control)); + } + }); + + test('task selector content mirrors with text direction', () { + final source = File( + 'lib/src/features/tasks/presentation/task_details_editor.dart', + ).readAsStringSync(); + + expect(source, isNot(contains('_taskEditorSelectedValue'))); + expect(source, isNot(contains('_TaskEditorAccountIdentity'))); + expect(source, isNot(contains('alignment: Alignment.centerRight'))); + expect(source, isNot(contains('alignment: Alignment.centerLeft'))); + }); + + testWidgets('editor actions keep native height without forced width', ( + tester, + ) async { + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + theme: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), + ); + expect( tester.getSize(_headerButtonFinder(tester, 'Cancel')).height, - BusyMaxSizes.headerIconButton, + kYaruButtonHeight, ); expect( tester.getSize(_headerButtonFinder(tester, 'Save')).height, - BusyMaxSizes.headerIconButton, + kYaruButtonHeight, ); }); @@ -173,6 +343,52 @@ void main() { expect(repository.patches.single.fields, {'title': 'Renamed task'}); }); + testWidgets( + 'invalid visible due time disables Save and Ctrl+S and makes Cancel confirm', + (tester) async { + final repository = _FakeTasksRepository(); + var closed = false; + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + repository: repository, + onClose: () => closed = true, + ); + + final dueTime = _labeledTextFormFieldFinder('Due time'); + await tester.enterText(dueTime, 'not a time'); + await tester.pump(); + + expect(_headerButtonOnPressed(tester, 'Save'), isNull); + expect( + find.text( + MaterialLocalizations.of(tester.element(dueTime)).invalidTimeLabel, + ), + findsOneWidget, + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + + expect(repository.patches, isEmpty); + expect(closed, isFalse); + + await tester.tap(_headerButtonFinder(tester, 'Cancel')); + await tester.pumpAndSettle(); + + expect(closed, isFalse); + expect(find.text('Discard changes?'), findsOneWidget); + + await tester.tap(_confirmDialogButton('Discard')); + await tester.pumpAndSettle(); + + expect(closed, isTrue); + expect(repository.patches, isEmpty); + }, + ); + testWidgets('Save closes editor after successful save', (tester) async { final repository = _FakeTasksRepository(); var closed = false; @@ -304,12 +520,49 @@ void main() { expect(_firstTextFieldText(tester), 'New task'); }); + testWidgets('invalid visible due time makes a task switch confirm discard', ( + tester, + ) async { + final repository = _SwitchingTasksRepository(); + addTearDown(repository.dispose); + TaskEntity? restoredTask; + await _pumpSwitchingDetails( + tester, + repository, + taskId: 'task-1', + onTaskSwitchCancelled: (task) => restoredTask = task, + ); + repository.emit(_switchTimedTask('task-1', 'Old task')); + await tester.pumpAndSettle(); + + await tester.enterText( + _labeledTextFormFieldFinder('Due time'), + 'not a time', + ); + await tester.pump(); + + await _pumpSwitchingDetails( + tester, + repository, + taskId: 'task-2', + onTaskSwitchCancelled: (task) => restoredTask = task, + ); + repository.emit(_switchTimedTask('task-2', 'New task')); + await tester.pumpAndSettle(); + + expect(find.text('Discard changes?'), findsOneWidget); + await tester.tap(_confirmDialogButton('Cancel')); + await tester.pumpAndSettle(); + + expect(restoredTask?.id, 'task-1'); + expect(_labeledFieldText(tester, 'Due time'), 'not a time'); + }); + testWidgets('status controls are absent from Task Details', (tester) async { await _pumpDetails(tester, microsoftTaskProviderCapabilities); expect(find.text('Open'), findsNothing); expect(find.text('Done'), findsNothing); - expect(find.byType(SegmentedButton), findsNothing); }); testWidgets('no visible timezone helper text or UTC appears', (tester) async { @@ -415,10 +668,14 @@ void main() { alwaysUse24HourFormat: true, ); - expect(find.text('Jun 6, 2026'), findsOneWidget); - expect(find.text('14:30'), findsOneWidget); - expect(find.byType(YaruDateTimeEntry), findsNothing); - expect(find.byType(YaruTimeEntry), findsNothing); + expect(_labeledFieldText(tester, 'Due date'), 'Jun 6, 2026'); + expect(_labeledFieldText(tester, 'Start date'), 'Jun 4, 2026'); + expect( + _labeledFieldText(tester, 'Due time'), + _withVancouverTimeZone('14:30'), + ); + expect(_labeledTextFormFieldFinder('Due date'), findsOneWidget); + expect(_labeledTextFormFieldFinder('Due time'), findsOneWidget); expect(find.text('14:30:00'), findsNothing); }); @@ -431,10 +688,13 @@ void main() { alwaysUse24HourFormat: false, ); - expect(find.text('2:30 PM'), findsOneWidget); - expect(find.text('Jun 4, 2026'), findsOneWidget); - expect(find.byType(YaruDateTimeEntry), findsNothing); - expect(find.byType(YaruTimeEntry), findsNothing); + expect(_labeledFieldText(tester, 'Start date'), 'Jun 4, 2026'); + expect( + _labeledFieldText(tester, 'Due time'), + _withVancouverTimeZone('2:30 PM'), + ); + expect(_labeledTextFormFieldFinder('Start date'), findsOneWidget); + expect(_labeledTextFormFieldFinder('Due time'), findsOneWidget); expect(find.text('Jun 4, 2026 · 7:00 AM'), findsNothing); }); @@ -448,7 +708,7 @@ void main() { alwaysUse24HourFormat: true, ); - expect(find.byType(YaruDateTimeEntry), findsNothing); + expect(_renderedDateFieldTexts(tester), ['6. Juni 2026', '4. Juni 2026']); expect(find.textContaining('June'), findsNothing); expect(find.textContaining('Jun 4'), findsNothing); }); @@ -463,7 +723,7 @@ void main() { alwaysUse24HourFormat: true, ); - expect(find.byType(YaruDateTimeEntry), findsNothing); + expect(_renderedDateFieldTexts(tester), ['6 juin 2026', '4 juin 2026']); expect(find.textContaining('June'), findsNothing); expect(find.textContaining('Jun 4'), findsNothing); }); @@ -478,7 +738,7 @@ void main() { alwaysUse24HourFormat: true, ); - expect(find.byType(YaruDateTimeEntry), findsNothing); + expect(_renderedDateFieldTexts(tester), ['6 jun 2026', '4 jun 2026']); expect(find.textContaining('June'), findsNothing); expect(find.textContaining('Jun 4'), findsNothing); }); @@ -510,12 +770,12 @@ void main() { await _pumpDetails(tester, googleTaskProviderCapabilities); expect(find.text('Due'), findsOneWidget); - expect(find.text('Due date'), findsOneWidget); - expect(find.text('All Day'), findsNothing); - expect(find.text('Time Slot'), findsNothing); - expect(find.text('Due time'), findsNothing); + expect(_dateRowFinder('Due date'), findsOneWidget); + expect(find.text('All day'), findsNothing); + expect(find.text('Time slot'), findsNothing); + expect(_timeRowFinder('Due time'), findsNothing); expect(find.text('Start'), findsNothing); - expect(find.text('Start date'), findsNothing); + expect(_dateRowFinder('Start date'), findsNothing); expect(find.text('Reminder'), findsNothing); expect(find.text('Repeat'), findsNothing); expect(find.text('Organization'), findsNothing); @@ -540,6 +800,88 @@ void main() { expect(find.text('Google Tasks'), findsWidgets); }); + testWidgets( + 'opaque Microsoft account id waits for and preserves stored provider', + (tester) async { + final accounts = StreamController>(); + addTearDown(accounts.close); + const accountId = 'opaque-account-id'; + + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + accountIdOverride: accountId, + accountsStream: accounts.stream, + ); + + expect(find.byType(TaskDetailsEditor), findsNothing); + + accounts.add([ + const AccountEntity( + id: accountId, + provider: TaskProvider.microsoft, + authState: 'signed_in', + displayName: 'Microsoft User', + email: 'microsoft@example.com', + ), + ]); + await tester.pumpAndSettle(); + + expect(find.text('Start'), findsOneWidget); + expect(find.text('Reminder'), findsOneWidget); + + await tester.enterText(find.byType(TextField).first, 'Unsaved task'); + await tester.pump(); + accounts.add(const []); + await tester.pumpAndSettle(); + + expect(find.text('Unsaved task'), findsOneWidget); + expect(find.text('Start'), findsOneWidget); + expect(find.text('Reminder'), findsOneWidget); + }, + ); + + testWidgets( + 'account stream errors preserve the pane and definitive removal closes it', + (tester) async { + final accounts = StreamController>(); + addTearDown(accounts.close); + const accountId = 'opaque-account-id'; + var closeCalls = 0; + + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + accountIdOverride: accountId, + accountsStream: accounts.stream, + onClose: () => closeCalls += 1, + ); + + expect(closeCalls, 0); + accounts.add([ + const AccountEntity( + id: accountId, + provider: TaskProvider.microsoft, + authState: 'signed_in', + displayName: 'Microsoft User', + email: 'microsoft@example.com', + ), + ]); + await tester.pumpAndSettle(); + expect(find.byType(TaskDetailsEditor), findsOneWidget); + + accounts.addError(StateError('temporary account stream failure')); + await tester.pumpAndSettle(); + expect(find.byType(TaskDetailsEditor), findsOneWidget); + expect(closeCalls, 0); + + accounts.add(const []); + await tester.pumpAndSettle(); + expect(closeCalls, 1); + expect(find.byType(TaskDetailsEditor), findsNothing); + }, + ); + testWidgets('unsupported provider text is not rendered for Google', ( tester, ) async { @@ -547,8 +889,8 @@ void main() { expect(find.text('Provider features'), findsNothing); expect(find.text('Not supported by Google Tasks.'), findsNothing); - expect(find.text('Start date'), findsNothing); - expect(find.text('Start time'), findsNothing); + expect(_dateRowFinder('Start date'), findsNothing); + expect(_timeRowFinder('Start time'), findsNothing); expect(find.text('Add Reminder'), findsNothing); expect(find.text('Importance'), findsNothing); expect(find.text('Categories'), findsNothing); @@ -562,7 +904,7 @@ void main() { expect(find.text('Due'), findsOneWidget); expect(find.text('Start'), findsOneWidget); expect(find.text('Reminder'), findsOneWidget); - expect(find.text('Repeat'), findsNWidgets(2)); + expect(find.text('Repeat'), findsOneWidget); expect(find.text('Organization'), findsOneWidget); expect(find.text('Provider features'), findsNothing); }, @@ -578,10 +920,12 @@ void main() { repository: repository, ); - expect(find.text('Home'), findsOneWidget); + expect(find.widgetWithText(InputChip, 'Home'), findsOneWidget); + expect(find.widgetWithText(ActionChip, 'Add category'), findsOneWidget); await tester.tap(find.text('Add category')); await tester.pump(); + expect(find.byType(YaruAutocomplete), findsOneWidget); await tester.enterText( find.byKey(const Key('task-category-input')), 'Work', @@ -603,9 +947,11 @@ void main() { expect(repository.patches.single.fields['categories'], ['Work']); }); - testWidgets('Microsoft category suggestions can be selected', (tester) async { + testWidgets('Microsoft category suggestions use Yaru autocomplete', ( + tester, + ) async { final repository = _FakeTasksRepository( - categorySuggestions: const ['Home', 'Work'], + categorySuggestions: const ['Home', 'Work', 'Workshop'], ); await _pumpDetails( tester, @@ -619,18 +965,23 @@ void main() { await tester.pumpAndSettle(); final input = find.byKey(const Key('task-category-input')); final field = tester.widget(input); - expect(field.decoration?.border, InputBorder.none); - expect(field.decoration?.focusedBorder, InputBorder.none); + expect(find.byType(YaruAutocomplete), findsOneWidget); + expect(field.decoration?.border, isNull); + expect(field.decoration?.focusedBorder, isNull); expect( tester.getTopLeft(find.text('Work').last).dy, greaterThanOrEqualTo(tester.getBottomLeft(input).dy - 1), ); - await tester.tap(find.text('Work').last); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); - expect(repository.patches.single.fields['categories'], ['Home', 'Work']); + expect(repository.patches.single.fields['categories'], [ + 'Home', + 'Workshop', + ]); }); testWidgets('Due group appears before separate Start group', (tester) async { @@ -638,8 +989,8 @@ void main() { final dueTop = tester.getTopLeft(find.text('Due')).dy; final startTop = tester.getTopLeft(find.text('Start')).dy; - final dueDateTop = tester.getTopLeft(find.text('Due date')).dy; - final startDateTop = tester.getTopLeft(find.text('Start date')).dy; + final dueDateTop = tester.getTopLeft(_dateRowFinder('Due date')).dy; + final startDateTop = tester.getTopLeft(_dateRowFinder('Start date')).dy; expect(dueTop, lessThan(dueDateTop)); expect(dueDateTop, lessThan(startTop)); @@ -670,12 +1021,15 @@ void main() { alwaysUse24HourFormat: true, ); - expect(find.text('Reminder date'), findsOneWidget); - expect(find.text('Reminder time'), findsOneWidget); - expect(find.text('Jun 5, 2026'), findsOneWidget); - expect(find.text('09:15'), findsOneWidget); - expect(find.byType(YaruDateTimeEntry), findsNothing); - expect(find.byType(YaruTimeEntry), findsNothing); + expect(_dateRowFinder('Reminder date'), findsOneWidget); + expect(_timeRowFinder('Reminder time'), findsOneWidget); + expect(_labeledFieldText(tester, 'Reminder date'), 'Jun 5, 2026'); + expect( + _labeledFieldText(tester, 'Reminder time'), + _withVancouverTimeZone('09:15'), + ); + expect(_labeledTextFormFieldFinder('Reminder date'), findsOneWidget); + expect(_labeledTextFormFieldFinder('Reminder time'), findsOneWidget); expect(find.textContaining('Time zone:'), findsNothing); expect(find.text('UTC'), findsNothing); }); @@ -780,25 +1134,23 @@ void main() { ); }); - testWidgets('due date uses native platform picker channel', (tester) async { + testWidgets('due date opens in-window date picker', (tester) async { final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_nativePickerChannel, (call) async { calls.add(call); - expect(call.method, 'pickDate'); - expect(call.arguments, containsPair('initialDate', '2026-06-06')); - expect(call.arguments, containsPair('cancelLabel', 'Cancel')); - expect(call.arguments, containsPair('okLabel', 'OK')); - return '2026-06-15'; + return null; }); await _pumpDetails(tester, microsoftTaskProviderCapabilities); - await _openRowMenu(tester, 'Due date'); + await _openDatePicker(tester, 'Due date'); expect(tester.takeException(), isNull); - expect(calls, hasLength(1)); - expect(find.text('June 2026'), findsNothing); - expect(find.text('Jun 15, 2026'), findsOneWidget); + expect(calls, isEmpty); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + expect(find.byType(CalendarDatePicker), findsNothing); + expect(find.text('June'), findsOneWidget); + expect(_labeledFieldText(tester, 'Due date'), 'Jun 6, 2026'); }); testWidgets('date value row can use in-window picker', (tester) async { @@ -817,12 +1169,20 @@ void main() { ), ); - await _openRowMenu(tester, 'Due date'); + await _openDatePicker(tester, 'Due date'); - expect(find.byType(YaruDateTimeEntry), findsOneWidget); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); expect(find.byType(CalendarDatePicker), findsNothing); + expect(_labeledFieldText(tester, 'Due date'), 'Jun 6, 2026'); - await tester.tap(find.text('OK')); + await tester.tap( + find + .descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.text('6'), + ) + .first, + ); await tester.pumpAndSettle(); expect(changed, '2026-06-06'); @@ -852,8 +1212,9 @@ void main() { ), ); - await _openRowMenu(tester, 'Due date'); - await tester.tap(find.text('OK')); + await _openDatePicker(tester, 'Due date'); + final todayTooltip = DateFormat('EEEE, MMMM d, yyyy').format(now); + await tester.tap(find.byTooltip(todayTooltip)); await tester.pumpAndSettle(); expect(changed, today); @@ -861,7 +1222,7 @@ void main() { }); testWidgets( - 'due time uses in-app time entry instead of custom picker channel', + 'due time uses an inline labeled field without a picker channel', (tester) async { final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -875,10 +1236,12 @@ void main() { alwaysUse24HourFormat: false, ); - expect(_timeTextEntryFinder(), findsNothing); - await _openRowMenu(tester, 'Due time'); - - expect(_timeTextEntryFinder(), findsOneWidget); + expect(_labeledTextFormFieldFinder('Due time'), findsOneWidget); + expect( + _labeledFieldText(tester, 'Due time'), + _withVancouverTimeZone('2:30 PM'), + ); + expect(find.byType(BusyMaxContentPopoverSurface), findsNothing); expect(tester.takeException(), isNull); expect(calls, isEmpty); @@ -896,16 +1259,16 @@ void main() { ); expect(find.byType(BusyMaxTimeModeRow), findsOneWidget); - expect(find.text('All Day'), findsOneWidget); - expect(find.text('Time Slot'), findsOneWidget); - expect(find.text('Due time'), findsOneWidget); - expect(find.text('Start time'), findsOneWidget); + expect(find.text('All day'), findsOneWidget); + expect(find.text('Time slot'), findsOneWidget); + expect(_timeRowFinder('Due time'), findsOneWidget); + expect(_timeRowFinder('Start time'), findsOneWidget); - await tester.tap(find.text('All Day')); + await tester.tap(find.text('All day')); await tester.pumpAndSettle(); - expect(find.text('Due time'), findsNothing); - expect(find.text('Start time'), findsNothing); + expect(_timeRowFinder('Due time'), findsNothing); + expect(_timeRowFinder('Start time'), findsNothing); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); @@ -923,6 +1286,36 @@ void main() { }); }); + testWidgets('all-day mode clears stale invalid scheduled-time state', ( + tester, + ) async { + final repository = _FakeTasksRepository(); + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + repository: repository, + ); + + await tester.enterText( + _labeledTextFormFieldFinder('Due time'), + 'not a time', + ); + await tester.pump(); + expect(_headerButtonOnPressed(tester, 'Save'), isNull); + + await tester.tap(find.text('All day')); + await tester.pumpAndSettle(); + + expect(_timeRowFinder('Due time'), findsNothing); + expect(_timeRowFinder('Start time'), findsNothing); + expect(_headerButtonOnPressed(tester, 'Save'), isNotNull); + + await tester.tap(_headerButtonFinder(tester, 'Save')); + await tester.pumpAndSettle(); + + expect(repository.patches, hasLength(1)); + }); + testWidgets( 'Microsoft all-day scheduled tasks can be switched to time slot', (tester) async { @@ -937,14 +1330,14 @@ void main() { ); expect(find.byType(BusyMaxTimeModeRow), findsOneWidget); - expect(find.text('Due time'), findsNothing); - expect(find.text('Start time'), findsNothing); + expect(_timeRowFinder('Due time'), findsNothing); + expect(_timeRowFinder('Start time'), findsNothing); - await tester.tap(find.text('Time Slot')); + await tester.tap(find.text('Time slot')); await tester.pumpAndSettle(); - expect(find.text('Due time'), findsWidgets); - expect(find.text('Start time'), findsOneWidget); + expect(_timeRowFinder('Due time'), findsOneWidget); + expect(_timeRowFinder('Start time'), findsOneWidget); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); @@ -975,10 +1368,10 @@ void main() { ); expect(find.byType(BusyMaxTimeModeRow), findsOneWidget); - expect(find.text('Due time'), findsOneWidget); - expect(find.text('Start time'), findsOneWidget); - expect(find.text('All Day'), findsOneWidget); - expect(find.text('Time Slot'), findsOneWidget); + expect(_timeRowFinder('Due time'), findsOneWidget); + expect(_timeRowFinder('Start time'), findsOneWidget); + expect(find.text('All day'), findsOneWidget); + expect(find.text('Time slot'), findsOneWidget); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); @@ -986,25 +1379,28 @@ void main() { expect(repository.patches, isEmpty); }); - testWidgets('time entries do not show redundant internal input label', ( + testWidgets('populated time field renders its floating label and value', ( tester, ) async { await _pumpDetails(tester, microsoftTaskProviderCapabilities); - await _openRowMenu(tester, 'Due time'); - - final entryContext = tester.element(_timeTextEntryFinder().first); - final decorationTheme = Theme.of(entryContext).inputDecorationTheme; + final field = _labeledTextFormFieldFinder('Due time'); + final label = find.text('Due time'); - expect(decorationTheme.floatingLabelBehavior, FloatingLabelBehavior.never); - expect(decorationTheme.labelStyle?.fontSize, 0); - expect(decorationTheme.floatingLabelStyle?.fontSize, 0); + expect(field, findsOneWidget); + expect(label, findsOneWidget); + expect( + _labeledFieldText(tester, 'Due time'), + _withVancouverTimeZone('2:30 PM'), + ); + expect(tester.getCenter(label).dy, lessThan(tester.getCenter(field).dy)); }); testWidgets( - 'empty time field uses time placeholder instead of None subtitle', + 'empty time field stays empty and floats its label when focused', (tester) async { await tester.pumpWidget( localizedTestApp( + alwaysUse24HourFormat: true, child: Scaffold( body: DesktopTimeField( label: 'Due time', @@ -1015,11 +1411,19 @@ void main() { ), ); - expect(find.text('Due time'), findsWidgets); + expect(find.byType(DesktopTimeField), findsOneWidget); expect(find.text('None'), findsNothing); - final entry = tester.widget(_timeTextEntryFinder()); - expect(entry.controller?.text, isEmpty); - expect(find.text('--:--'), findsOneWidget); + final field = _labeledTextFormFieldFinder('Due time'); + final label = find.text('Due time'); + final restingLabelTop = tester.getTopLeft(label).dy; + + expect(_labeledFieldText(tester, 'Due time'), isEmpty); + + await tester.tap(field); + await tester.pumpAndSettle(); + + expect(_labeledFieldText(tester, 'Due time'), isEmpty); + expect(tester.getTopLeft(label).dy, lessThan(restingLabelTop)); }, ); @@ -1027,54 +1431,51 @@ void main() { String? changed; await tester.pumpWidget( localizedTestApp( + alwaysUse24HourFormat: true, child: Scaffold( - body: DesktopTimeField( + body: _ControlledTimeField( label: 'Due time', - time: '09:30', + initialTime: '09:30', onChanged: (time) => changed = time, ), ), ), ); - await tester.enterText(_timeTextEntryFinder(), '00:00'); - await tester.pump(); + await _enterTime(tester, label: 'Due time', value: '00:00'); expect(changed, '00:00'); + expect(_labeledFieldText(tester, 'Due time'), '00:00'); expect(tester.takeException(), isNull); }); - testWidgets('time field formats compact numeric input', (tester) async { + testWidgets('time field accepts a complete localized time value', ( + tester, + ) async { String? changed; await tester.pumpWidget( localizedTestApp( + alwaysUse24HourFormat: true, child: Scaffold( - body: DesktopTimeField( + body: _ControlledTimeField( label: 'Due time', - time: null, + initialTime: null, onChanged: (time) => changed = time, ), ), ), ); - await tester.enterText(_timeTextEntryFinder(), '0517'); - await tester.pump(); + await _enterTime(tester, label: 'Due time', value: '05:17'); - final entry = tester.widget(_timeTextEntryFinder()); - expect(entry.controller?.text, '05:17'); expect(changed, '05:17'); + expect(_labeledFieldText(tester, 'Due time'), '05:17'); expect(tester.takeException(), isNull); }); testWidgets('Microsoft payload still includes time zone on Save', ( tester, ) async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_nativePickerChannel, (call) async { - expect(call.method, 'pickDate'); - return '2026-06-15'; - }); final repository = _FakeTasksRepository(); await _pumpDetails( tester, @@ -1083,7 +1484,15 @@ void main() { repository: repository, ); - await _openRowMenu(tester, 'Due date'); + await _openDatePicker(tester, 'Due date'); + await tester.tap( + find + .descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.text('15'), + ) + .first, + ); await tester.pumpAndSettle(); expect(repository.patches, isEmpty); @@ -1101,19 +1510,13 @@ void main() { testWidgets('date field does not render a Flutter calendar grid', ( tester, ) async { - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_nativePickerChannel, (call) async { - calls.add(call); - return null; - }); await _pumpDetails(tester, microsoftTaskProviderCapabilities); - await _openRowMenu(tester, 'Due date'); + await _openDatePicker(tester, 'Due date'); - expect(calls.single.method, 'pickDate'); - expect(find.text('June 2026'), findsNothing); expect(find.byType(CalendarDatePicker), findsNothing); + expect(find.text('June 2026'), findsNothing); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); }); } @@ -1136,6 +1539,9 @@ Future _pumpDetails( bool includeAccountIdentity = true, String? displayName, String? email, + Stream>? accountsStream, + ThemeData? theme, + bool modalEditorSurface = false, }) async { final accountId = accountIdOverride ?? @@ -1173,15 +1579,16 @@ Future _pumpDetails( selectedAccountCapabilitiesProvider.overrideWithValue(capabilities), localTimeZoneProvider.overrideWithValue('UTC'), accountsStreamProvider.overrideWith((ref) { - return Stream.value([ - AccountEntity( - id: accountId, - provider: provider, - authState: 'signed_in', - displayName: accountDisplayName, - email: accountEmail, - ), - ]); + return accountsStream ?? + Stream.value([ + AccountEntity( + id: accountId, + provider: provider, + authState: 'signed_in', + displayName: accountDisplayName, + email: accountEmail, + ), + ]); }), tasksRepositoryForAccountProvider.overrideWith((ref, requestedId) { expect(requestedId, accountId); @@ -1199,13 +1606,25 @@ Future _pumpDetails( child: localizedTestApp( locale: locale, alwaysUse24HourFormat: alwaysUse24HourFormat, + theme: theme, child: Scaffold( - body: TaskDetailsPane( - accountId: accountId, - taskListId: 'list-1', - taskId: 'task-1', - onClose: onClose, - ), + body: modalEditorSurface + ? BusyMaxModalEditorSurface( + maxWidth: 640, + maxHeight: 1000, + child: TaskDetailsPane( + accountId: accountId, + taskListId: 'list-1', + taskId: 'task-1', + onClose: onClose, + ), + ) + : TaskDetailsPane( + accountId: accountId, + taskListId: 'list-1', + taskId: 'task-1', + onClose: onClose, + ), ), ), ), @@ -1300,23 +1719,100 @@ void _focusEditorShortcuts(WidgetTester tester) { focusWidget.focusNode!.requestFocus(); } -Future _openRowMenu(WidgetTester tester, String label) async { - final row = find - .ancestor( - of: find.text(label).first, - matching: find.byType(BusyMaxCalendarValueRow), - ) - .first; +Finder _dateRowFinder(String label) { + return find.byWidgetPredicate( + (widget) => widget is DesktopDateValueRow && widget.label == label, + ); +} + +Finder _timeRowFinder(String label) { + return find.byWidgetPredicate( + (widget) => widget is DesktopTimeValueRow && widget.label == label, + ); +} + +Finder _labeledTextFormFieldFinder(String label) { + return find.ancestor( + of: find.text(label), + matching: find.byType(TextFormField), + ); +} + +String _labeledFieldText(WidgetTester tester, String label) { + return tester + .widget(_labeledTextFormFieldFinder(label).first) + .controller + ?.text ?? + ''; +} + +List _renderedDateFieldTexts(WidgetTester tester) { + final fields = find.descendant( + of: find.byType(DesktopDateField), + matching: find.byType(TextFormField), + ); + return [ + for (final field in tester.widgetList(fields)) + field.controller?.text ?? '', + ]; +} + +Future _openDatePicker(WidgetTester tester, String label) async { + final row = _dateRowFinder(label); await tester.ensureVisible(row); await tester.pumpAndSettle(); - await tester.tap(row); + final calendarIcon = find.descendant( + of: row, + matching: find.byIcon(YaruIcons.calendar), + ); + final button = tester.widget( + find.ancestor(of: calendarIcon, matching: find.byType(YaruIconButton)), + ); + button.onPressed?.call(); await tester.pumpAndSettle(); } -Finder _timeTextEntryFinder() { - return find.byWidgetPredicate( - (widget) => widget is TextFormField && widget.controller != null, - ); +Future _enterTime( + WidgetTester tester, { + required String label, + required String value, +}) async { + final field = _labeledTextFormFieldFinder(label); + await tester.tap(field); + await tester.enterText(field, value); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); +} + +class _ControlledTimeField extends StatefulWidget { + const _ControlledTimeField({ + required this.label, + required this.initialTime, + required this.onChanged, + }); + + final String label; + final String? initialTime; + final ValueChanged onChanged; + + @override + State<_ControlledTimeField> createState() => _ControlledTimeFieldState(); +} + +class _ControlledTimeFieldState extends State<_ControlledTimeField> { + late String? _time = widget.initialTime; + + @override + Widget build(BuildContext context) { + return DesktopTimeField( + label: widget.label, + time: _time, + onChanged: (value) { + setState(() => _time = value); + widget.onChanged(value); + }, + ); + } } class _FakeTasksRepository implements TasksRepository { @@ -1450,6 +1946,26 @@ TaskEntity _switchTask(String id, String title) { ); } +TaskEntity _switchTimedTask(String id, String title) { + return TaskEntity( + accountId: 'microsoft:m', + taskListId: 'list-1', + id: id, + title: title, + localDirty: false, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + updatedLocalAtUtc: '2026-06-04T00:00:00.000Z', + status: 'needsAction', + dueUtc: '2026-06-06', + microsoftDueDateTime: '2026-06-06T14:30:00', + microsoftDueTimeZone: 'America/Vancouver', + microsoftStartDateTime: '2026-06-04T07:00:00', + microsoftStartTimeZone: 'UTC', + ); +} + class _FakeTaskListsRepository implements TaskListsRepository { const _FakeTaskListsRepository({this.accountId = 'microsoft:m'}); diff --git a/test/features/tasks/presentation/tasks_selection_state_test.dart b/test/features/tasks/presentation/tasks_selection_state_test.dart deleted file mode 100644 index 8dcba6d..0000000 --- a/test/features/tasks/presentation/tasks_selection_state_test.dart +++ /dev/null @@ -1,595 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:busymax/src/app/app_bootstrap.dart'; -import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; -import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; -import 'package:busymax/src/features/task_lists/presentation/task_lists_sidebar.dart'; -import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; -import 'package:busymax/src/features/tasks/presentation/task_details_pane.dart'; -import 'package:busymax/src/features/tasks/presentation/tasks_selection_state.dart'; -import 'package:busymax/src/features/tasks/presentation/tasks_workspace.dart'; -import 'package:busymax/src/task_providers/task_provider.dart'; -import 'package:yaru/yaru.dart'; -import '../../../test_localized_app.dart'; - -void main() { - testWidgets('clicking a task list updates selected list provider', ( - tester, - ) async { - final container = await _container(); - await _pumpSidebar(tester, container); - - await tester.tap(find.text('List A')); - await tester.pump(); - - expect(container.read(selectedTaskListIdProvider), 'list-a'); - }); - - testWidgets('clicking a task list does not require GoRouter navigation', ( - tester, - ) async { - final container = await _container(); - await _pumpSidebar(tester, container); - - await tester.tap(find.text('List A')); - await tester.pump(); - - expect(tester.takeException(), isNull); - }); - - testWidgets('sidebar remains mounted after selecting a list', (tester) async { - final container = await _container(); - await _pumpSidebar(tester, container); - - expect(find.byType(TaskListsSidebar), findsOneWidget); - - await tester.tap( - find.descendant( - of: find.byType(TaskListsSidebar), - matching: find.text('List B'), - ), - ); - await tester.pump(); - - expect(find.byType(TaskListsSidebar), findsOneWidget); - expect(container.read(selectedTaskListIdProvider), 'list-b'); - }); - - testWidgets('sidebar renders logo, All tasks row, and accounts header', ( - tester, - ) async { - final container = await _container(); - await _pumpSidebar(tester, container); - - expect(find.byType(Image), findsOneWidget); - expect(find.text('Navigation'), findsNothing); - expect(find.text('All tasks'), findsOneWidget); - expect(find.text('Accounts'), findsOneWidget); - expect(find.byTooltip('New list'), findsOneWidget); - }); - - testWidgets('sidebar header does not overflow when constrained', ( - tester, - ) async { - final container = await _container(); - await _pumpSidebar(tester, container, width: 220); - - expect(tester.takeException(), isNull); - }); - - testWidgets('sidebar renders open account accordion', (tester) async { - final container = await _container( - accounts: const [ - AccountEntity( - id: 'google:a', - provider: TaskProvider.google, - authState: 'signed_in', - displayName: 'Ada', - email: 'ada@example.com', - ), - ], - ); - await _pumpSidebar(tester, container); - - expect(find.text('Google Tasks'), findsOneWidget); - expect(find.text('Ada · ada@example.com'), findsOneWidget); - expect(find.text('List A'), findsOneWidget); - expect(find.text('List B'), findsOneWidget); - expect(find.byType(Card), findsNothing); - expect(find.byTooltip('Switch account'), findsNothing); - expect(find.text('Add Google account'), findsNothing); - }); - - testWidgets( - 'Google account header with no email uses stored account identity', - (tester) async { - final container = await _container( - accounts: const [ - AccountEntity( - id: 'google:a', - provider: TaskProvider.google, - authState: 'signed_in', - providerAccountId: 'ada@gmail.com', - displayName: 'Google', - ), - ], - ); - await _pumpSidebar(tester, container); - - expect(find.text('Google Tasks'), findsOneWidget); - expect(find.text('ada@gmail.com'), findsOneWidget); - expect(find.text('Signed in'), findsNothing); - expect(find.text('Google'), findsNothing); - }, - ); - - testWidgets('Google account header does not show generated account id', ( - tester, - ) async { - final container = await _container( - accounts: const [ - AccountEntity( - id: 'google-abc123', - provider: TaskProvider.google, - authState: 'signed_in', - displayName: 'Google', - ), - ], - ); - await _pumpSidebar(tester, container); - - expect(find.text('Google Tasks'), findsOneWidget); - expect(find.text('google-abc123'), findsNothing); - expect(find.text('Signed in'), findsOneWidget); - expect(find.text('Google'), findsNothing); - }); - - testWidgets('Microsoft account header shows provider and account identity', ( - tester, - ) async { - final container = await _container(accounts: const [_microsoftAccount]); - await _pumpSidebar(tester, container); - - expect(find.text('Microsoft To Do'), findsOneWidget); - expect(find.text('Microsoft User · microsoft@example.com'), findsOneWidget); - expect(find.text('Microsoft'), findsNothing); - }); - - testWidgets('account accordion collapses and expands task lists', ( - tester, - ) async { - final container = await _container(); - await _pumpSidebar(tester, container); - - expect(find.text('List A'), findsOneWidget); - expect(find.text('List B'), findsOneWidget); - - await tester.tap(find.text('Google Tasks')); - await tester.pumpAndSettle(); - - expect(find.text('List A'), findsNothing); - expect(find.text('List B'), findsNothing); - - await tester.tap(find.text('Google Tasks')); - await tester.pumpAndSettle(); - - expect(find.text('List A'), findsOneWidget); - expect(find.text('List B'), findsOneWidget); - }); - - testWidgets('sidebar groups task lists under each account', (tester) async { - final container = await _container( - accounts: const [_googleAccount, _microsoftAccount], - taskListsByAccount: const { - 'google:g': [_TaskListSeed(id: 'google-list', title: 'Google Inbox')], - 'microsoft:m': [ - _TaskListSeed(id: 'microsoft-list', title: 'Microsoft Tasks'), - ], - }, - ); - await _pumpSidebar(tester, container); - - expect(find.text('Google Tasks'), findsOneWidget); - expect(find.text('Google User · google@example.com'), findsOneWidget); - expect(find.text('Microsoft To Do'), findsOneWidget); - expect(find.text('Microsoft User · microsoft@example.com'), findsOneWidget); - expect(find.text('Google Inbox'), findsOneWidget); - expect(find.text('Microsoft Tasks'), findsOneWidget); - - await tester.tap(find.text('Microsoft Tasks')); - await tester.pump(); - - expect(container.read(selectedAccountIdProvider), 'microsoft:m'); - expect(container.read(selectedTaskListIdProvider), 'microsoft-list'); - }); - - testWidgets('clicking All tasks returns to All Tasks mode after a list', ( - tester, - ) async { - final container = await _container(); - await _pumpSidebar(tester, container); - - await tester.tap(find.text('List A')); - await tester.pump(); - - expect(container.read(allTasksModeProvider), isFalse); - expect(container.read(selectedTaskListIdProvider), 'list-a'); - - await tester.tap(find.text('All tasks')); - await tester.pump(); - - expect(container.read(allTasksModeProvider), isTrue); - expect(container.read(selectedTaskListIdProvider), isNull); - expect(container.read(selectedTaskIdProvider), isNull); - }); - - testWidgets('All Tasks row is selected when allTasksMode is true', ( - tester, - ) async { - final container = await _container(); - container.read(allTasksModeProvider.notifier).state = true; - await _pumpSidebar(tester, container); - - final selectedTile = tester.widget( - find.ancestor( - of: find.text('All tasks'), - matching: find.byType(YaruSelectableContainer), - ), - ); - - expect(selectedTile.selected, isTrue); - }); - - testWidgets('entering All Tasks mode clears selected task and list', ( - tester, - ) async { - final container = await _container(); - container.read(allTasksModeProvider.notifier).state = false; - container.read(selectedTaskListIdProvider.notifier).state = 'list-a'; - container.read(selectedTaskIdProvider.notifier).state = 'task-a'; - await _pumpSidebar(tester, container); - - await tester.tap(find.text('All tasks')); - await tester.pump(); - - expect(container.read(allTasksModeProvider), isTrue); - expect(container.read(selectedTaskListIdProvider), isNull); - expect(container.read(selectedTaskIdProvider), isNull); - }); - - testWidgets('sidebar selected list uses theme primary accent', ( - tester, - ) async { - final container = await _container(); - container.read(allTasksModeProvider.notifier).state = false; - container.read(selectedTaskListIdProvider.notifier).state = 'list-a'; - await _pumpSidebar(tester, container); - - final selectedTile = tester.widget( - find.ancestor( - of: find.text('List A'), - matching: find.byType(YaruSelectableContainer), - ), - ); - - expect(selectedTile.selected, isTrue); - }); - - testWidgets('task panel updates for selected list', (tester) async { - final container = await _container(); - await _pumpWorkspace(tester, container); - - expect(find.text('Task for List A'), findsOneWidget); - expect(find.text('Task for List B'), findsOneWidget); - - await tester.tap( - find.descendant( - of: find.byType(TaskListsSidebar), - matching: find.text('List B'), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Task for List B'), findsOneWidget); - expect(find.text('Task for List A'), findsNothing); - }); - - testWidgets('selecting a different list clears selected task', ( - tester, - ) async { - final container = await _container(); - container.read(selectedTaskListIdProvider.notifier).state = 'list-a'; - container.read(selectedTaskIdProvider.notifier).state = 'task-a'; - await _pumpSidebar(tester, container); - - await tester.tap(find.text('List B')); - await tester.pump(); - - expect(container.read(selectedTaskListIdProvider), 'list-b'); - expect(container.read(selectedTaskIdProvider), isNull); - }); - - testWidgets( - 'clicking a task updates selected task provider and shows details pane', - (tester) async { - final container = await _container(); - container.read(selectedTaskListIdProvider.notifier).state = 'list-a'; - container.read(allTasksModeProvider.notifier).state = false; - await _pumpWorkspace(tester, container); - - await tester.tap(find.text('Task for List A')); - await tester.pumpAndSettle(); - - expect(container.read(selectedTaskIdProvider), 'task-a'); - expect(find.byType(TaskDetailsPane), findsOneWidget); - expect(tester.takeException(), isNull); - }, - ); -} - -Future _container({ - List accounts = const [_defaultAccount], - Map>? taskListsByAccount, -}) async { - final taskLists = - taskListsByAccount ?? - (accounts.isEmpty - ? const >{} - : _defaultTaskLists(accounts.first.id)); - final selectedAccountId = accounts.isEmpty ? '' : accounts.first.id; - - final container = ProviderContainer( - overrides: [ - accountsStreamProvider.overrideWith((ref) => Stream.value(accounts)), - taskListsRepositoryForAccountProvider.overrideWith((ref, accountId) { - return _FakeTaskListsRepository( - _taskListEntities(accountId, taskLists[accountId] ?? const []), - ); - }), - taskListsRepositoryProvider.overrideWithValue( - _FakeTaskListsRepository( - _taskListEntities( - selectedAccountId, - taskLists[selectedAccountId] ?? const [], - ), - ), - ), - tasksRepositoryProvider.overrideWithValue(_FakeTasksRepository()), - tasksRepositoryForAccountProvider.overrideWith((ref, accountId) { - return _FakeTasksRepository(); - }), - syncEngineProvider.overrideWithValue(null), - signedInSyncRunnerProvider.overrideWithValue((accountId, initial) async { - return; - }), - ], - ); - addTearDown(container.dispose); - return container; -} - -Future _pumpSidebar( - WidgetTester tester, - ProviderContainer container, { - double? width, -}) async { - addTearDown(() async { - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - }); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: localizedTestApp( - child: Scaffold( - body: width == null - ? const TaskListsSidebar() - : SizedBox(width: width, child: const TaskListsSidebar()), - ), - ), - ), - ); - await tester.pumpAndSettle(); -} - -Future _pumpWorkspace( - WidgetTester tester, - ProviderContainer container, -) async { - addTearDown(() async { - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - }); - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: localizedTestApp(child: const TasksWorkspace()), - ), - ); - await tester.pumpAndSettle(); -} - -class _FakeTaskListsRepository implements TaskListsRepository { - const _FakeTaskListsRepository(this.lists); - - final List lists; - - @override - Stream> watchTaskLists() { - return Stream.value(lists); - } - - @override - Stream watchTaskList(String id) { - return watchTaskLists().map( - (lists) => lists.where((list) => list.id == id).firstOrNull, - ); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _FakeTasksRepository implements TasksRepository { - @override - Stream watchTask(String taskListId, String taskId) { - return Stream.value(_taskForList(taskListId)); - } - - @override - Stream> watchAllTaskTreeGroups( - List accountIds, - TaskViewFilter filter, - ) { - return Stream.value(const [ - TaskTreeGroup( - accountId: 'account', - accountLabel: 'Ada - ada@example.com', - provider: TaskProvider.google, - taskListId: 'list-a', - taskListTitle: 'List A', - nodes: [ - TaskTreeNode( - task: TaskEntity( - accountId: 'account', - taskListId: 'list-a', - id: 'task-a', - title: 'Task for List A', - localDirty: false, - pendingDelete: false, - pendingMove: false, - rawJson: '{}', - updatedLocalAtUtc: '2026-06-04T00:00:00.000Z', - status: 'needsAction', - ), - children: [], - ), - ], - ), - TaskTreeGroup( - accountId: 'account', - accountLabel: 'Ada - ada@example.com', - provider: TaskProvider.google, - taskListId: 'list-b', - taskListTitle: 'List B', - nodes: [ - TaskTreeNode( - task: TaskEntity( - accountId: 'account', - taskListId: 'list-b', - id: 'task-b', - title: 'Task for List B', - localDirty: false, - pendingDelete: false, - pendingMove: false, - rawJson: '{}', - updatedLocalAtUtc: '2026-06-04T00:00:00.000Z', - status: 'needsAction', - ), - children: [], - ), - ], - ), - ]); - } - - @override - Stream> watchTaskTree( - String taskListId, - TaskViewFilter filter, - ) { - return Stream.value([ - TaskTreeNode(task: _taskForList(taskListId), children: const []), - ]); - } - - @override - Stream> watchCategorySuggestions() { - return Stream.value(const []); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -List _taskListEntities( - String accountId, - List<_TaskListSeed> seeds, -) { - return [ - for (final seed in seeds) - TaskListEntity( - accountId: accountId, - id: seed.id, - title: seed.title, - localDirty: false, - pendingDelete: false, - rawJson: '{}', - ), - ]; -} - -Map> _defaultTaskLists(String accountId) { - return { - accountId: const [ - _TaskListSeed(id: 'list-a', title: 'List A'), - _TaskListSeed(id: 'list-b', title: 'List B'), - ], - }; -} - -TaskEntity _taskForList(String taskListId) { - final suffix = taskListId == 'list-b' ? 'B' : 'A'; - return TaskEntity( - accountId: 'account', - taskListId: taskListId, - id: 'task-${suffix.toLowerCase()}', - title: 'Task for List $suffix', - localDirty: false, - pendingDelete: false, - pendingMove: false, - rawJson: '{}', - updatedLocalAtUtc: '2026-06-04T00:00:00.000Z', - status: 'needsAction', - ); -} - -class _TaskListSeed { - const _TaskListSeed({required this.id, required this.title}); - - final String id; - final String title; -} - -const _defaultAccount = AccountEntity( - id: 'account', - provider: TaskProvider.google, - authState: 'signed_in', - displayName: 'Ada', - email: 'ada@example.com', -); - -const _googleAccount = AccountEntity( - id: 'google:g', - provider: TaskProvider.google, - authState: 'signed_in', - displayName: 'Google User', - email: 'google@example.com', -); - -const _microsoftAccount = AccountEntity( - id: 'microsoft:m', - provider: TaskProvider.microsoft, - authState: 'signed_in', - displayName: 'Microsoft User', - email: 'microsoft@example.com', -); diff --git a/test/features/tasks/presentation/tasks_workspace_test.dart b/test/features/tasks/presentation/tasks_workspace_test.dart deleted file mode 100644 index b10bfc1..0000000 --- a/test/features/tasks/presentation/tasks_workspace_test.dart +++ /dev/null @@ -1,1731 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:drift/native.dart'; -import 'package:desktop_notifications/desktop_notifications.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:busymax/src/app/app_bootstrap.dart'; -import 'package:busymax/src/app/busymax_design.dart'; -import 'package:busymax/src/db/app_database.dart'; -import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; -import 'package:busymax/src/features/notifications/desktop_notification_service.dart'; -import 'package:busymax/src/features/sync/sync_engine.dart'; -import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; -import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; -import 'package:busymax/src/features/tasks/presentation/task_details_editor.dart'; -import 'package:busymax/src/features/tasks/presentation/task_details_pane.dart'; -import 'package:busymax/src/features/tasks/presentation/task_filters.dart'; -import 'package:busymax/src/features/tasks/presentation/tasks_selection_state.dart'; -import 'package:busymax/src/features/tasks/presentation/tasks_workspace.dart'; -import 'package:busymax/src/features/tasks/presentation/task_tree_view.dart'; -import 'package:busymax/src/google_tasks/api/google_tasks_api_client.dart'; -import 'package:busymax/src/google_tasks/api/google_tasks_api_models.dart'; -import 'package:busymax/src/task_providers/task_provider.dart'; -import 'package:yaru/yaru.dart'; -import '../../../test_localized_app.dart'; - -void main() { - test( - 'All Tasks filter capabilities enable Google hidden and assigned filters', - () async { - final container = ProviderContainer( - overrides: [ - accountsStreamProvider.overrideWith((ref) { - return Stream.value([ - _accountEntity(id: 'google:g', provider: TaskProvider.google), - _accountEntity( - id: 'microsoft:m', - provider: TaskProvider.microsoft, - ), - ]); - }), - selectedAccountCapabilitiesProvider.overrideWithValue( - microsoftTaskProviderCapabilities, - ), - ], - ); - addTearDown(container.dispose); - container.read(allTasksModeProvider.notifier).state = true; - await container.read(accountsStreamProvider.future); - - final capabilities = container.read( - visibleTaskFilterCapabilitiesProvider, - ); - - expect(capabilities.supportsHiddenTasks, isTrue); - expect(capabilities.supportsAssignedTasks, isTrue); - }, - ); - - test( - 'Microsoft-only All Tasks keeps hidden and assigned filters disabled', - () async { - final container = ProviderContainer( - overrides: [ - accountsStreamProvider.overrideWith((ref) { - return Stream.value([ - _accountEntity( - id: 'microsoft:m', - provider: TaskProvider.microsoft, - ), - ]); - }), - selectedAccountCapabilitiesProvider.overrideWithValue( - googleTaskProviderCapabilities, - ), - ], - ); - addTearDown(container.dispose); - container.read(allTasksModeProvider.notifier).state = true; - await container.read(accountsStreamProvider.future); - - final capabilities = container.read( - visibleTaskFilterCapabilitiesProvider, - ); - - expect(capabilities.supportsHiddenTasks, isFalse); - expect(capabilities.supportsAssignedTasks, isFalse); - }, - ); - - test( - 'List mode filter capabilities use the selected account capabilities', - () { - final container = ProviderContainer( - overrides: [ - accountsStreamProvider.overrideWith((ref) { - return Stream.value([ - _accountEntity(id: 'google:g', provider: TaskProvider.google), - ]); - }), - selectedAccountCapabilitiesProvider.overrideWithValue( - microsoftTaskProviderCapabilities, - ), - ], - ); - addTearDown(container.dispose); - container.read(allTasksModeProvider.notifier).state = false; - - final capabilities = container.read( - visibleTaskFilterCapabilitiesProvider, - ); - - expect(capabilities.supportsHiddenTasks, isFalse); - expect(capabilities.supportsAssignedTasks, isFalse); - expect(capabilities.supportsDueTime, isTrue); - }, - ); - - testWidgets('All Tasks view shows tasks from Google and Microsoft accounts', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _insertAccount( - database, - id: 'google:g', - provider: TaskProvider.google, - displayName: 'Google User', - email: 'google@example.com', - ); - await _insertAccount( - database, - id: 'microsoft:m', - provider: TaskProvider.microsoft, - displayName: 'Microsoft User', - email: 'microsoft@example.com', - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'google:g', - id: 'google-list', - title: 'Google Inbox', - ), - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'microsoft:m', - id: 'microsoft-list', - title: 'Microsoft Tasks', - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'google-task', - title: 'Google task', - dueUtc: Value(_dueUtcForDayOffset(0)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'microsoft:m', - taskListId: 'microsoft-list', - id: 'microsoft-task', - title: 'Microsoft task', - dueUtc: Value(_dueUtcForDayOffset(-1)), - status: const Value('needsAction'), - ), - ); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - final taskTree = find.byType(TaskTreeView); - expect(find.text('All tasks'), findsWidgets); - expect( - find.descendant(of: taskTree, matching: find.text('Google Inbox')), - findsNothing, - ); - expect( - find.descendant(of: taskTree, matching: find.text('Google User')), - findsNothing, - ); - expect( - find.descendant( - of: taskTree, - matching: find.textContaining('Google · Google User · Google Inbox'), - ), - findsOneWidget, - ); - expect( - find.descendant(of: taskTree, matching: find.text('Google task')), - findsOneWidget, - ); - expect( - find.descendant(of: taskTree, matching: find.text('Microsoft Tasks')), - findsNothing, - ); - expect( - find.descendant(of: taskTree, matching: find.text('Microsoft User')), - findsNothing, - ); - expect( - find.descendant( - of: taskTree, - matching: find.textContaining( - 'Microsoft · Microsoft User · Microsoft Tasks', - ), - ), - findsOneWidget, - ); - expect( - find.descendant(of: taskTree, matching: find.text('Microsoft task')), - findsOneWidget, - ); - expect( - tester - .getTopLeft( - find.descendant( - of: taskTree, - matching: find.text('Microsoft task'), - ), - ) - .dy, - lessThan( - tester - .getTopLeft( - find.descendant(of: taskTree, matching: find.text('Google task')), - ) - .dy, - ), - ); - expect(find.text('Select or create a task list to begin.'), findsNothing); - - await _disposeWorkspace(tester); - }); - - testWidgets( - 'checking a Microsoft task queues and syncs the Microsoft account', - (tester) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - final syncEngines = { - 'google:g': _FakeSyncEngine(), - 'microsoft:m': _FakeSyncEngine(), - }; - - final container = _workspaceContainerWithAccountSync( - database, - syncEngines, - ); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await _tapTaskCheckbox(tester, 'Microsoft task'); - await _waitForQueuedMutationSync(tester); - - expect(syncEngines['microsoft:m']!.incrementalSyncCalls, 1); - expect(syncEngines['google:g']!.incrementalSyncCalls, 0); - expect(container.read(selectedAccountIdProvider), 'google:g'); - - await _disposeWorkspace(tester); - }, - ); - - testWidgets('checking a Google task queues and syncs that Google account', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - final syncEngines = { - 'google:g': _FakeSyncEngine(), - 'microsoft:m': _FakeSyncEngine(), - }; - - final container = _workspaceContainerWithAccountSync(database, syncEngines); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await _tapTaskCheckbox(tester, 'Google task'); - await _waitForQueuedMutationSync(tester); - - expect(syncEngines['google:g']!.incrementalSyncCalls, 1); - expect(syncEngines['microsoft:m']!.incrementalSyncCalls, 0); - - await _disposeWorkspace(tester); - }); - - testWidgets('Refresh all in All Tasks mode syncs every signed-in account', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - final syncEngines = { - 'google:g': _FakeSyncEngine(), - 'microsoft:m': _FakeSyncEngine(), - }; - - final container = _workspaceContainerWithAccountSync(database, syncEngines); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await tester.tap(find.byTooltip('Refresh all')); - await tester.pumpAndSettle(); - - expect(syncEngines['google:g']!.incrementalSyncCalls, 1); - expect(syncEngines['microsoft:m']!.incrementalSyncCalls, 1); - expect(find.text('All accounts refreshed.'), findsOneWidget); - - await _disposeWorkspace(tester); - }); - - testWidgets('Refresh all is enabled when signed-in accounts exist', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - final syncEngines = { - 'google:g': _FakeSyncEngine(), - 'microsoft:m': _FakeSyncEngine(), - }; - - final container = _workspaceContainerWithAccountSync(database, syncEngines); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - final onPressed = _toolbarActionOnPressed( - tester, - tooltip: 'Refresh all', - label: 'Refresh all', - ); - expect(onPressed == null, isFalse); - - await _disposeWorkspace(tester); - }); - - testWidgets('New task in All Tasks mode creates in chosen account/list', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - final syncEngines = { - 'google:g': _FakeSyncEngine(), - 'microsoft:m': _FakeSyncEngine(), - }; - - final container = _workspaceContainerWithAccountSync(database, syncEngines); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - final onPressed = _toolbarActionOnPressed( - tester, - tooltip: 'New task', - label: 'New task', - ); - expect(onPressed == null, isFalse); - - await tester.tap(find.byTooltip('New task')); - await tester.pumpAndSettle(); - await tester.enterText(find.widgetWithText(TextField, 'Title'), 'All task'); - await tester.pump(); - await tester.tap(find.text('Create')); - await tester.pumpAndSettle(); - - final created = - await (database.select(database.tasks)..where( - (row) => - row.accountId.equals('google:g') & - row.taskListId.equals('google-list') & - row.title.equals('All task'), - )) - .get(); - expect(created, hasLength(1)); - - await _disposeWorkspace(tester); - }); - - testWidgets('opening an aggregate task keeps all tasks visible', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _insertAccount( - database, - id: 'google:g', - provider: TaskProvider.google, - displayName: 'Google User', - email: 'google@example.com', - ); - await _insertAccount( - database, - id: 'microsoft:m', - provider: TaskProvider.microsoft, - displayName: 'Microsoft User', - email: 'microsoft@example.com', - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'google:g', - id: 'google-list', - title: 'Google Inbox', - ), - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'microsoft:m', - id: 'microsoft-list', - title: 'Microsoft Tasks', - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'google-task', - title: 'Google task', - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'microsoft:m', - taskListId: 'microsoft-list', - id: 'microsoft-task', - title: 'Microsoft task', - status: const Value('needsAction'), - ), - ); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await tester.tap(find.text('Microsoft task')); - await tester.pumpAndSettle(); - - expect(container.read(selectedAccountIdProvider), 'microsoft:m'); - expect(container.read(selectedTaskListIdProvider), 'microsoft-list'); - expect(container.read(selectedTaskIdProvider), 'microsoft-task'); - expect(container.read(allTasksModeProvider), isTrue); - expect(find.byType(TaskDetailsPane), findsOneWidget); - expect(find.byType(TaskDetailsEditor), findsOneWidget); - final taskTree = find.byType(TaskTreeView); - expect( - find.descendant(of: taskTree, matching: find.text('Google task')), - findsOneWidget, - ); - expect( - find.descendant(of: taskTree, matching: find.text('Microsoft task')), - findsOneWidget, - ); - - await _disposeWorkspace(tester); - }); - - testWidgets('wide workspace does not render persistent Task Details editor', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - container.read(selectedAccountIdProvider.notifier).state = 'google:g'; - container.read(selectedTaskListIdProvider.notifier).state = 'google-list'; - container.read(selectedTaskIdProvider.notifier).state = 'google-task'; - await _pumpWorkspaceWithContainer(tester, container); - - expect(find.byType(TaskDetailsPane), findsNothing); - expect(find.byType(TaskDetailsEditor), findsNothing); - - await _disposeWorkspace(tester); - }); - - testWidgets( - 'wide workspace opens Task Details overlay only after task selection', - (tester) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - expect(find.byType(TaskDetailsPane), findsNothing); - expect(find.byType(TaskDetailsEditor), findsNothing); - - await tester.tap(find.text('Microsoft task')); - await tester.pumpAndSettle(); - - expect(find.byType(TaskDetailsPane), findsOneWidget); - expect(find.byType(TaskDetailsEditor), findsOneWidget); - expect(find.byType(ModalBarrier), findsWidgets); - - await _disposeWorkspace(tester); - }, - ); - - testWidgets('Task Details overlay is clamped around editor width', ( - tester, - ) async { - _setTallWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await tester.tap(find.text('Microsoft task')); - await tester.pumpAndSettle(); - - final paneSize = tester.getSize(find.byType(TaskDetailsPane)); - expect(paneSize.width, closeTo(BusyMaxSizes.compactDetailsWidth, 1)); - expect(paneSize.width, lessThan(tester.view.physicalSize.width)); - - await _disposeWorkspace(tester); - }); - - testWidgets( - 'medium workspace does not show inspector button for selected task', - (tester) async { - _setMediumViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - container.read(selectedAccountIdProvider.notifier).state = 'google:g'; - container.read(selectedTaskListIdProvider.notifier).state = 'google-list'; - container.read(selectedTaskIdProvider.notifier).state = 'google-task'; - await _pumpWorkspaceWithContainer(tester, container); - - expect(find.byTooltip('Task details'), findsNothing); - expect(find.byType(TaskDetailsPane), findsNothing); - expect(find.byType(TaskDetailsEditor), findsNothing); - - await _disposeWorkspace(tester); - }, - ); - - testWidgets('medium task selection opens Task Details overlay', ( - tester, - ) async { - _setMediumViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await tester.tap(find.text('Google task')); - await tester.pumpAndSettle(); - - expect(find.byType(TaskDetailsPane), findsOneWidget); - expect(find.byType(TaskDetailsEditor), findsOneWidget); - expect(find.byType(ModalBarrier), findsWidgets); - - await _disposeWorkspace(tester); - }); - - testWidgets('Escape closes medium Task Details overlay', (tester) async { - _setMediumViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await tester.tap(find.text('Google task')); - await tester.pumpAndSettle(); - expect(find.byType(TaskDetailsEditor), findsOneWidget); - - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - - expect(find.byType(TaskDetailsPane), findsNothing); - expect(find.byType(TaskDetailsEditor), findsNothing); - expect(container.read(selectedTaskIdProvider), 'google-task'); - - await _disposeWorkspace(tester); - }); - - testWidgets( - 'saving All Tasks pane edits task account, not selected account', - (tester) async { - _setTallWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - expect(container.read(selectedAccountIdProvider), 'google:g'); - - await tester.tap(find.text('Microsoft task')); - await tester.pumpAndSettle(); - await tester.enterText( - find - .descendant( - of: find.byType(TaskDetailsPane), - matching: find.byType(TextField), - ) - .first, - 'Renamed Microsoft', - ); - await tester.pump(); - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - final microsoftTask = - await (database.select(database.tasks)..where( - (row) => - row.accountId.equals('microsoft:m') & - row.id.equals('microsoft-task'), - )) - .getSingle(); - final googleTask = - await (database.select(database.tasks)..where( - (row) => - row.accountId.equals('google:g') & - row.id.equals('google-task'), - )) - .getSingle(); - - expect(microsoftTask.title, 'Renamed Microsoft'); - expect(googleTask.title, 'Google task'); - - await _disposeWorkspace(tester); - }, - ); - - testWidgets('list-mode task opens details pane scoped to selected account', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - container.read(allTasksModeProvider.notifier).state = false; - container.read(selectedAccountIdProvider.notifier).state = 'google:g'; - container.read(selectedTaskListIdProvider.notifier).state = 'google-list'; - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: localizedTestApp( - child: const TasksWorkspace(selectedListId: 'google-list'), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Google task')); - await tester.pumpAndSettle(); - - expect(find.byType(TaskDetailsPane), findsOneWidget); - expect(find.byType(TaskDetailsEditor), findsOneWidget); - expect(container.read(selectedAccountIdProvider), 'google:g'); - expect(container.read(selectedTaskListIdProvider), 'google-list'); - expect(container.read(selectedTaskIdProvider), 'google-task'); - expect(container.read(allTasksModeProvider), isFalse); - - await _disposeWorkspace(tester); - }); - - testWidgets( - 'Refresh list invokes sync instead of task list metadata refresh', - (tester) async { - _setCompactViewport(tester); - final syncEngine = _FakeSyncEngine(); - final listsRepository = _FakeTaskListsRepository(); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - taskListsRepositoryProvider.overrideWithValue(listsRepository), - tasksRepositoryProvider.overrideWithValue(_FakeTasksRepository()), - syncEngineProvider.overrideWithValue(syncEngine), - ], - child: localizedTestApp( - child: const TasksWorkspace(selectedListId: 'list-1'), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byTooltip('Refresh list')); - await tester.pumpAndSettle(); - - expect(syncEngine.incrementalSyncCalls, 1); - expect(listsRepository.refreshTaskListCalls, 0); - expect(find.text('List refreshed.'), findsOneWidget); - await _disposeWorkspace(tester); - }, - ); - - testWidgets('Refresh list pulls remote completed task into local tree', ( - tester, - ) async { - _setCompactViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _insertAccount(database); - await database.taskListsDao.upsertTaskList(_localTaskList()); - await database.tasksDao.upsertTask( - _localTask(status: const Value('needsAction')), - ); - final apiClient = _FakeGoogleTasksApiClient() - ..taskListsPages = [ - TaskListsPageDto(items: [_taskListDto('list-1')], rawJson: const {}), - ] - ..taskPages['list-1'] = [ - TasksPageDto( - items: [ - TaskDto( - id: 'task-2', - title: 'Task 2', - status: 'completed', - completed: DateTime.utc(2026, 6, 4, 12), - rawJson: const { - 'id': 'task-2', - 'title': 'Task 2', - 'status': 'completed', - 'completed': '2026-06-04T12:00:00.000Z', - }, - ), - ], - rawJson: const {}, - ), - ]; - - await _pumpWorkspace( - tester, - database: database, - syncEngine: SyncEngine( - database: database, - apiClient: apiClient, - accountId: 'account', - nowUtc: () => DateTime.utc(2026, 6, 4), - ), - ); - - expect(find.text('Task 2'), findsOneWidget); - expect( - tester.widget(find.byType(YaruCheckbox)).value, - isFalse, - ); - - await tester.tap(find.byTooltip('Refresh list')); - await tester.pumpAndSettle(); - - final task = - await (database.select(database.tasks)..where( - (row) => - row.accountId.equals('account') & - row.taskListId.equals('list-1') & - row.id.equals('task-2'), - )) - .getSingle(); - expect(task.status, 'completed'); - expect(task.localDirty, isFalse); - expect(apiClient.listTasksPageCalls, 1); - expect( - tester.widget(find.byType(YaruCheckbox)).value, - isTrue, - ); - await _disposeWorkspace(tester); - }); - - testWidgets('search filters tasks by title in list mode', (tester) async { - _setCompactViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _insertAccount(database); - await database.taskListsDao.upsertTaskList(_localTaskList()); - await database.tasksDao.upsertTask( - _localTask( - id: 'task-alpha', - title: 'Alpha plan', - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - id: 'task-beta', - title: 'Beta task', - status: const Value('needsAction'), - ), - ); - - await _pumpWorkspace( - tester, - database: database, - syncEngine: _FakeSyncEngine(), - ); - - await tester.enterText(find.byType(TextField).first, 'alpha'); - await tester.pumpAndSettle(); - - expect(find.text('Alpha plan'), findsOneWidget); - expect(find.text('Beta task'), findsNothing); - await _disposeWorkspace(tester); - }); - - testWidgets('search filters tasks by notes and body in list mode', ( - tester, - ) async { - _setCompactViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _insertAccount(database); - await database.taskListsDao.upsertTaskList(_localTaskList()); - await database.tasksDao.upsertTask( - _localTask( - id: 'task-notes', - title: 'General task', - notes: const Value('Private note marker'), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - id: 'task-body', - title: 'Body task', - bodyContent: const Value('Body marker'), - status: const Value('needsAction'), - ), - ); - - await _pumpWorkspace( - tester, - database: database, - syncEngine: _FakeSyncEngine(), - ); - - await tester.enterText(find.byType(TextField).first, 'body marker'); - await tester.pumpAndSettle(); - - expect(find.text('Body task'), findsOneWidget); - expect(find.text('General task'), findsNothing); - await _disposeWorkspace(tester); - }); - - testWidgets('search works in All Tasks mode and clearing restores rows', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await tester.enterText(find.byType(TextField).first, 'microsoft'); - await tester.pumpAndSettle(); - - expect(find.text('Microsoft task'), findsOneWidget); - expect(find.text('Google task'), findsNothing); - - await tester.tap(find.byIcon(YaruIcons.edit_clear).first); - await tester.pumpAndSettle(); - - expect(find.text('Microsoft task'), findsOneWidget); - expect(find.text('Google task'), findsOneWidget); - await _disposeWorkspace(tester); - }); - - testWidgets('empty search results do not render boxed call to action', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await tester.enterText(find.byType(TextField).first, 'no matching task'); - await tester.pumpAndSettle(); - - final taskTree = find.byType(TaskTreeView); - expect( - find.descendant(of: taskTree, matching: find.byType(YaruInfoBox)), - findsNothing, - ); - expect( - find.descendant(of: taskTree, matching: find.text('No tasks yet')), - findsNothing, - ); - expect( - find.descendant(of: taskTree, matching: find.text('No tasks.')), - findsOneWidget, - ); - await _disposeWorkspace(tester); - }); - - testWidgets('All Tasks groups tasks by due bucket', (tester) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - expect(find.text('Overdue'), findsOneWidget); - expect(find.text('Today'), findsOneWidget); - expect( - tester.getTopLeft(find.text('Overdue')).dy, - lessThan(tester.getTopLeft(find.text('Today')).dy), - ); - await _disposeWorkspace(tester); - }); - - testWidgets( - 'All Tasks orders buckets and rows by due, title, source, and id', - (tester) async { - _setTallWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _insertAccount( - database, - id: 'google:g', - provider: TaskProvider.google, - displayName: 'Google User', - email: 'google@example.com', - ); - await _insertAccount( - database, - id: 'microsoft:m', - provider: TaskProvider.microsoft, - displayName: 'Microsoft User', - email: 'microsoft@example.com', - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'google:g', - id: 'google-list', - title: 'Google Inbox', - ), - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'microsoft:m', - id: 'microsoft-list', - title: 'Microsoft Tasks', - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'overdue', - title: 'Overdue task', - dueUtc: Value(_dueUtcForDayOffset(-1)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'microsoft:m', - taskListId: 'microsoft-list', - id: 'today-alpha', - title: 'Alpha today', - dueUtc: Value(_dueUtcForDayOffset(0)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'today-same-a', - title: 'Same today', - dueUtc: Value(_dueUtcForDayOffset(0)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'today-same-b', - title: 'Same today', - dueUtc: Value(_dueUtcForDayOffset(0)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'microsoft:m', - taskListId: 'microsoft-list', - id: 'today-same-microsoft', - title: 'Same today', - dueUtc: Value(_dueUtcForDayOffset(0)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'tomorrow', - title: 'Tomorrow task', - dueUtc: Value(_dueUtcForDayOffset(1)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'microsoft:m', - taskListId: 'microsoft-list', - id: 'upcoming', - title: 'Upcoming task', - dueUtc: Value(_dueUtcForDayOffset(2)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'no-date', - title: 'No date task', - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'microsoft:m', - taskListId: 'microsoft-list', - id: 'completed', - title: 'Completed task', - dueUtc: Value(_dueUtcForDayOffset(-1)), - status: const Value('completed'), - ), - ); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - expect( - _top(tester, find.text('Overdue')), - lessThan(_top(tester, find.text('Today'))), - ); - expect( - _top(tester, find.text('Today')), - lessThan(_top(tester, find.text('Tomorrow'))), - ); - expect( - _top(tester, find.text('Tomorrow')), - lessThan(_top(tester, find.text('Upcoming'))), - ); - expect( - _top(tester, find.text('Upcoming')), - lessThan(_top(tester, find.text('No date'))), - ); - expect( - _top(tester, find.text('No date')), - lessThan(_top(tester, find.text('Completed'))), - ); - expect( - _top(tester, find.text('Completed')), - lessThan( - _top( - tester, - find.byKey( - const ValueKey('task-row-microsoft:m/microsoft-list/completed'), - ), - ), - ), - ); - expect( - find.textContaining('Google · Google User · Google Inbox'), - findsWidgets, - ); - expect( - find.textContaining('Microsoft · Microsoft User · Microsoft Tasks'), - findsWidgets, - ); - expect( - _rowTop(tester, 'microsoft:m', 'microsoft-list', 'today-alpha'), - lessThan(_rowTop(tester, 'google:g', 'google-list', 'today-same-a')), - ); - expect( - _rowTop(tester, 'google:g', 'google-list', 'today-same-a'), - lessThan(_rowTop(tester, 'google:g', 'google-list', 'today-same-b')), - ); - expect( - _rowTop(tester, 'google:g', 'google-list', 'today-same-b'), - lessThan( - _rowTop( - tester, - 'microsoft:m', - 'microsoft-list', - 'today-same-microsoft', - ), - ), - ); - - await _disposeWorkspace(tester); - }, - ); - - testWidgets('Refresh failure is shown in a snackbar', (tester) async { - _setCompactViewport(tester); - final syncEngine = _FakeSyncEngine()..error = StateError('refresh failed'); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - taskListsRepositoryProvider.overrideWithValue( - _FakeTaskListsRepository(), - ), - tasksRepositoryProvider.overrideWithValue(_FakeTasksRepository()), - syncEngineProvider.overrideWithValue(syncEngine), - ], - child: localizedTestApp( - child: const TasksWorkspace(selectedListId: 'list-1'), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byTooltip('Refresh list')); - await tester.pumpAndSettle(); - - expect(syncEngine.incrementalSyncCalls, 1); - expect(find.textContaining('Refresh failed:'), findsOneWidget); - expect(find.textContaining('refresh failed'), findsOneWidget); - await _disposeWorkspace(tester); - }); - - testWidgets('Clear completed is enabled for Google capabilities', ( - tester, - ) async { - _setCompactViewport(tester); - final tasksRepository = _FakeTasksRepository(); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - selectedAccountCapabilitiesProvider.overrideWithValue( - googleTaskProviderCapabilities, - ), - taskListsRepositoryProvider.overrideWithValue( - _FakeTaskListsRepository(), - ), - tasksRepositoryProvider.overrideWithValue(tasksRepository), - syncEngineProvider.overrideWithValue(_FakeSyncEngine()), - ], - child: localizedTestApp( - child: const TasksWorkspace(selectedListId: 'list-1'), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byTooltip('Clear completed')); - await tester.pumpAndSettle(); - - expect(tasksRepository.clearCompletedCalls, ['list-1']); - await _disposeWorkspace(tester); - }); - - testWidgets('Clear completed is hidden for Microsoft capabilities', ( - tester, - ) async { - _setCompactViewport(tester); - final tasksRepository = _FakeTasksRepository(); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - selectedAccountCapabilitiesProvider.overrideWithValue( - microsoftTaskProviderCapabilities, - ), - taskListsRepositoryProvider.overrideWithValue( - _FakeTaskListsRepository(), - ), - tasksRepositoryProvider.overrideWithValue(tasksRepository), - syncEngineProvider.overrideWithValue(_FakeSyncEngine()), - ], - child: localizedTestApp( - child: const TasksWorkspace(selectedListId: 'list-1'), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byTooltip('Clear completed'), findsNothing); - - expect(tasksRepository.clearCompletedCalls, isEmpty); - await _disposeWorkspace(tester); - }); -} - -void _setCompactViewport(WidgetTester tester) { - tester.view.physicalSize = const Size(600, 800); - tester.view.devicePixelRatio = 1; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); -} - -void _setMediumViewport(WidgetTester tester) { - tester.view.physicalSize = const Size(900, 800); - tester.view.devicePixelRatio = 1; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); -} - -void _setWideViewport(WidgetTester tester) { - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); -} - -void _setTallWideViewport(WidgetTester tester) { - tester.view.physicalSize = const Size(1280, 1600); - tester.view.devicePixelRatio = 1; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); -} - -Future _disposeWorkspace(WidgetTester tester) async { - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(const Duration(milliseconds: 1)); -} - -Future _pumpWorkspace( - WidgetTester tester, { - required AppDatabase database, - required SyncEngine syncEngine, -}) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - taskListsRepositoryProvider.overrideWithValue( - TaskListsRepository(database: database, accountId: 'account'), - ), - tasksRepositoryProvider.overrideWithValue( - TasksRepository(database: database, accountId: 'account'), - ), - syncEngineProvider.overrideWithValue(syncEngine), - ], - child: localizedTestApp( - child: const TasksWorkspace(selectedListId: 'list-1'), - ), - ), - ); - await tester.pumpAndSettle(); -} - -ProviderContainer _workspaceContainer(AppDatabase database) { - final container = ProviderContainer( - overrides: [ - databaseProvider.overrideWithValue(database), - accountsStreamProvider.overrideWith((ref) { - final query = database.select(database.accounts) - ..where((row) => row.authState.equals('signed_in')); - return query.watch().map( - (rows) => rows.map(AccountEntity.fromRow).toList(), - ); - }), - taskListsRepositoryProvider.overrideWithValue( - TaskListsRepository(database: database, accountId: 'google:g'), - ), - tasksRepositoryProvider.overrideWithValue( - TasksRepository(database: database, accountId: 'google:g'), - ), - taskListsRepositoryForAccountProvider.overrideWith((ref, accountId) { - return TaskListsRepository(database: database, accountId: accountId); - }), - tasksRepositoryForAccountProvider.overrideWith((ref, accountId) { - return TasksRepository(database: database, accountId: accountId); - }), - syncEngineProvider.overrideWithValue(null), - signedInSyncRunnerProvider.overrideWithValue((accountId, initial) async { - return; - }), - ], - ); - container.read(selectedAccountIdProvider.notifier).state = 'google:g'; - return container; -} - -ProviderContainer _workspaceContainerWithAccountSync( - AppDatabase database, - Map syncEngines, -) { - final apiClients = {}; - final container = ProviderContainer( - overrides: [ - databaseProvider.overrideWithValue(database), - accountsStreamProvider.overrideWith((ref) { - final query = database.select(database.accounts) - ..where((row) => row.authState.equals('signed_in')); - return query.watch().map( - (rows) => rows.map(AccountEntity.fromRow).toList(), - ); - }), - tasksRepositoryProvider.overrideWithValue( - TasksRepository(database: database, accountId: 'google:g'), - ), - tasksRepositoryForAccountProvider.overrideWith((ref, accountId) { - final apiClient = accountId.startsWith('microsoft:') - ? ref.watch( - microsoftAsGoogleTasksApiClientForAccountProvider(accountId), - ) - : ref.watch(googleTasksApiClientForAccountProvider(accountId)); - return TasksRepository( - database: database, - accountId: accountId, - apiClient: apiClient, - onMutationQueued: ref - .watch(pendingMutationSyncRequesterForAccountProvider(accountId)) - .request, - ); - }), - syncEngineProvider.overrideWithValue(null), - syncEngineForAccountFactoryProvider.overrideWithValue((accountId) { - return syncEngines[accountId]!; - }), - googleTasksApiClientForAccountProvider.overrideWith((ref, accountId) { - return apiClients.putIfAbsent(accountId, _FakeGoogleTasksApiClient.new); - }), - microsoftAsGoogleTasksApiClientForAccountProvider.overrideWith(( - ref, - accountId, - ) { - return apiClients.putIfAbsent(accountId, _FakeGoogleTasksApiClient.new); - }), - desktopNotificationBackendProvider.overrideWithValue( - _FakeNotificationBackend(), - ), - signedInSyncRunnerProvider.overrideWithValue((accountId, initial) async { - return; - }), - ], - ); - container.read(selectedAccountIdProvider.notifier).state = 'google:g'; - return container; -} - -Future _pumpWorkspaceWithContainer( - WidgetTester tester, - ProviderContainer container, -) async { - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: localizedTestApp(child: const TasksWorkspace()), - ), - ); - await tester.pumpAndSettle(); -} - -Future _tapTaskCheckbox(WidgetTester tester, String taskTitle) async { - final tile = find.ancestor( - of: find.text(taskTitle), - matching: find.byType(YaruListTile), - ); - await tester.tap( - find.descendant(of: tile, matching: find.byType(YaruCheckbox)), - ); - await tester.pump(); -} - -Future _waitForQueuedMutationSync(WidgetTester tester) async { - await tester.pump(); - await tester.pump(const Duration(milliseconds: 350)); - await tester.pump(); -} - -VoidCallback? _toolbarActionOnPressed( - WidgetTester tester, { - required String tooltip, - required String label, -}) { - final iconButton = find.byWidgetPredicate( - (widget) => widget is YaruIconButton && widget.tooltip == tooltip, - ); - if (iconButton.evaluate().isNotEmpty) { - return tester.widget(iconButton).onPressed; - } - - final button = find.ancestor( - of: find.text(label), - matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton), - ); - return tester.widget(button).onPressed; -} - -double _top(WidgetTester tester, Finder finder) { - return tester.getTopLeft(finder).dy; -} - -double _rowTop( - WidgetTester tester, - String accountId, - String taskListId, - String taskId, -) { - return _top( - tester, - find.byKey(ValueKey('task-row-$accountId/$taskListId/$taskId')), - ); -} - -Future _seedTwoAccountWorkspace(AppDatabase database) async { - await _insertAccount( - database, - id: 'google:g', - provider: TaskProvider.google, - displayName: 'Google User', - email: 'google@example.com', - ); - await _insertAccount( - database, - id: 'microsoft:m', - provider: TaskProvider.microsoft, - displayName: 'Microsoft User', - email: 'microsoft@example.com', - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'google:g', - id: 'google-list', - title: 'Google Inbox', - ), - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'microsoft:m', - id: 'microsoft-list', - title: 'Microsoft Tasks', - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'google-task', - title: 'Google task', - dueUtc: Value(_dueUtcForDayOffset(0)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'microsoft:m', - taskListId: 'microsoft-list', - id: 'microsoft-task', - title: 'Microsoft task', - dueUtc: Value(_dueUtcForDayOffset(-1)), - status: const Value('needsAction'), - ), - ); -} - -String _dueUtcForDayOffset(int dayOffset) { - final now = DateTime.now(); - final date = DateTime( - now.year, - now.month, - now.day, - ).add(Duration(days: dayOffset)); - final year = date.year.toString().padLeft(4, '0'); - final month = date.month.toString().padLeft(2, '0'); - final day = date.day.toString().padLeft(2, '0'); - return '$year-$month-${day}T00:00:00.000Z'; -} - -Future _insertAccount( - AppDatabase database, { - String id = 'account', - TaskProvider provider = TaskProvider.google, - String? displayName, - String? email, -}) { - return database - .into(database.accounts) - .insert( - AccountsCompanion.insert( - id: id, - provider: Value(provider.storageValue), - displayName: Value(displayName), - email: Value(email), - authState: const Value('signed_in'), - createdAtUtc: _now, - updatedAtUtc: _now, - ), - ); -} - -AccountEntity _accountEntity({ - required String id, - required TaskProvider provider, -}) { - return AccountEntity(id: id, provider: provider, authState: 'signed_in'); -} - -TaskListsCompanion _localTaskList({ - String accountId = 'account', - String id = 'list-1', - String title = 'Inbox', -}) { - return TaskListsCompanion.insert( - accountId: accountId, - id: id, - title: title, - rawJson: '{}', - createdLocalAtUtc: _now, - updatedLocalAtUtc: _now, - ); -} - -TasksCompanion _localTask({ - String accountId = 'account', - String taskListId = 'list-1', - String id = 'task-2', - String title = 'Task 2', - Value notes = const Value.absent(), - Value bodyContent = const Value.absent(), - Value dueUtc = const Value.absent(), - required Value status, -}) { - return TasksCompanion.insert( - accountId: accountId, - taskListId: taskListId, - id: id, - title: title, - notes: notes, - status: status, - dueUtc: dueUtc, - bodyContent: bodyContent, - rawJson: '{"id":"$id","title":"$title"}', - createdLocalAtUtc: _now, - updatedLocalAtUtc: _now, - ); -} - -TaskListDto _taskListDto(String id) { - return TaskListDto( - id: id, - title: 'Inbox', - rawJson: {'id': id, 'title': 'Inbox'}, - ); -} - -const _now = '2026-06-04T00:00:00.000Z'; - -class _FakeSyncEngine implements SyncEngine { - var incrementalSyncCalls = 0; - Object? error; - - @override - Future fullSync() async {} - - @override - Future incrementalSync() async { - incrementalSyncCalls += 1; - final failure = error; - if (failure != null) { - throw failure; - } - } -} - -class _FakeTaskListsRepository implements TaskListsRepository { - var refreshTaskListCalls = 0; - - @override - Stream> watchTaskLists() { - return Stream.value(const [ - TaskListEntity( - accountId: 'account', - id: 'list-1', - title: 'Inbox', - localDirty: false, - pendingDelete: false, - rawJson: '{}', - ), - ]); - } - - @override - Stream watchTaskList(String id) { - return Stream.value( - const TaskListEntity( - accountId: 'account', - id: 'list-1', - title: 'Inbox', - localDirty: false, - pendingDelete: false, - rawJson: '{}', - ), - ); - } - - @override - Future refreshTaskList(String id) async { - refreshTaskListCalls += 1; - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _FakeTasksRepository implements TasksRepository { - final clearCompletedCalls = []; - - @override - Stream> watchTaskTree( - String taskListId, - TaskViewFilter filter, - ) { - return Stream.value(const []); - } - - @override - Future clearCompleted(String taskListId) async { - clearCompletedCalls.add(taskListId); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _FakeGoogleTasksApiClient implements GoogleTasksApiClient { - var taskListsPages = []; - final taskPages = >{}; - var listTasksPageCalls = 0; - var _taskListPageIndex = 0; - final _taskPageIndexes = {}; - - @override - Future listTaskListsPage({ - int maxResults = 1000, - String? pageToken, - }) async { - return taskListsPages[_taskListPageIndex++]; - } - - @override - Future listTasksPage({ - required String taskListId, - DateTime? completedMax, - DateTime? completedMin, - DateTime? dueMax, - DateTime? dueMin, - int maxResults = 100, - String? pageToken, - bool showCompleted = true, - bool showDeleted = false, - bool showHidden = false, - DateTime? updatedMin, - bool showAssigned = false, - }) async { - listTasksPageCalls += 1; - final index = _taskPageIndexes.update( - taskListId, - (value) => value + 1, - ifAbsent: () => 0, - ); - return taskPages[taskListId]![index]; - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _FakeNotificationBackend implements DesktopNotificationBackend { - @override - Future close() async {} - - @override - Future notify( - String summary, { - String body = '', - List hints = const [], - List actions = const [], - DesktopNotificationActionHandler? onAction, - }) async {} -} diff --git a/test/google_tasks/oauth/token_exchange_test.dart b/test/google_tasks/oauth/token_exchange_test.dart index 7da3b8b..50772bb 100644 --- a/test/google_tasks/oauth/token_exchange_test.dart +++ b/test/google_tasks/oauth/token_exchange_test.dart @@ -626,7 +626,7 @@ void main() { loopbackFlow: OAuthLoopbackFlow(), ); - await service.signOutAccount('google-a'); + await service.clearLocalSession(accountId: 'google-a'); expect(await tokenStore.readTokenSet('google-a'), isNull); expect(await tokenStore.readTokenSet('google-b'), isNotNull); @@ -665,7 +665,8 @@ void main() { await service.revokeAndSignOutAccount('google-a'); - expect(captured.url.queryParameters['token'], 'refresh'); + expect(captured.url.queryParameters, isEmpty); + expect(Uri.splitQueryString(captured.body)['token'], 'refresh'); expect(await tokenStore.readTokenSet('google-a'), isNull); expect(await tokenStore.readTokenSet('google-b'), isNotNull); expect(await tokenStore.readTokenSet('microsoft:m'), isNotNull); @@ -673,6 +674,36 @@ void main() { }, ); + test( + 'revokeAuthorization reports non-success without clearing credentials', + () async { + final tokenStore = InMemoryOAuthTokenStore(); + await tokenStore.saveTokenSet( + 'google-a', + const OAuthTokenSetFixture().tokenSet, + ); + final service = OAuthService( + config: _config, + httpClient: MockClient((request) async => http.Response('', 503)), + tokenStore: tokenStore, + loopbackFlow: OAuthLoopbackFlow(), + ); + + await expectLater( + service.revokeAuthorization('google-a'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'OAuthRevocationFailed', + ), + ), + ); + + expect(await tokenStore.readTokenSet('google-a'), isNotNull); + }, + ); + test('refresh 400 clears only account being refreshed', () async { final tokenStore = InMemoryOAuthTokenStore(); await tokenStore.saveTokenSet( diff --git a/test/platform/busymax_tray_service_test.dart b/test/platform/busymax_tray_service_test.dart index 558677a..e9b036a 100644 --- a/test/platform/busymax_tray_service_test.dart +++ b/test/platform/busymax_tray_service_test.dart @@ -154,7 +154,7 @@ void main() { ); }); - test('agenda action opens compact agenda without restoring main first', () { + test('agenda action delegates navigation to the app', () { final source = File( 'lib/src/platform/busymax_tray_service.dart', ).readAsStringSync(); @@ -187,7 +187,7 @@ void main() { ), ), ); - expect(source, contains('await _onBeforeQuit?.call();')); + expect(source, isNot(contains('onBeforeQuit'))); }); test('DBus menu exposes layout, properties, and routes stable IDs', () async { diff --git a/test/platform/busymax_window_args_test.dart b/test/platform/busymax_window_args_test.dart deleted file mode 100644 index 81ca72b..0000000 --- a/test/platform/busymax_window_args_test.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:busymax/src/platform/busymax_window_args.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('empty string parses as main window', () { - expect(BusyMaxWindowArgs.parse('').kind, BusyMaxWindowKind.main); - }); - - test('malformed JSON parses as main window', () { - expect(BusyMaxWindowArgs.parse('{bad').kind, BusyMaxWindowKind.main); - }); - - test('compact agenda JSON parses as compact agenda window', () { - final args = BusyMaxWindowArgs.parse( - BusyMaxWindowArgs.compactAgenda.encode(), - ); - - expect(args.kind, BusyMaxWindowKind.compactAgenda); - expect(args.version, BusyMaxWindowArgs.currentVersion); - }); - - test('compact agenda JSON preserves requested position', () { - final args = BusyMaxWindowArgs.parse( - BusyMaxWindowArgs.compactAgendaAt(x: 123, y: 45).encode(), - ); - - expect(args.kind, BusyMaxWindowKind.compactAgenda); - expect(args.requestedPositionX, 123); - expect(args.requestedPositionY, 45); - }); - - test('compact agenda JSON ignores malformed requested position', () { - final args = BusyMaxWindowArgs.parse( - '{"app":"BusyMax","version":1,"kind":"compactAgenda",' - '"position":{"x":"bad","y":45}}', - ); - - expect(args.kind, BusyMaxWindowKind.compactAgenda); - expect(args.requestedPositionX, isNull); - expect(args.requestedPositionY, isNull); - }); - - test('unknown app parses as main window', () { - final args = BusyMaxWindowArgs.parse( - '{"app":"Other","version":1,"kind":"compactAgenda"}', - ); - - expect(args.kind, BusyMaxWindowKind.main); - }); - - test('unknown kind parses as main window', () { - final args = BusyMaxWindowArgs.parse( - '{"app":"BusyMax","version":1,"kind":"unknown"}', - ); - - expect(args.kind, BusyMaxWindowKind.main); - }); -} diff --git a/test/platform/compact_agenda_window_service_test.dart b/test/platform/compact_agenda_window_service_test.dart deleted file mode 100644 index 9195cab..0000000 --- a/test/platform/compact_agenda_window_service_test.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:busymax/src/platform/compact_agenda_window_service.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('compact agenda placement', () { - test('top-right fallback keeps the full window frame on-screen', () { - final position = compactAgendaTopRightWorkAreaPositionForTest( - const Rect.fromLTWH(0, 0, 1920, 1080), - ); - - expect(position.dx, 1430); - expect(position.dy, 6); - }); - - test('top-right fallback clamps to the workarea minimum', () { - final position = compactAgendaTopRightWorkAreaPositionForTest( - const Rect.fromLTWH(0, 0, 320, 240), - ); - - expect(position.dx, 6); - expect(position.dy, 6); - }); - }); -} diff --git a/test/platform/gtk_font_service_test.dart b/test/platform/gtk_font_service_test.dart index 41a7e76..68da640 100644 --- a/test/platform/gtk_font_service_test.dart +++ b/test/platform/gtk_font_service_test.dart @@ -1,10 +1,29 @@ import 'package:busymax/src/platform/gtk_font_service.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + test('providers publish preloaded GTK settings on the first frame', () async { + const font = GtkFontSettings(family: 'Ubuntu Sans', size: 11); + const colors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFFFFFFF), + ); + final container = ProviderContainer( + overrides: [ + initialGtkFontSettingsProvider.overrideWithValue(font), + initialGtkThemeColorsProvider.overrideWithValue(colors), + ], + ); + addTearDown(container.dispose); + + expect(await container.read(gtkFontSettingsProvider.future), font); + expect(await container.read(gtkThemeColorsProvider.future), colors); + }); + test('reads native Ubuntu Sans 11 font settings', () async { const channel = MethodChannel('busymax_test/gtk_font_ubuntu'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -63,6 +82,25 @@ void main() { expect(settings, isNull); }); + test('native GTK settings errors fall back to null during preload', () async { + const channel = MethodChannel('busymax_test/gtk_settings_error'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) => throw PlatformException(code: 'unavailable'), + ); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + expect(await const GtkFontService(channel: channel).getGtkFont(), isNull); + expect( + await const GtkThemeService(channel: channel).getGtkThemeColors(), + isNull, + ); + }); + test('invalid native GTK font size does not crash', () async { const channel = MethodChannel('busymax_test/gtk_font_invalid_size'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -79,6 +117,43 @@ void main() { expect(settings, const GtkFontSettings(family: 'GTK Test Sans', size: 0)); }); + test('malformed native GTK payload types are ignored safely', () async { + const fontChannel = MethodChannel('busymax_test/gtk_font_malformed'); + const themeChannel = MethodChannel('busymax_test/gtk_theme_malformed'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + fontChannel, + (_) async => {'family': 42, 'size': 11}, + ); + messenger.setMockMethodCallHandler( + themeChannel, + (_) async => { + 'brightness': 'light', + 'window': 42, + 'view': true, + 'foreground': ['#000000'], + }, + ); + addTearDown(() { + messenger + ..setMockMethodCallHandler(fontChannel, null) + ..setMockMethodCallHandler(themeChannel, null); + }); + + expect( + await const GtkFontService(channel: fontChannel).getGtkFont(), + isNull, + ); + final colors = await const GtkThemeService( + channel: themeChannel, + ).getGtkThemeColors(); + expect(colors?.brightness, Brightness.light); + expect(colors?.window, isNull); + expect(colors?.view, isNull); + expect(colors?.foreground, isNull); + }); + test('missing native GTK font size does not crash', () async { const channel = MethodChannel('busymax_test/gtk_font_missing_size'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -201,13 +276,16 @@ void main() { 'controlHover': '#2EFFFFFF', 'controlActive': '#33FFFFFF', 'accent': '#C061CB', + 'accentForeground': '#FFFFFFFF', 'activeToggle': '#44FFFFFF', 'foreground': '#FFFFFF', 'mutedForeground': '#C0C0C0', 'disabledForeground': '#61FFFFFF', 'disabledControl': '#0FFFFFFF', 'border': '#99000000', - 'subtleBorder': '#1AFFFFFF', + 'divider': '#1AFFFFFF', + 'cardShade': '#5A101010', + 'floatingBorder': '#24000000', 'sidebarBorder': '#33000000', 'shade': '#55000000', }; @@ -223,13 +301,18 @@ void main() { expect(colors?.brightness, Brightness.dark); expect(colors?.window, const Color(0xFF202020)); + expect(colors?.view, const Color(0xFF212121)); expect(colors?.sidebar, const Color(0xFF303030)); expect(colors?.headerbar, const Color(0xFF242424)); + expect(colors?.headerbarFlat, const Color(0xFF212121)); expect(colors?.popover, const Color(0xFF383838)); expect(colors?.control, const Color(0x1AFFFFFF)); expect(colors?.controlActive, const Color(0x33FFFFFF)); expect(colors?.accent, const Color(0xFFC061CB)); - expect(colors?.subtleBorder, const Color(0x1AFFFFFF)); + expect(colors?.accentForeground, const Color(0xFFFFFFFF)); + expect(colors?.divider, const Color(0x1AFFFFFF)); + expect(colors?.cardShade, const Color(0x5A101010)); + expect(colors?.floatingBorder, const Color(0x24000000)); }); test('missing native GTK theme color channel falls back to null', () async { @@ -240,6 +323,34 @@ void main() { expect(settings, isNull); }); + test( + 'explicit theme preference is forwarded before palette lookup', + () async { + const channel = MethodChannel('busymax_test/gtk_theme_preference'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + const service = GtkThemeService(channel: channel); + + await service.setPreferDark(true); + await service.setPreferDark(false); + await service.setPreferDark(null); + + expect(calls.map((call) => call.method), [ + 'setGtkThemePreference', + 'setGtkThemePreference', + ]); + expect(calls.map((call) => call.arguments), [true, false]); + }, + ); + test('theme color stream emits initial and updated values', () async { const events = EventChannel('busymax_test/gtk_theme_events_update'); MockStreamHandlerEventSink? sink; diff --git a/test/platform/linux_header_bar_configuration_synchronizer_test.dart b/test/platform/linux_header_bar_configuration_synchronizer_test.dart new file mode 100644 index 0000000..0e94a97 --- /dev/null +++ b/test/platform/linux_header_bar_configuration_synchronizer_test.dart @@ -0,0 +1,151 @@ +import 'dart:async'; + +import 'package:busymax/src/platform/linux_header_bar_configuration_synchronizer.dart'; +import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'coalesces equal requests and applies only the latest frame value', + () async { + final callbacks = []; + final applied = []; + final synchronizer = BusyMaxHeaderBarConfigurationSynchronizer.forTesting( + apply: (configuration) async => applied.add(configuration), + scheduleAfterFrame: callbacks.add, + ); + addTearDown(synchronizer.dispose); + final first = _configuration(dark: false); + final latest = _configuration(dark: true); + + synchronizer + ..schedule(first) + ..schedule(first) + ..schedule(latest); + + expect(callbacks, hasLength(2)); + for (final callback in callbacks) { + callback(); + } + await synchronizer.settled; + + expect(applied, [latest]); + }, + ); + + test('serializes an in-flight update before the newest value', () async { + final callbacks = []; + final firstRelease = Completer(); + final applied = []; + final synchronizer = BusyMaxHeaderBarConfigurationSynchronizer.forTesting( + apply: (configuration) async { + applied.add(configuration); + if (applied.length == 1) { + await firstRelease.future; + } + }, + scheduleAfterFrame: callbacks.add, + ); + addTearDown(synchronizer.dispose); + final first = _configuration(dark: false); + final latest = _configuration(dark: true); + + synchronizer.schedule(first); + callbacks.removeAt(0)(); + await Future.delayed(Duration.zero); + + synchronizer.schedule(latest); + callbacks.removeAt(0)(); + firstRelease.complete(); + await synchronizer.settled; + + expect(applied, [first, latest]); + }); + + test( + 'recovers after a failed native apply and permits an equal retry', + () async { + final callbacks = []; + final applied = []; + final errors = []; + var shouldFail = true; + final synchronizer = BusyMaxHeaderBarConfigurationSynchronizer.forTesting( + apply: (configuration) async { + applied.add(configuration); + if (shouldFail) { + shouldFail = false; + throw StateError('native header unavailable'); + } + }, + scheduleAfterFrame: callbacks.add, + reportError: (error, _) => errors.add(error), + ); + addTearDown(synchronizer.dispose); + final configuration = _configuration(dark: false); + + synchronizer.schedule(configuration); + callbacks.removeAt(0)(); + await synchronizer.settled; + + synchronizer.schedule(configuration); + callbacks.removeAt(0)(); + await synchronizer.settled; + + expect(applied, [configuration, configuration]); + expect(errors, [isA()]); + }, + ); +} + +BusyMaxHeaderBarConfiguration _configuration({required bool dark}) { + return BusyMaxHeaderBarConfiguration( + labels: const BusyMaxHeaderBarLabels( + today: 'Today', + day: 'Day', + week: 'Week', + month: 'Month', + year: 'Year', + agenda: 'Agenda', + search: 'Search', + create: 'Create', + createEvent: 'Event', + createTask: 'Task', + refresh: 'Refresh', + menu: 'Menu', + previous: 'Previous', + next: 'Next', + showSidebarPanel: 'Show sidebar panel', + hideSidebarPanel: 'Hide sidebar panel', + back: 'Back', + settings: 'Settings', + keyboardShortcuts: 'Keyboard shortcuts', + reportIssue: 'Report an issue', + aboutBusyMax: 'About BusyMax', + ), + sidebarWidth: 300, + textDirection: TextDirection.ltr, + theme: BusyMaxHeaderBarTheme( + preferDark: dark, + highContrast: false, + windowBackgroundColor: dark ? Colors.black : Colors.white, + backgroundColor: dark ? Colors.black : Colors.white, + sidebarBackgroundColor: dark ? Colors.black : Colors.white, + foregroundColor: dark ? Colors.white : Colors.black, + sidebarBorderColor: Colors.grey, + dialogBackgroundColor: dark ? Colors.black : Colors.white, + dialogOutlineColor: dark ? Colors.white : Colors.white10, + modalBarrierColor: Colors.black54, + tooltip: const BusyMaxHeaderBarTooltipTheme( + backgroundColor: Color.fromRGBO(0, 0, 0, 0.8), + foregroundColor: Colors.white, + borderColor: Color.fromRGBO(255, 255, 255, 0.1), + borderRadius: 8, + fontSize: 14, + horizontalPadding: 10, + verticalPadding: 6, + minimumHeight: 30, + ), + ), + ); +} diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index a230dd3..a118b2d 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -1,12 +1,45 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:busymax/src/platform/linux_header_bar_service.dart'; import 'package:busymax/src/schedule/schedule_view_mode.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +const _scheduleHeaderState = BusyMaxHeaderBarState( + title: 'July 2026', + viewMode: ScheduleViewMode.month, + canRefresh: true, + canCreateEvent: true, + canCreateTask: true, + searchActive: false, + searchQuery: '', + canShowSidebar: true, + sidebarVisible: true, + navigationVisible: true, + scheduleControlsVisible: true, + backVisible: false, +); + +const _settingsHeaderState = BusyMaxHeaderBarState( + title: 'Settings', + viewMode: ScheduleViewMode.month, + canRefresh: false, + canCreateEvent: false, + canCreateTask: false, + searchActive: false, + searchQuery: '', + canShowSidebar: true, + sidebarVisible: true, + navigationVisible: false, + scheduleControlsVisible: false, + backVisible: true, +); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); - test('sends schedule range and state updates to native headerbar', () async { + test('sends application-wide state to the native headerbar', () async { const channel = MethodChannel('busymax_test/headerbar_updates'); final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -24,12 +57,10 @@ void main() { final service = LinuxHeaderBarService(channel: channel, isLinux: true); addTearDown(service.dispose); + final session = service.claimSession(); + addTearDown(session.dispose); await service.initialize(); - await service.setTitleRange('Jun 7-13, 2026'); - await service.setViewMode(ScheduleViewMode.week); - await service.setCanRefresh(true); - await service.setCanCreate(true); await service.setLocalizedLabels( const BusyMaxHeaderBarLabels( today: 'Today', @@ -40,48 +71,59 @@ void main() { agenda: 'Agenda', search: 'Search', create: 'Create', + createEvent: 'Event', + createTask: 'Task', refresh: 'Refresh', menu: 'Menu', previous: 'Previous', next: 'Next', - sidebar: 'Toggle Sidebar', + showSidebarPanel: 'Show sidebar panel', + hideSidebarPanel: 'Hide sidebar panel', back: 'Back', settings: 'Settings', keyboardShortcuts: 'Keyboard Shortcuts', + reportIssue: 'Report an issue', aboutBusyMax: 'About BusyMax', + todayShortcut: 'Shift+T', + dayShortcut: '1', + sidebarShortcut: 'F9', + createEventShortcut: 'E', + settingsShortcut: 'Ctrl+Alt+S', + keyboardShortcutsShortcut: 'Ctrl+Alt+K', ), ); await service.setSidebarWidth(300); - await service.setSearchActive(false); - await service.setSidebarVisible(true); - await service.setNavigationVisible(false); - await service.setBackVisible(false); - await service.setOnboardingControls( + await service.setTextDirection(TextDirection.rtl); + await session.setOnboardingControls( visible: true, canGoBack: false, canContinue: true, backLabel: 'Back', continueLabel: 'Continue', ); - await service.setModalBarrierVisible(true); + await service.setModalBarrierDepth(2); await service.setTheme( const BusyMaxHeaderBarTheme( preferDark: true, + highContrast: false, windowBackgroundColor: Color(0xFF18181B), backgroundColor: Color(0xFF1D1D20), sidebarBackgroundColor: Color(0xFF2E2E32), foregroundColor: Color(0xFFFFFFFF), - mutedForegroundColor: Color.fromRGBO(255, 255, 255, 0.70), - disabledForegroundColor: Color.fromRGBO(255, 255, 255, 0.38), - controlColor: Color.fromRGBO(255, 255, 255, 0.10), - controlHoverColor: Color.fromRGBO(255, 255, 255, 0.14), - controlActiveColor: Color.fromRGBO(255, 255, 255, 0.18), - accentColor: Color(0xFF2E7D32), - accentForegroundColor: Color(0xFFFFFFFF), - popoverBackgroundColor: Color(0xFF36363A), - borderColor: Color.fromRGBO(0, 0, 6, 0.75), - shadeColor: Color.fromRGBO(0, 0, 6, 0.25), + sidebarBorderColor: Color.fromRGBO(0, 0, 6, 0.75), + dialogBackgroundColor: Color(0xFF36363A), + dialogOutlineColor: Color.fromRGBO(255, 255, 255, 0.07), modalBarrierColor: Color.fromRGBO(0, 0, 0, 0.32), + tooltip: BusyMaxHeaderBarTooltipTheme( + backgroundColor: Color.fromRGBO(0, 0, 0, 0.8), + foregroundColor: Color(0xFFFFFFFF), + borderColor: Color.fromRGBO(255, 255, 255, 0.1), + borderRadius: 8, + fontSize: 14, + horizontalPadding: 10, + verticalPadding: 6, + minimumHeight: 30, + ), ), ); @@ -90,62 +132,85 @@ void main() { calls.map((call) => call.method), containsAllInOrder([ 'initialize', - 'setTitleRange', - 'setViewMode', - 'setCanRefresh', - 'setCanCreate', 'setLocalizedLabels', 'setSidebarWidth', - 'setSearchActive', - 'setSidebarVisible', - 'setNavigationVisible', - 'setBackVisible', + 'setTextDirection', 'setOnboardingControls', - 'setModalBarrierVisible', + 'setModalBarrierState', 'setTheme', ]), ); - expect(calls[1].arguments, 'Jun 7-13, 2026'); - expect(calls[2].arguments, 'week'); - expect(calls[5].arguments, containsPair('today', 'Today')); - expect(calls[5].arguments, containsPair('year', 'Year')); - expect(calls[5].arguments, containsPair('create', 'Create')); - expect(calls[5].arguments, containsPair('menu', 'Menu')); - expect(calls[5].arguments, containsPair('sidebar', 'Toggle Sidebar')); - expect(calls[5].arguments, containsPair('back', 'Back')); - expect(calls[5].arguments, containsPair('settings', 'Settings')); - expect( - calls[5].arguments, - containsPair('keyboardShortcuts', 'Keyboard Shortcuts'), + expect( + calls + .singleWhere((call) => call.method == 'setModalBarrierState') + .arguments, + {'visible': true, 'shadeDepth': 2}, ); - expect(calls[5].arguments, containsPair('aboutBusyMax', 'About BusyMax')); - expect(calls[6].arguments, 300); - expect(calls[9].arguments, false); - expect(calls[11].arguments, containsPair('visible', true)); - expect(calls[11].arguments, containsPair('canContinue', true)); - expect(calls[11].arguments, containsPair('continueLabel', 'Continue')); - expect(calls.last.arguments, containsPair('preferDark', true)); - expect(calls.last.arguments, containsPair('backgroundColor', '#1D1D20')); + expect(calls[1].arguments, containsPair('today', 'Today')); + expect(calls[1].arguments, containsPair('year', 'Year')); + expect(calls[1].arguments, containsPair('create', 'Create')); + expect(calls[1].arguments, containsPair('createEvent', 'Event')); + expect(calls[1].arguments, containsPair('createTask', 'Task')); + expect(calls[1].arguments, containsPair('menu', 'Menu')); expect( - calls.last.arguments, - containsPair('windowBackgroundColor', '#18181B'), + calls[1].arguments, + containsPair('showSidebarPanel', 'Show sidebar panel'), ); expect( - calls.last.arguments, - containsPair('sidebarBackgroundColor', '#2E2E32'), + calls[1].arguments, + containsPair('hideSidebarPanel', 'Hide sidebar panel'), ); + expect(calls[1].arguments, containsPair('back', 'Back')); + expect(calls[1].arguments, containsPair('settings', 'Settings')); expect( - calls.last.arguments, - containsPair('controlHoverColor', 'rgba(255,255,255,0.14)'), + calls[1].arguments, + containsPair('keyboardShortcuts', 'Keyboard Shortcuts'), ); + expect(calls[1].arguments, containsPair('reportIssue', 'Report an issue')); + expect(calls[1].arguments, containsPair('aboutBusyMax', 'About BusyMax')); + expect(calls[1].arguments, containsPair('todayShortcut', 'Shift+T')); + expect(calls[1].arguments, containsPair('dayShortcut', '1')); + expect(calls[1].arguments, containsPair('sidebarShortcut', 'F9')); + expect(calls[1].arguments, containsPair('createEventShortcut', 'E')); + expect(calls[1].arguments, containsPair('settingsShortcut', 'Ctrl+Alt+S')); expect( - calls.last.arguments, - containsPair('controlActiveColor', 'rgba(255,255,255,0.18)'), + calls[1].arguments, + containsPair('keyboardShortcutsShortcut', 'Ctrl+Alt+K'), ); - expect(calls.last.arguments, containsPair('accentColor', '#2E7D32')); + expect(calls[2].arguments, 300); + expect(calls[3].arguments, 'rtl'); + expect(calls[4].arguments, containsPair('visible', true)); + expect(calls[4].arguments, containsPair('canContinue', true)); + expect(calls[4].arguments, containsPair('continueLabel', 'Continue')); + expect( + calls[4].arguments, + containsPair('contentWidth', busyMaxOnboardingContentMaxWidth), + ); + expect(calls[5].arguments, {'visible': true, 'shadeDepth': 2}); expect( calls.last.arguments, - containsPair('accentForegroundColor', '#FFFFFF'), + equals({ + 'preferDark': true, + 'highContrast': false, + 'windowBackgroundColor': '#18181B', + 'backgroundColor': '#1D1D20', + 'sidebarBackgroundColor': '#2E2E32', + 'foregroundColor': '#FFFFFF', + 'sidebarBorderColor': 'rgba(0,0,6,0.75)', + 'dialogBackgroundColor': '#36363A', + 'dialogOutlineColor': 'rgba(255,255,255,0.07)', + 'modalBarrierColor': 'rgba(0,0,0,0.32)', + 'tooltip': { + 'backgroundColor': 'rgba(0,0,0,0.80)', + 'foregroundColor': '#FFFFFF', + 'borderColor': 'rgba(255,255,255,0.10)', + 'borderRadius': 8.0, + 'fontSize': 14.0, + 'horizontalPadding': 10.0, + 'verticalPadding': 6.0, + 'minimumHeight': 30.0, + }, + }), ); }); @@ -161,25 +226,632 @@ void main() { ); }); + test('native bridge failures degrade without blocking Flutter UI', () async { + const channel = MethodChannel('busymax_test/headerbar_platform_failure'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'initialize') { + return true; + } + throw PlatformException(code: 'native_failure'); + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + final service = LinuxHeaderBarService(channel: channel, isLinux: true); + addTearDown(service.dispose); + + await service.initialize(); + expect(service.isAvailable, isTrue); + + await service.setModalBarrierVisible(true); + expect(service.isAvailable, isFalse); + }); + + test('modal depth compatibility preserves visual shade depth', () async { + const channel = MethodChannel('busymax_test/headerbar_modal_visibility'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return call.method == 'initialize' ? true : null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + final service = LinuxHeaderBarService(channel: channel, isLinux: true); + addTearDown(service.dispose); + + await service.initialize(); + await service.setModalBarrierDepth(1); + await service.setModalBarrierDepth(2); + await service.setModalBarrierDepth(1); + await service.setModalBarrierDepth(0); + + expect( + calls + .where((call) => call.method == 'setModalBarrierState') + .map((call) => call.arguments), + [ + {'visible': true, 'shadeDepth': 1}, + {'visible': true, 'shadeDepth': 2}, + {'visible': true, 'shadeDepth': 1}, + {'visible': false, 'shadeDepth': 0}, + ], + ); + }); + + test( + 'sends complete header state atomically and diffs equal state', + () async { + const channel = MethodChannel('busymax_test/headerbar_atomic_state'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return call.method == 'initialize' ? true : null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService(channel: channel, isLinux: true); + addTearDown(service.dispose); + final session = service.claimSession(); + addTearDown(session.dispose); + const state = BusyMaxHeaderBarState( + title: 'July 2026', + viewMode: ScheduleViewMode.month, + canRefresh: true, + canCreateEvent: false, + canCreateTask: true, + searchActive: true, + searchQuery: 'planning', + canShowSidebar: false, + sidebarVisible: false, + navigationVisible: true, + scheduleControlsVisible: true, + backVisible: false, + ); + + await service.initialize(); + await session.updateState(state); + await session.updateState(state.copyWith()); + await session.updateState(state.copyWith(title: 'August 2026')); + + expect(calls.map((call) => call.method), [ + 'initialize', + 'setState', + 'setState', + ]); + expect(calls[1].arguments, { + 'schemaVersion': BusyMaxHeaderBarState.schemaVersion, + 'title': 'July 2026', + 'viewMode': 'month', + 'canRefresh': true, + 'canCreateEvent': false, + 'canCreateTask': true, + 'searchActive': true, + 'searchQuery': 'planning', + 'canShowSidebar': false, + 'sidebarVisible': false, + 'navigationVisible': true, + 'scheduleControlsVisible': true, + 'backVisible': false, + }); + expect(calls[2].arguments, containsPair('title', 'August 2026')); + expect(state.canCreate, isTrue); + expect(BusyMaxHeaderBarState.schemaVersion, 3); + }, + ); + + test( + 'only the active route session can open the native Create menu', + () async { + const channel = MethodChannel('busymax_test/headerbar_create_menu'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (call.method == 'initialize' || + call.method == 'showCreateMenu') { + return true; + } + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService(channel: channel, isLinux: true); + addTearDown(service.dispose); + final scheduleSession = service.claimSession(); + addTearDown(scheduleSession.dispose); + final coveredSession = service.claimSession(); + addTearDown(coveredSession.dispose); + + expect(await scheduleSession.showCreateMenu(), isFalse); + expect(await coveredSession.showCreateMenu(), isTrue); + expect( + calls.where((call) => call.method == 'showCreateMenu'), + hasLength(1), + ); + }, + ); + + test('shares in-flight initialization before applying state', () async { + const channel = MethodChannel('busymax_test/headerbar_shared_initialize'); + final calls = []; + final initialization = Completer(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (call.method == 'initialize') { + return initialization.future; + } + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService(channel: channel, isLinux: true); + addTearDown(service.dispose); + final session = service.claimSession(); + addTearDown(session.dispose); + + final firstInitialization = service.initialize(); + final stateUpdate = session.updateState(_settingsHeaderState); + await pumpEventQueue(times: 1); + + expect(calls.where((call) => call.method == 'initialize'), hasLength(1)); + expect(calls.where((call) => call.method == 'setState'), isEmpty); + + initialization.complete(true); + await Future.wait([firstInitialization, stateUpdate]); + + expect(calls.where((call) => call.method == 'setState'), hasLength(1)); + expect(service.isAvailable, isTrue); + }); + + test('only the active route session can publish header state', () async { + const channel = MethodChannel('busymax_test/headerbar_route_ownership'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return call.method == 'initialize' ? true : null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService(channel: channel, isLinux: true); + addTearDown(service.dispose); + final scheduleSession = service.claimSession(); + addTearDown(scheduleSession.dispose); + await scheduleSession.updateState(_scheduleHeaderState); + + final settingsSession = service.claimSession(); + addTearDown(settingsSession.dispose); + await settingsSession.updateState(_settingsHeaderState); + + await scheduleSession.updateState( + _scheduleHeaderState.copyWith(title: 'Stale schedule update'), + force: true, + ); + scheduleSession.dispose(); + await settingsSession.updateState(_settingsHeaderState.copyWith()); + await settingsSession.updateState( + _settingsHeaderState.copyWith(title: 'Preferences'), + ); + + final stateCalls = calls + .where((call) => call.method == 'setState') + .toList(); + expect(stateCalls, hasLength(3)); + expect(stateCalls[0].arguments, containsPair('title', 'July 2026')); + expect(stateCalls[1].arguments, containsPair('title', 'Settings')); + expect(stateCalls[2].arguments, containsPair('title', 'Preferences')); + expect( + stateCalls.skip(1).map((call) => call.arguments), + everyElement(containsPair('backVisible', true)), + ); + }); + + test('closing the active session restores the covered route', () async { + const channel = MethodChannel('busymax_test/headerbar_route_restore'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return call.method == 'initialize' ? true : null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService(channel: channel, isLinux: true); + addTearDown(service.dispose); + final scheduleSession = service.claimSession(); + addTearDown(scheduleSession.dispose); + await scheduleSession.updateState(_scheduleHeaderState); + + final settingsSession = service.claimSession(); + addTearDown(settingsSession.dispose); + await settingsSession.updateState(_settingsHeaderState); + await scheduleSession.updateState( + _scheduleHeaderState.copyWith( + title: 'Updated schedule', + searchActive: true, + searchQuery: 'meeting', + ), + ); + + expect(scheduleSession.isAvailable, isTrue); + settingsSession.dispose(); + await pumpEventQueue(); + + final stateCalls = calls + .where((call) => call.method == 'setState') + .toList(); + expect(stateCalls, hasLength(3)); + expect( + stateCalls.last.arguments, + containsPair('title', 'Updated schedule'), + ); + expect(stateCalls.last.arguments, containsPair('backVisible', false)); + expect(stateCalls.last.arguments, containsPair('searchActive', true)); + expect(stateCalls.last.arguments, containsPair('searchQuery', 'meeting')); + expect( + stateCalls.last.arguments, + containsPair('scheduleControlsVisible', true), + ); + }); + + test('native actions belong exclusively to the active session', () async { + final service = LinuxHeaderBarService( + channel: const MethodChannel('busymax_test/headerbar_owned_actions'), + isLinux: false, + ); + addTearDown(service.dispose); + final scheduleSession = service.claimSession(); + addTearDown(scheduleSession.dispose); + final scheduleActions = []; + final scheduleSubscription = scheduleSession.actions.listen( + scheduleActions.add, + ); + addTearDown(scheduleSubscription.cancel); + + final settingsSession = service.claimSession(); + addTearDown(settingsSession.dispose); + final settingsActions = []; + final settingsSubscription = settingsSession.actions.listen( + settingsActions.add, + ); + addTearDown(settingsSubscription.cancel); + + await service.handleNativeMethodCall(const MethodCall('aboutBusyMax')); + await pumpEventQueue(); + + expect(scheduleActions, isEmpty); + expect(settingsActions, [BusyMaxHeaderBarAction.aboutBusyMax]); + + settingsSession.dispose(); + await service.handleNativeMethodCall(const MethodCall('back')); + await pumpEventQueue(); + + expect(scheduleActions, [BusyMaxHeaderBarAction.back]); + expect(settingsActions, [BusyMaxHeaderBarAction.aboutBusyMax]); + }); + + test( + 'native search events belong exclusively to the active session', + () async { + final service = LinuxHeaderBarService( + channel: const MethodChannel('busymax_test/headerbar_owned_search'), + isLinux: false, + ); + addTearDown(service.dispose); + final coveredSession = service.claimSession(); + addTearDown(coveredSession.dispose); + final coveredEvents = []; + final coveredSubscription = coveredSession.searchEvents.listen( + coveredEvents.add, + ); + addTearDown(coveredSubscription.cancel); + + final activeSession = service.claimSession(); + addTearDown(activeSession.dispose); + final activeEvents = []; + final activeSubscription = activeSession.searchEvents.listen( + activeEvents.add, + ); + addTearDown(activeSubscription.cancel); + + await service.handleNativeMethodCall( + const MethodCall('searchQueryChanged', 'planning'), + ); + await service.handleNativeMethodCall( + const MethodCall('searchFocusChanged', true), + ); + await service.handleNativeMethodCall(const MethodCall('searchCleared')); + await service.handleNativeMethodCall( + const MethodCall('searchEscapePressed'), + ); + await service.handleNativeMethodCall( + const MethodCall('searchQueryChanged', 42), + ); + await service.handleNativeMethodCall( + const MethodCall('searchFocusChanged', 'yes'), + ); + await pumpEventQueue(); + + expect(coveredEvents, isEmpty); + expect(activeEvents, hasLength(4)); + expect( + (activeEvents[0] as BusyMaxHeaderBarSearchQueryChanged).query, + 'planning', + ); + expect( + (activeEvents[1] as BusyMaxHeaderBarSearchFocusChanged).focused, + isTrue, + ); + expect(activeEvents[2], isA()); + expect(activeEvents[3], isA()); + }, + ); + + test('only the active route session can focus native search', () async { + const channel = MethodChannel('busymax_test/headerbar_focus_search'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (call.method == 'initialize' || call.method == 'focusSearch') { + return true; + } + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final service = LinuxHeaderBarService(channel: channel, isLinux: true); + addTearDown(service.dispose); + final coveredSession = service.claimSession(); + addTearDown(coveredSession.dispose); + final activeSession = service.claimSession(); + addTearDown(activeSession.dispose); + + expect(await coveredSession.focusSearch(), isFalse); + expect(await activeSession.focusSearch(), isTrue); + expect(calls.where((call) => call.method == 'focusSearch'), hasLength(1)); + }); + + test('native search uses responsive GTK geometry with a scoped Yaru shim', () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + final geometryCssStart = source.indexOf( + 'g_autofree gchar* native_search_geometry_css =', + ); + final geometryCssEnd = source.indexOf( + 'const gchar* tooltip_background =', + geometryCssStart, + ); + + expect(source, contains('gtk_search_entry_new()')); + expect( + source, + contains( + 'gtk_widget_set_halign(self->header_title_box, GTK_ALIGN_FILL);', + ), + ); + expect( + source, + contains( + 'gtk_stack_set_hhomogeneous(GTK_STACK(self->header_title_stack), FALSE)', + ), + ); + expect( + source, + contains('gtk_entry_set_max_width_chars(GTK_ENTRY(self->search_entry),'), + ); + expect( + source, + contains('gtk_widget_set_halign(self->search_entry, GTK_ALIGN_FILL);'), + ); + expect( + source, + contains('gtk_widget_set_hexpand(self->search_entry, TRUE);'), + ); + expect( + source, + isNot(contains('gtk_widget_set_size_request(self->search_entry')), + ); + expect(source, contains('"busymax-header-search-entry"')); + expect( + source, + contains( + 'gtk_style_context_add_class(gtk_widget_get_style_context(self->search_entry),', + ), + ); + expect(source, contains('kHeaderSearchEntryStyleClass')); + expect( + source, + contains( + 'set_widget_visible(self->today_button,\n' + ' schedule_controls_visible &&\n' + ' !self->header_search_active);', + ), + ); + expect( + source, + contains( + 'set_widget_visible(self->header_view_box,\n' + ' schedule_controls_visible &&\n' + ' !self->header_search_active);', + ), + ); + + expect(geometryCssStart, isNonNegative); + expect(geometryCssEnd, greaterThan(geometryCssStart)); + final geometryCss = source.substring(geometryCssStart, geometryCssEnd); + expect(geometryCss, contains('use_legacy_yaru_compatibility')); + expect(geometryCss, contains('"entry.search.%s {"')); + expect(geometryCss, contains('"border-radius: 9px;"')); + expect(geometryCss, contains('kHeaderSearchEntryStyleClass')); + expect(geometryCss, isNot(contains('background'))); + expect(geometryCss, isNot(contains('border-color'))); + expect(geometryCss, isNot(contains('"border:'))); + expect(geometryCss, isNot(contains('box-shadow'))); + expect(geometryCss, isNot(contains('padding'))); + expect(geometryCss, isNot(contains('min-height'))); + expect(geometryCss, isNot(contains('#'))); + expect(geometryCss, isNot(contains('rgba('))); + expect( + source, + contains( + 'const gboolean use_legacy_yaru_compatibility =\n' + ' !self->header_bar_high_contrast &&', + ), + ); + }); + + test('focused native search text wins delayed Dart snapshots', () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + final stateSetter = RegExp( + r'static void set_header_search_state[\s\S]*?' + r'(?=^static void set_header_view_mode)', + multiLine: true, + ).firstMatch(source)?.group(0); + + expect(source, contains('enum class HeaderSearchQueryUpdateDisposition')); + expect( + source, + contains('resolve_header_search_query_update(false, true, true)'), + ); + expect( + source, + contains('resolve_header_search_query_update(false, true, false)'), + ); + expect( + source, + contains('HeaderSearchQueryUpdateDisposition::kPreserveNativeText'), + ); + expect( + source, + contains( + 'A newer focused native edit must survive a delayed Dart snapshot', + ), + ); + expect(source, contains('native_entry_has_authority')); + expect(source, isNot(contains('echoes_last_native_query'))); + + expect(stateSetter, isNotNull); + final queryOffset = stateSetter!.indexOf( + 'set_header_search_query(self, query, effective_active);', + ); + final activationOffset = stateSetter.indexOf( + 'self->header_search_active = effective_active;', + ); + expect(queryOffset, isNonNegative); + expect(activationOffset, greaterThan(queryOffset)); + }); + + test('native header menus delegate row focus modality to GTK', () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + + expect(source, contains('gtk_menu_button_new()')); + expect(source, contains('gtk_menu_button_set_menu_model')); + expect(source, contains('g_menu_item_set_action_and_target')); + expect(source, contains('g_simple_action_new_stateful')); + expect(source, contains('GTK_STYLE_CLASS_FLAT')); + expect(source, isNot(contains('GTK_STYLE_CLASS_SUGGESTED_ACTION'))); + expect(source, contains('kHeaderOnboardingTextButtonStyleClass')); + expect( + source, + isNot(contains('button.busymax-header-view-mode-button:focus {"')), + ); + expect(source, isNot(contains('"box-shadow: inset 0 0 0 2px %s;"'))); + expect(source, isNot(contains('outline-style: none'))); + expect(source, isNot(contains('transition: none'))); + expect(source, isNot(contains('popover.busymax-header-popover'))); + expect(source, isNot(contains('kNativePopoverStyleClass'))); + expect(source, isNot(contains('style_header_menu_popover'))); + expect( + source, + contains( + 'gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), FALSE)', + ), + ); + expect(source, contains('gtk_menu_button_get_popup')); + expect(source, contains('GTK_IS_MENU(menu)')); + expect(source, contains('gtk_menu_shell_deactivate')); + expect(source, isNot(contains('gtk_popover_'))); + expect(source, isNot(contains('modelbutton:hover'))); + expect(source, contains('tooltip.background')); + expect(source, contains('fl_lookup_map_arg(args, "tooltip")')); + expect( + source, + contains('fl_lookup_string_arg(tooltip, "backgroundColor")'), + ); + expect(source, isNot(contains('button.busymax-header-popover-row'))); + expect(source, isNot(contains('busymax-keyboard-focus'))); + expect(source, isNot(contains('gtk_window_get_focus_visible'))); + expect(source, isNot(contains('configure_header_popover_row'))); + expect(source, isNot(contains('header_popover_row_key_press_cb'))); + expect(source, isNot(contains('header_popover_row_button_press_cb'))); + expect(source, isNot(contains('gtk_widget_set_can_focus(row, FALSE)'))); + }); + + test('native sidebar availability is separate from expanded state', () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + + expect(source, contains('gboolean header_bar_can_show_sidebar;')); + expect(source, contains('"canShowSidebar"')); + expect( + source, + contains( + 'schedule_controls_visible &&\n' + ' self->header_bar_can_show_sidebar', + ), + ); + expect(source, contains('strcmp(method, "setState") == 0')); + }); + test('native headerbar methods emit Dart actions', () async { final service = LinuxHeaderBarService( channel: const MethodChannel('busymax_test/headerbar_actions'), isLinux: false, ); addTearDown(service.dispose); + final session = service.claimSession(); + addTearDown(session.dispose); - final nextAction = service.actions.take(5).toList(); - await service.handleNativeMethodCall(const MethodCall('create')); + final nextAction = session.actions.take(7).toList(); + await service.handleNativeMethodCall(const MethodCall('createEvent')); + await service.handleNativeMethodCall(const MethodCall('createTask')); await service.handleNativeMethodCall(const MethodCall('continueSetup')); await service.handleNativeMethodCall(const MethodCall('settings')); await service.handleNativeMethodCall(const MethodCall('keyboardShortcuts')); + await service.handleNativeMethodCall(const MethodCall('reportIssue')); await service.handleNativeMethodCall(const MethodCall('aboutBusyMax')); expect(await nextAction, [ - BusyMaxHeaderBarAction.create, + BusyMaxHeaderBarAction.createEvent, + BusyMaxHeaderBarAction.createTask, BusyMaxHeaderBarAction.continueSetup, BusyMaxHeaderBarAction.settings, BusyMaxHeaderBarAction.keyboardShortcuts, + BusyMaxHeaderBarAction.reportIssue, BusyMaxHeaderBarAction.aboutBusyMax, ]); }); @@ -204,23 +876,25 @@ void main() { final service = LinuxHeaderBarService(channel: channel, isLinux: true); addTearDown(service.dispose); + final session = service.claimSession(); + addTearDown(session.dispose); await service.initialize(); - await service.setOnboardingControls( + await session.setOnboardingControls( visible: false, canGoBack: false, canContinue: false, backLabel: '', continueLabel: '', ); - await service.setOnboardingControls( + await session.setOnboardingControls( visible: false, canGoBack: false, canContinue: false, backLabel: '', continueLabel: '', ); - await service.setOnboardingControls( + await session.setOnboardingControls( visible: false, canGoBack: false, canContinue: false, @@ -245,7 +919,6 @@ void main() { addTearDown(service.dispose); await service.initialize(); - await service.setTitleRange('Settings'); expect(service.isAvailable, isFalse); }); diff --git a/test/platform/main_window_command_bridge_test.dart b/test/platform/main_window_command_bridge_test.dart deleted file mode 100644 index bc26ea7..0000000 --- a/test/platform/main_window_command_bridge_test.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:busymax/src/features/schedule/application/compact_agenda_data.dart'; -import 'package:busymax/src/features/schedule/application/compact_agenda_snapshot.dart'; -import 'package:busymax/src/platform/main_window_command_bridge.dart'; -import 'package:busymax/src/schedule/schedule_item.dart'; -import 'package:busymax/src/schedule/schedule_range.dart'; -import 'package:busymax/src/task_providers/task_provider.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('compact agenda snapshot refreshes a cached query', ( - tester, - ) async { - const query = CompactAgendaQuery.initial; - var current = _agendaData(); - var loadCount = 0; - final container = ProviderContainer( - overrides: [ - compactAgendaDataLoaderProvider.overrideWithValue((ref, query) async { - loadCount += 1; - return current; - }), - ], - ); - final provider = compactAgendaDataForQueryProvider(query); - // Keep the initial value cached so a plain read would reproduce the bug. - final subscription = container.listen(provider, (_, _) {}); - addTearDown(() { - subscription.close(); - container.dispose(); - }); - - final initial = await container.read(provider.future); - expect(initial.items, isEmpty); - expect(loadCount, 1); - - late WidgetRef widgetRef; - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: Consumer( - builder: (context, ref, child) { - widgetRef = ref; - return const SizedBox.shrink(); - }, - ), - ), - ); - - current = _agendaData(items: [_googleTask('New Google task')]); - final encoded = await loadFreshCompactAgendaSnapshot( - widgetRef, - encodeCompactAgendaQuery(query), - ); - final refreshed = decodeCompactAgendaData(encoded); - - expect(refreshed.items, hasLength(1)); - expect(refreshed.items.single.title, 'New Google task'); - expect(loadCount, 2); - - await tester.pumpWidget(const SizedBox.shrink()); - }); -} - -CompactAgendaData _agendaData({List items = const []}) { - final today = DateTime(2026, 7, 21); - return CompactAgendaData( - today: today, - range: ScheduleRange( - start: today, - end: today.add(const Duration(days: 30)), - ), - items: items, - hasMoreOverdueTasks: false, - hasMoreNoDateTasks: false, - hasSignedInAccounts: true, - hasSources: true, - generatedAt: today.add(Duration(minutes: items.length)), - ); -} - -TaskScheduleItem _googleTask(String title) { - return TaskScheduleItem( - id: 'new-google-task', - accountId: 'google-account', - provider: TaskProvider.google, - sourceId: 'google-list', - title: title, - completed: false, - allDay: true, - start: DateTime(2026, 7, 21), - ); -} diff --git a/test/platform/native_dialog_service_test.dart b/test/platform/native_dialog_service_test.dart new file mode 100644 index 0000000..2fc2002 --- /dev/null +++ b/test/platform/native_dialog_service_test.dart @@ -0,0 +1,125 @@ +import 'package:busymax/src/platform/native_dialog_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('busymax_test/native_dialogs'); + const groupedListStyle = NativeGroupedListStyle( + surfaceColor: Color(0xFF444444), + dividerColor: Color(0x5C000000), + sectionHeaderColor: Color(0xFFCCCCCC), + primaryTextColor: Color(0xFFEFEFEF), + secondaryTextColor: Color(0xFFBBBBBB), + hoverColor: Color(0x1FFFFFFF), + shadowColor: Color(0xFF000000), + outlineColor: Color(0x1FFFFFFF), + highContrast: false, + radius: 8, + sectionTopSpacing: 16, + sectionHorizontalPadding: 4, + titleBottomSpacing: 8, + ); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('passes timezone content to the native chooser', () async { + MethodCall? receivedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + receivedCall = call; + return 'America/Vancouver'; + }); + const service = NativeDialogService(channel: channel); + + final result = await service.selectTimeZone( + title: 'Select Timezone', + searchPlaceholder: 'Search locations', + noResultsLabel: 'No locations found', + selectedTimeZone: 'Etc/UTC', + options: const [ + NativeTimeZoneOption( + id: 'America/Vancouver', + region: 'America', + name: 'Vancouver', + englishName: 'Vancouver', + title: 'Vancouver (PDT)', + subtitle: 'America/Vancouver - CA', + searchText: 'Vancouver\nAmerica/Vancouver\nCA', + ), + ], + groupedListStyle: groupedListStyle, + ); + + expect(result.available, isTrue); + expect(result.selectedTimeZone, 'America/Vancouver'); + expect(receivedCall?.method, 'selectTimeZone'); + expect(receivedCall?.arguments, { + 'title': 'Select Timezone', + 'searchPlaceholder': 'Search locations', + 'noResultsLabel': 'No locations found', + 'selectedTimeZone': 'Etc/UTC', + 'options': [ + { + 'id': 'America/Vancouver', + 'region': 'America', + 'name': 'Vancouver', + 'englishName': 'Vancouver', + 'title': 'Vancouver (PDT)', + 'subtitle': 'America/Vancouver - CA', + 'searchText': 'Vancouver\nAmerica/Vancouver\nCA', + }, + ], + 'groupedListStyle': { + 'surfaceColor': '#444444', + 'dividerColor': 'rgba(0,0,0,0.36)', + 'sectionHeaderColor': '#CCCCCC', + 'primaryTextColor': '#EFEFEF', + 'secondaryTextColor': '#BBBBBB', + 'hoverColor': 'rgba(255,255,255,0.12)', + 'shadowColor': '#000000', + 'outlineColor': 'rgba(255,255,255,0.12)', + 'highContrast': false, + 'radius': 8, + 'sectionTopSpacing': 16, + 'sectionHorizontalPadding': 4, + 'titleBottomSpacing': 8, + }, + }); + }); + + test( + 'distinguishes native timezone cancellation from unavailable host', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + const service = NativeDialogService(channel: channel); + + final result = await service.selectTimeZone( + title: 'Select Timezone', + searchPlaceholder: 'Search locations', + noResultsLabel: 'No locations found', + selectedTimeZone: 'Etc/UTC', + options: const [ + NativeTimeZoneOption( + id: 'Etc/UTC', + region: 'UTC', + name: 'UTC', + englishName: 'UTC', + title: 'UTC (UTC)', + subtitle: 'Etc/UTC', + searchText: 'UTC\nEtc/UTC', + ), + ], + groupedListStyle: groupedListStyle, + ); + + expect(result.available, isTrue); + expect(result.selectedTimeZone, isNull); + }, + ); +} diff --git a/test/platform/native_menu_service_test.dart b/test/platform/native_menu_service_test.dart new file mode 100644 index 0000000..be91c7f --- /dev/null +++ b/test/platform/native_menu_service_test.dart @@ -0,0 +1,209 @@ +import 'package:busymax/src/platform/native_menu_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('busymax_test/native_menus'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('sends the anchor and semantic entries to the native host', () async { + MethodCall? receivedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + receivedCall = call; + return 1; + }); + const service = NativeMenuService(channel: channel); + final session = NativeMenuSession(); + + final result = await service.show( + session: session, + anchor: const Rect.fromLTWH(24, 36, 140, 34), + entries: const [ + NativeMenuEntry(label: 'Personal'), + NativeMenuEntry( + label: 'Work', + iconName: 'folder-symbolic', + shortcut: 'Ctrl+W', + ), + NativeMenuEntry(label: 'Archived', enabled: false), + ], + ); + + expect(result.available, isTrue); + expect(result.selectedIndex, 1); + expect(receivedCall?.method, 'show'); + expect(receivedCall?.arguments, { + 'sessionId': session.id, + 'anchor': {'x': 24.0, 'y': 36.0, 'width': 140.0, 'height': 34.0}, + 'entries': [ + {'label': 'Personal', 'enabled': true, 'selected': false}, + { + 'label': 'Work', + 'icon': 'folder-symbolic', + 'enabled': true, + 'selected': false, + 'shortcut': 'Ctrl+W', + }, + {'label': 'Archived', 'enabled': false, 'selected': false}, + ], + 'focusFirst': false, + 'preferredPosition': 'bottom', + }); + }); + + test('can request keyboard focus for the first native menu entry', () async { + MethodCall? receivedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + receivedCall = call; + return null; + }); + const service = NativeMenuService(channel: channel); + + await service.show( + session: NativeMenuSession(), + anchor: const Rect.fromLTWH(0, 0, 100, 34), + entries: const [NativeMenuEntry(label: 'Event')], + focusFirst: true, + ); + + expect(receivedCall?.arguments, containsPair('focusFirst', true)); + }); + + test('can place a native menu above its anchor', () async { + MethodCall? receivedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + receivedCall = call; + return null; + }); + const service = NativeMenuService(channel: channel); + + await service.show( + session: NativeMenuSession(), + anchor: const Rect.fromLTWH(0, 0, 100, 34), + entries: const [NativeMenuEntry(label: 'Event')], + preferAbove: true, + ); + + expect(receivedCall?.arguments, containsPair('preferredPosition', 'top')); + }); + + test('distinguishes menu dismissal from an unavailable host', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + const service = NativeMenuService(channel: channel); + + final result = await service.show( + session: NativeMenuSession(), + anchor: const Rect.fromLTWH(0, 0, 100, 34), + entries: const [NativeMenuEntry(label: 'Event')], + ); + + expect(result.available, isTrue); + expect(result.selectedIndex, isNull); + }); + + test('reports unavailable when the native channel is missing', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); + const service = NativeMenuService(channel: channel); + + final result = await service.show( + session: NativeMenuSession(), + anchor: const Rect.fromLTWH(0, 0, 100, 34), + entries: const [NativeMenuEntry(label: 'Event')], + ); + + expect(result.available, isFalse); + expect(result.selectedIndex, isNull); + }); + + test('reports unavailable when the native host is unavailable', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw PlatformException(code: 'unavailable'), + ); + const service = NativeMenuService(channel: channel); + + final result = await service.show( + session: NativeMenuSession(), + anchor: const Rect.fromLTWH(0, 0, 100, 34), + entries: const [NativeMenuEntry(label: 'Event')], + ); + + expect(result.available, isFalse); + expect(result.selectedIndex, isNull); + }); + + test('surfaces native menu protocol failures', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw PlatformException(code: 'invalid-arguments'), + ); + const service = NativeMenuService(channel: channel); + + expect( + () => service.show( + session: NativeMenuSession(), + anchor: const Rect.fromLTWH(0, 0, 100, 34), + entries: const [NativeMenuEntry(label: 'Event')], + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'invalid-arguments', + ), + ), + ); + }); + + test('asks the native host to dismiss only its own menu', () async { + MethodCall? receivedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + receivedCall = call; + return true; + }); + const service = NativeMenuService(channel: channel); + final session = NativeMenuSession(); + + expect(await service.dismiss(session), isTrue); + expect(receivedCall?.method, 'dismiss'); + expect(receivedCall?.arguments, {'sessionId': session.id}); + }); + + test('dismiss is safe when native menus are unavailable', () async { + const service = NativeMenuService(channel: channel); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + messenger.setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); + expect(await service.dismiss(NativeMenuSession()), isFalse); + + messenger.setMockMethodCallHandler( + channel, + (_) async => throw PlatformException(code: 'unavailable'), + ); + expect(await service.dismiss(NativeMenuSession()), isFalse); + + messenger.setMockMethodCallHandler(channel, (_) async => null); + expect(await service.dismiss(NativeMenuSession()), isFalse); + }); +} diff --git a/test/test_localized_app.dart b/test/test_localized_app.dart index bae9532..a8e6880 100644 --- a/test/test_localized_app.dart +++ b/test/test_localized_app.dart @@ -6,20 +6,26 @@ Widget localizedTestApp({ required Widget child, Locale locale = const Locale('en'), bool? alwaysUse24HourFormat, + TextScaler? textScaler, + ThemeData? theme, }) { return MaterialApp( locale: locale, + theme: theme, localizationsDelegates: const [ ...AppLocalizations.localizationsDelegates, ...GlobalUbuntuLocalizations.delegates, ], supportedLocales: AppLocalizations.supportedLocales, - builder: alwaysUse24HourFormat == null + builder: alwaysUse24HourFormat == null && textScaler == null ? null : (context, child) { - final data = MediaQuery.of( - context, - ).copyWith(alwaysUse24HourFormat: alwaysUse24HourFormat); + final mediaQuery = MediaQuery.of(context); + final data = mediaQuery.copyWith( + alwaysUse24HourFormat: + alwaysUse24HourFormat ?? mediaQuery.alwaysUse24HourFormat, + textScaler: textScaler ?? mediaQuery.textScaler, + ); return MediaQuery(data: data, child: child ?? const SizedBox()); }, home: child, diff --git a/test/tools/install_linux_dev_desktop_test.dart b/test/tools/install_linux_dev_desktop_test.dart new file mode 100644 index 0000000..2782dcf --- /dev/null +++ b/test/tools/install_linux_dev_desktop_test.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('install_linux_dev_desktop.sh', () { + test( + 'installs an idempotent Wayland desktop registration', + () async { + final dataHome = await Directory.systemTemp.createTemp( + 'busymax-dev-desktop-test-', + ); + addTearDown(() => dataHome.delete(recursive: true)); + + final environment = { + ...Platform.environment, + 'XDG_DATA_HOME': dataHome.path, + }; + final executable = File( + 'build/linux/x64/debug/bundle/busymax', + ).absolute.path; + + for (var attempt = 0; attempt < 2; attempt++) { + final result = await Process.run('bash', [ + 'tools/install_linux_dev_desktop.sh', + '--executable', + executable, + ], environment: environment); + expect(result.exitCode, 0, reason: _processFailure(result)); + } + + final desktop = File( + '${dataHome.path}/applications/io.busystack.busymax.desktop', + ); + final icon = File( + '${dataHome.path}/icons/hicolor/scalable/apps/' + 'io.busystack.busymax.svg', + ); + expect(desktop.existsSync(), isTrue); + expect(icon.existsSync(), isTrue); + + final desktopContents = desktop.readAsStringSync(); + expect(desktopContents, contains('Exec="$executable"')); + expect(desktopContents, contains('Icon=${icon.absolute.path}')); + expect( + desktopContents, + contains('StartupWMClass=io.busystack.busymax'), + ); + expect(desktopContents, contains('X-BusyMax-Development=true')); + expect( + icon.readAsBytesSync(), + File('assets/branding/busymax-logo.svg').readAsBytesSync(), + ); + + final uninstall = await Process.run('bash', [ + 'tools/install_linux_dev_desktop.sh', + '--uninstall', + ], environment: environment); + expect(uninstall.exitCode, 0, reason: _processFailure(uninstall)); + expect(desktop.existsSync(), isFalse); + expect(icon.existsSync(), isFalse); + }, + skip: !Platform.isLinux, + ); + + test( + 'does not overwrite a desktop entry it does not own', + () async { + final dataHome = await Directory.systemTemp.createTemp( + 'busymax-dev-desktop-unowned-test-', + ); + addTearDown(() => dataHome.delete(recursive: true)); + final desktop = File( + '${dataHome.path}/applications/io.busystack.busymax.desktop', + )..createSync(recursive: true); + desktop.writeAsStringSync('[Desktop Entry]\nName=Keep me\n'); + + final result = await Process.run( + 'bash', + ['tools/install_linux_dev_desktop.sh'], + environment: { + ...Platform.environment, + 'XDG_DATA_HOME': dataHome.path, + }, + ); + + expect(result.exitCode, isNot(0)); + expect(desktop.readAsStringSync(), '[Desktop Entry]\nName=Keep me\n'); + expect(result.stderr, contains('not owned by this helper')); + }, + skip: !Platform.isLinux, + ); + }); +} + +String _processFailure(ProcessResult result) { + return 'stdout:\n${result.stdout}\nstderr:\n${result.stderr}'; +} diff --git a/tools/install_linux_dev_desktop.sh b/tools/install_linux_dev_desktop.sh new file mode 100755 index 0000000..25e0192 --- /dev/null +++ b/tools/install_linux_dev_desktop.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: tools/install_linux_dev_desktop.sh [options] + +Register this BusyMax checkout with the current user's Linux desktop so GNOME +can associate native Wayland windows with the BusyMax launcher and icon. + +Options: + --executable FILE Launcher target. Defaults to the Flutter debug bundle. + --uninstall Remove files previously installed by this helper. + -h, --help Show this help. +EOF +} + +fail() { + echo "error: $*" >&2 + exit 1 +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" +APP_ID="io.busystack.busymax" +DESKTOP_SOURCE="$PROJECT_ROOT/linux/${APP_ID}.desktop" +ICON_SOURCE="$PROJECT_ROOT/assets/branding/busymax-logo.svg" +EXECUTABLE="$PROJECT_ROOT/build/linux/x64/debug/bundle/busymax" +UNINSTALL=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --executable) + [[ $# -ge 2 ]] || fail "--executable requires a value" + EXECUTABLE="$2" + shift + ;; + --uninstall) + UNINSTALL=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac + shift +done + +[[ -f "$DESKTOP_SOURCE" ]] || fail "desktop entry not found: $DESKTOP_SOURCE" +[[ -f "$ICON_SOURCE" ]] || fail "icon not found: $ICON_SOURCE" + +if [[ "$EXECUTABLE" != /* ]]; then + EXECUTABLE="$PROJECT_ROOT/$EXECUTABLE" +fi +EXECUTABLE="$(realpath -m -- "$EXECUTABLE")" +case "$EXECUTABLE" in + *$'\n'*|*$'\r'*|*%*) + fail "executable path contains characters unsupported by desktop entries" + ;; +esac + +if [[ -n "${XDG_DATA_HOME:-}" ]]; then + DATA_HOME="$(realpath -m -- "$XDG_DATA_HOME")" +else + [[ -n "${HOME:-}" ]] || fail "HOME or XDG_DATA_HOME must be set" + DATA_HOME="$(realpath -m -- "$HOME/.local/share")" +fi + +DESKTOP_DEST="$DATA_HOME/applications/${APP_ID}.desktop" +ICON_DEST="$DATA_HOME/icons/hicolor/scalable/apps/${APP_ID}.svg" +OWNERSHIP_MARKER="X-BusyMax-Development=true" + +refresh_desktop_database() { + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$DATA_HOME/applications" + fi +} + +if [[ "$UNINSTALL" == 1 ]]; then + if [[ ! -e "$DESKTOP_DEST" && ! -L "$DESKTOP_DEST" ]]; then + echo "BusyMax development desktop entry is not installed." + exit 0 + fi + [[ ! -L "$DESKTOP_DEST" ]] || + fail "refusing to remove symbolic link: $DESKTOP_DEST" + grep -Fxq "$OWNERSHIP_MARKER" "$DESKTOP_DEST" || + fail "refusing to remove desktop entry not owned by this helper: $DESKTOP_DEST" + + rm -f -- "$DESKTOP_DEST" "$ICON_DEST" + refresh_desktop_database + echo "Removed BusyMax development desktop registration." + exit 0 +fi + +if [[ -e "$DESKTOP_DEST" || -L "$DESKTOP_DEST" ]]; then + [[ ! -L "$DESKTOP_DEST" ]] || + fail "refusing to replace symbolic link: $DESKTOP_DEST" + grep -Fxq "$OWNERSHIP_MARKER" "$DESKTOP_DEST" || + fail "refusing to replace desktop entry not owned by this helper: $DESKTOP_DEST" +fi +[[ ! -L "$ICON_DEST" ]] || + fail "refusing to replace symbolic link: $ICON_DEST" + +TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/busymax-dev-desktop.XXXXXX")" +trap 'rm -rf -- "$TEMP_DIR"' EXIT +GENERATED_DESKTOP="$TEMP_DIR/${APP_ID}.desktop" + +awk -v executable="$EXECUTABLE" -v icon="$ICON_DEST" ' + /^Exec=/ { + print "Exec=\"" executable "\"" + next + } + /^Icon=/ { + print "Icon=" icon + next + } + /^X-BusyMax-Development=/ { next } + { print } + END { print "X-BusyMax-Development=true" } +' "$DESKTOP_SOURCE" > "$GENERATED_DESKTOP" + +if command -v desktop-file-validate >/dev/null 2>&1; then + desktop-file-validate "$GENERATED_DESKTOP" +fi + +install -Dm644 "$GENERATED_DESKTOP" "$DESKTOP_DEST" +install -Dm644 "$ICON_SOURCE" "$ICON_DEST" +refresh_desktop_database + +echo "Registered BusyMax for native Wayland desktop integration:" +echo " Desktop entry: $DESKTOP_DEST" +echo " Icon: $ICON_DEST" +echo " Executable: $EXECUTABLE" +if [[ ! -x "$EXECUTABLE" ]]; then + echo "The executable does not exist yet; Flutter will create it on first run." +fi +echo "Quit all BusyMax windows and relaunch the app to refresh its dock icon."