From b5ba4bbf98dd33ac8c7567345db70e8b5efc6131 Mon Sep 17 00:00:00 2001 From: Onyeka Nwokike Date: Thu, 15 Oct 2026 21:16:07 +0100 Subject: [PATCH] Add the flet-notifications extension (#6948) `LocalNotifications` service wrapping flutter_local_notifications for Android, iOS, macOS, Windows, Linux and Web: show, schedule (zoned, daily, weekly, periodic), cancel, permissions and probes, Android channels and foreground service, rich styles (big text/picture/inbox/messaging), inline-reply actions and typed on_tap/on_action/on_dismissed handlers. The plugin needs scheduled-notification receivers and core-library-desugaring Gradle flags that [tool.flet.android.*] cannot declare, so the package ships an idempotent `flet-notifications-patch` console script for the built project. --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 1 + client/lib/main.dart | 3 + client/pubspec.yaml | 3 + .../apps/flet_build_test/pyproject.toml | 3 + .../examples/apps/flet_build_test/src/main.py | 1 + .../notifications/notifications/main.py | 473 +++++++++ .../notifications/pyproject.toml | 26 + .../packages/flet-notifications/LICENSE | 3 + .../packages/flet-notifications/README.md | 7 + .../flet-notifications/pyproject.toml | 29 + .../src/flet_notifications/__init__.py | 91 ++ .../src/flet_notifications/actions.py | 54 + .../src/flet_notifications/base.py | 87 ++ .../src/flet_notifications/channels.py | 86 ++ .../src/flet_notifications/events.py | 42 + .../flet_notifications/local_notifications.py | 612 ++++++++++++ .../src/flet_notifications/options.py | 359 +++++++ .../src/flet_notifications/patcher.py | 284 ++++++ .../src/flet_notifications/scheduling.py | 123 +++ .../src/flet_notifications/styles.py | 159 +++ .../src/flet_notifications/types.py | 53 + .../src/flutter/flet_notifications/.gitignore | 25 + .../flet_notifications/analysis_options.yaml | 1 + .../flutter/flet_notifications/pubspec.yaml | 26 + .../test/notifications_service_test.dart | 937 ++++++++++++++++++ .../flet_notifications/test/parsers_test.dart | 205 ++++ .../test/response_events_test.dart | 222 +++++ .../test/scheduling_test.dart | 121 +++ .../extensions/notifications/test_actions.py | 96 ++ .../extensions/notifications/test_channels.py | 256 +++++ .../extensions/notifications/test_events.py | 169 ++++ .../extensions/notifications/test_options.py | 184 ++++ .../extensions/notifications/test_patcher.py | 268 +++++ .../extensions/notifications/test_probes.py | 269 +++++ .../notifications/test_scheduling.py | 290 ++++++ .../extensions/notifications/test_service.py | 183 ++++ .../extensions/notifications/test_styles.py | 108 ++ sdk/python/packages/flet/pyproject.toml | 1 + sdk/python/pyproject.toml | 3 + tools/crocodocs/pyproject.toml | 1 + website/docs/services/notifications/index.md | 102 ++ .../notifications/types/activenotification.md | 7 + .../notifications/types/androidicon.md | 7 + .../notifications/types/androidoptions.md | 7 + .../types/androidschedulemode.md | 7 + .../notifications/types/bigpicturestyle.md | 7 + .../notifications/types/bigtextstyle.md | 7 + .../notifications/types/darwinoptions.md | 7 + .../notifications/types/datetimecomponents.md | 7 + .../notifications/types/groupalertbehavior.md | 7 + .../notifications/types/importance.md | 7 + .../notifications/types/inboxstyle.md | 7 + .../notifications/types/interruptionlevel.md | 7 + .../notifications/types/launchdetails.md | 7 + .../notifications/types/linuxoptions.md | 7 + .../notifications/types/linuxurgency.md | 7 + .../services/notifications/types/message.md | 7 + .../notifications/types/messagingstyle.md | 7 + .../notifications/types/notificationaction.md | 7 + .../types/notificationactioninput.md | 7 + .../types/notificationchannel.md | 7 + .../notifications/types/notificationerror.md | 7 + .../types/notificationerrorcode.md | 7 + .../notifications/types/notificationevent.md | 7 + .../types/notificationoptions.md | 7 + .../types/pendingnotification.md | 7 + .../services/notifications/types/person.md | 7 + .../services/notifications/types/priority.md | 7 + .../notifications/types/repeatinterval.md | 7 + .../notifications/types/responsetype.md | 7 + .../services/notifications/types/style.md | 7 + .../notifications/types/visibility.md | 7 + .../notifications/types/weboptions.md | 7 + .../notifications/types/windowsoptions.md | 7 + .../notifications/types/windowsscenario.md | 7 + website/sidebars.yml | 36 + 77 files changed, 6242 insertions(+) create mode 100644 sdk/python/examples/extensions/notifications/notifications/main.py create mode 100644 sdk/python/examples/extensions/notifications/notifications/pyproject.toml create mode 100644 sdk/python/packages/flet-notifications/LICENSE create mode 100644 sdk/python/packages/flet-notifications/README.md create mode 100644 sdk/python/packages/flet-notifications/pyproject.toml create mode 100644 sdk/python/packages/flet-notifications/src/flet_notifications/__init__.py create mode 100644 sdk/python/packages/flet-notifications/src/flet_notifications/actions.py create mode 100644 sdk/python/packages/flet-notifications/src/flet_notifications/base.py create mode 100644 sdk/python/packages/flet-notifications/src/flet_notifications/channels.py create mode 100644 sdk/python/packages/flet-notifications/src/flet_notifications/events.py create mode 100644 sdk/python/packages/flet-notifications/src/flet_notifications/local_notifications.py create mode 100644 sdk/python/packages/flet-notifications/src/flet_notifications/options.py create mode 100644 sdk/python/packages/flet-notifications/src/flet_notifications/patcher.py create mode 100644 sdk/python/packages/flet-notifications/src/flet_notifications/scheduling.py create mode 100644 sdk/python/packages/flet-notifications/src/flet_notifications/styles.py create mode 100644 sdk/python/packages/flet-notifications/src/flet_notifications/types.py create mode 100644 sdk/python/packages/flet-notifications/src/flutter/flet_notifications/.gitignore create mode 100644 sdk/python/packages/flet-notifications/src/flutter/flet_notifications/analysis_options.yaml create mode 100644 sdk/python/packages/flet-notifications/src/flutter/flet_notifications/pubspec.yaml create mode 100644 sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/notifications_service_test.dart create mode 100644 sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/parsers_test.dart create mode 100644 sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/response_events_test.dart create mode 100644 sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/scheduling_test.dart create mode 100644 sdk/python/packages/flet/integration_tests/extensions/notifications/test_actions.py create mode 100644 sdk/python/packages/flet/integration_tests/extensions/notifications/test_channels.py create mode 100644 sdk/python/packages/flet/integration_tests/extensions/notifications/test_events.py create mode 100644 sdk/python/packages/flet/integration_tests/extensions/notifications/test_options.py create mode 100644 sdk/python/packages/flet/integration_tests/extensions/notifications/test_patcher.py create mode 100644 sdk/python/packages/flet/integration_tests/extensions/notifications/test_probes.py create mode 100644 sdk/python/packages/flet/integration_tests/extensions/notifications/test_scheduling.py create mode 100644 sdk/python/packages/flet/integration_tests/extensions/notifications/test_service.py create mode 100644 sdk/python/packages/flet/integration_tests/extensions/notifications/test_styles.py create mode 100644 website/docs/services/notifications/index.md create mode 100644 website/docs/services/notifications/types/activenotification.md create mode 100644 website/docs/services/notifications/types/androidicon.md create mode 100644 website/docs/services/notifications/types/androidoptions.md create mode 100644 website/docs/services/notifications/types/androidschedulemode.md create mode 100644 website/docs/services/notifications/types/bigpicturestyle.md create mode 100644 website/docs/services/notifications/types/bigtextstyle.md create mode 100644 website/docs/services/notifications/types/darwinoptions.md create mode 100644 website/docs/services/notifications/types/datetimecomponents.md create mode 100644 website/docs/services/notifications/types/groupalertbehavior.md create mode 100644 website/docs/services/notifications/types/importance.md create mode 100644 website/docs/services/notifications/types/inboxstyle.md create mode 100644 website/docs/services/notifications/types/interruptionlevel.md create mode 100644 website/docs/services/notifications/types/launchdetails.md create mode 100644 website/docs/services/notifications/types/linuxoptions.md create mode 100644 website/docs/services/notifications/types/linuxurgency.md create mode 100644 website/docs/services/notifications/types/message.md create mode 100644 website/docs/services/notifications/types/messagingstyle.md create mode 100644 website/docs/services/notifications/types/notificationaction.md create mode 100644 website/docs/services/notifications/types/notificationactioninput.md create mode 100644 website/docs/services/notifications/types/notificationchannel.md create mode 100644 website/docs/services/notifications/types/notificationerror.md create mode 100644 website/docs/services/notifications/types/notificationerrorcode.md create mode 100644 website/docs/services/notifications/types/notificationevent.md create mode 100644 website/docs/services/notifications/types/notificationoptions.md create mode 100644 website/docs/services/notifications/types/pendingnotification.md create mode 100644 website/docs/services/notifications/types/person.md create mode 100644 website/docs/services/notifications/types/priority.md create mode 100644 website/docs/services/notifications/types/repeatinterval.md create mode 100644 website/docs/services/notifications/types/responsetype.md create mode 100644 website/docs/services/notifications/types/style.md create mode 100644 website/docs/services/notifications/types/visibility.md create mode 100644 website/docs/services/notifications/types/weboptions.md create mode 100644 website/docs/services/notifications/types/windowsoptions.md create mode 100644 website/docs/services/notifications/types/windowsscenario.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20c34435a9..de8e0c3d3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -796,6 +796,7 @@ jobs: flet-lottie flet-local-auth flet-map + flet-notifications flet-permission-handler flet-rive flet-secure-storage @@ -1079,6 +1080,7 @@ jobs: flet_lottie \ flet_local_auth \ flet_map \ + flet_notifications \ flet_permission_handler \ flet_rive \ flet_secure_storage \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f86b43f1..553b6b12c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ by @ada-koki. * Add the `flet-shadcn-ui` extension: controls styled after [shadcn/ui](https://ui.shadcn.com), built on the Flutter [`shadcn_ui`](https://pub.dev/packages/shadcn_ui) package, with `Theme` color schemes and the [Lucide](https://lucide.dev) icon set (`LucideIcons`). It includes `Button`, `IconButton`, `Badge`, `Alert`, `Avatar`, `Card`, `Breadcrumb`, `Separator`, `Progress`, `Input`, `Textarea`, `Checkbox`, `Switch`, `Slider`, `RadioGroup`, `Select`, `InputOTP`, `Calendar`, `DatePicker`, `DateRangePicker`, `TimePicker`, `Tabs`, `Accordion`, `ResizablePanelGroup`, `Tooltip`, `Popover`, `ContextMenu`, `Menubar`, `Table`, `Dialog`, `Sheet`, `Toast` and `Sonner`; value controls support `label`, `description` and `error_text` ([#6918](https://github.com/flet-dev/flet/pull/6918)) by @InesaFitsner. +* Add the `flet-notifications` extension: a `LocalNotifications` service that shows, schedules and cancels local notifications on Android, iOS, macOS, Windows, Linux and Web, wrapping the [`flutter_local_notifications`](https://pub.dev/packages/flutter_local_notifications) plugin, with permission requests, Android channels, rich styles, inline-reply actions and typed tap/action/dismissed handlers ([#6948](https://github.com/flet-dev/flet/issues/6948)) by @Nwokike. ### Improvements diff --git a/client/lib/main.dart b/client/lib/main.dart index 008e6b21be..98e6af7b7c 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -16,6 +16,8 @@ import 'package:flet_geolocator/flet_geolocator.dart' as flet_geolocator; import 'package:flet_lottie/flet_lottie.dart' as flet_lottie; import 'package:flet_local_auth/flet_local_auth.dart' as flet_local_auth; import 'package:flet_map/flet_map.dart' as flet_map; +import 'package:flet_notifications/flet_notifications.dart' + as flet_notifications; import 'package:flet_permission_handler/flet_permission_handler.dart' as flet_permission_handler; // --FAT_CLIENT_START-- @@ -61,6 +63,7 @@ void main([List? args]) async { flet_lottie.Extension(), flet_local_auth.Extension(), flet_map.Extension(), + flet_notifications.Extension(), flet_permission_handler.Extension(), flet_secure_storage.Extension(), flet_shadcn_ui.Extension(), diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 36165eea57..43bd7ce7c7 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -79,6 +79,9 @@ dependencies: flet_map: path: ../sdk/python/packages/flet-map/src/flutter/flet_map + flet_notifications: + path: ../sdk/python/packages/flet-notifications/src/flutter/flet_notifications + flet_permission_handler: path: ../sdk/python/packages/flet-permission-handler/src/flutter/flet_permission_handler diff --git a/sdk/python/examples/apps/flet_build_test/pyproject.toml b/sdk/python/examples/apps/flet_build_test/pyproject.toml index aabaed67f5..b223b6aae9 100644 --- a/sdk/python/examples/apps/flet_build_test/pyproject.toml +++ b/sdk/python/examples/apps/flet_build_test/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "flet-lottie", "flet-local-auth", "flet-map", + "flet-notifications", "flet-permission-handler", "flet-rive", "flet-secure-storage", @@ -56,6 +57,7 @@ flet-geolocator = { path = "../../../packages/flet-geolocator", editable = true flet-lottie = { path = "../../../packages/flet-lottie", editable = true } flet-local-auth = { path = "../../../packages/flet-local-auth", editable = true } flet-map = { path = "../../../packages/flet-map", editable = true } +flet-notifications = { path = "../../../packages/flet-notifications", editable = true } flet-permission-handler = { path = "../../../packages/flet-permission-handler", editable = true } flet-rive = { path = "../../../packages/flet-rive", editable = true } flet-secure-storage = { path = "../../../packages/flet-secure-storage", editable = true } @@ -79,6 +81,7 @@ flet-geolocator = "../../../packages/flet-geolocator" flet-lottie = "../../../packages/flet-lottie" flet-local-auth = "../../../packages/flet-local-auth" flet-map = "../../../packages/flet-map" +flet-notifications = "../../../packages/flet-notifications" flet-permission-handler = "../../../packages/flet-permission-handler" flet-rive = "../../../packages/flet-rive" flet-secure-storage = "../../../packages/flet-secure-storage" diff --git a/sdk/python/examples/apps/flet_build_test/src/main.py b/sdk/python/examples/apps/flet_build_test/src/main.py index 63c35edd28..e628ee6d67 100644 --- a/sdk/python/examples/apps/flet_build_test/src/main.py +++ b/sdk/python/examples/apps/flet_build_test/src/main.py @@ -18,6 +18,7 @@ import flet_geolocator # noqa: F401 import flet_lottie # noqa: F401 import flet_map # noqa: F401 +import flet_notifications # noqa: F401 import flet_permission_handler # noqa: F401 import flet_rive # noqa: F401 import flet_secure_storage # noqa: F401 diff --git a/sdk/python/examples/extensions/notifications/notifications/main.py b/sdk/python/examples/extensions/notifications/notifications/main.py new file mode 100644 index 0000000000..6955d8bf91 --- /dev/null +++ b/sdk/python/examples/extensions/notifications/notifications/main.py @@ -0,0 +1,473 @@ +"""Example app for flet-notifications — every API, platform-aware. + +House example pattern (adaptive AppBar, scrollable page, activity terminal) +plus capability gating: only the features this platform can actually run are +enabled; the rest are listed with the platforms that support them, because +developers should see everything that is possible. +""" + +import asyncio +import contextlib +import datetime + +import flet as ft +from flet_notifications import ( + AndroidOptions, + AndroidScheduleMode, + BigTextStyle, + DarwinOptions, + DateTimeComponents, + InboxStyle, + LocalNotifications, + NotificationAction, + NotificationActionInput, + NotificationChannel, + NotificationOptions, + RepeatInterval, +) + +# Activity terminal — house pattern (Courier New, #A6E22E on #0D0D0D). +TERMINAL_BG = "#0D0D0D" +TERMINAL_TEXT = "#A6E22E" + +# Capability model: which features each platform supports. Anything absent +# here is available everywhere. +# Android TV runs the Android plugin (Flutter dispatches the Android +# implementation), so it gets the full Android feature set. +ANDROID_ONLY = {ft.PagePlatform.ANDROID, ft.PagePlatform.ANDROID_TV} +SCHEDULED = { + ft.PagePlatform.ANDROID, + ft.PagePlatform.ANDROID_TV, + ft.PagePlatform.IOS, + ft.PagePlatform.MACOS, +} +REQUIRED = { + "schedule_oneshot": SCHEDULED, # Linux/Web have no OS scheduler + "schedule_recurring": SCHEDULED, + "periodic": SCHEDULED, + "cancel_pending": SCHEDULED, + "exact_alarms": ANDROID_ONLY, # AlarmManager alarms on Android + "channels": ANDROID_ONLY, # Notification channels are Android 8+ + "channel_groups": ANDROID_ONLY, + "foreground": ANDROID_ONLY, # Android foreground service + "fullscreen_intent": ANDROID_ONLY, # Android 14+ full-screen intents + "dnd": ANDROID_ONLY, # notification policy access +} +REASON = { + "schedule_oneshot": "Android/iOS/macOS only — Linux/Web have no OS scheduler", + "schedule_recurring": "Android/iOS/macOS only — Linux/Web have no OS scheduler", + "periodic": "Android/iOS/macOS only — Linux/Web have no OS scheduler", + "cancel_pending": "Android/iOS/macOS only — Linux/Web have no OS scheduler", + "exact_alarms": "Android only", + "channels": "Android only (channels are an Android 8+ concept)", + "channel_groups": "Android only", + "foreground": "Android only", + "fullscreen_intent": "Android only (14+)", + "dnd": "Android only", +} + + +def main(page: ft.Page): + page.appbar = ft.AppBar(adaptive=True, title="flet-notifications Example") + page.scroll = ft.ScrollMode.AUTO + page.padding = 16 + + notifications = LocalNotifications() + + log_text = ft.Text( + value="", + font_family="Courier New", + size=12, + color=TERMINAL_TEXT, + selectable=True, + ) + log_col = ft.Column(controls=[log_text], scroll=ft.ScrollMode.AUTO, expand=True) + log_terminal = ft.Container( + content=log_col, + bgcolor=TERMINAL_BG, + padding=12, + border=ft.Border.all(1, ft.Colors.with_opacity(0.15, ft.Colors.WHITE)), + border_radius=8, + height=320, + ) + + def log_line(message: str): + ts = datetime.datetime.now().strftime("%H:%M:%S") + log_text.value = ( + f"{log_text.value}\n[{ts}] {message}" + if log_text.value + else f"[{ts}] {message}" + ) + page.update(log_text) + + async def _scroll_to_end(): + await asyncio.sleep(0.05) + with contextlib.suppress(Exception): + await log_col.scroll_to(offset=-1) # offset=-1: jump to newest + + page.run_task(_scroll_to_end) + + # Plain `flet run` uses the prebuilt client without this extension's + # Dart half; invokes time out. Reported separately from calls this + # platform genuinely doesn't implement. + missing_backend = {"warned": False} + + def warn_no_backend(method: str): + if missing_backend["warned"]: + return + missing_backend["warned"] = True + log_line(f"{method}: no notification backend in this runner.") + log_line("Run a built app instead: a release artifact, or") + log_line(" uv run flet build windows|macos|linux|apk") + + def report_error(method: str, exc: Exception): + """Classify: unsupported on this platform vs no backend at all.""" + if getattr(exc, "code", "") == "unsupported_platform": + msg = getattr(exc, "message", "") or "unsupported" + log_line(f"{method}: not available on this platform ({msg})") + return + warn_no_backend(method) + + def guarded(coro, method: str): + async def _run(): + try: + await coro + except Exception as exc: # noqa: BLE001 - classify and degrade + report_error(method, exc) + + page.run_task(_run) + + def guarded_async(make_coro, method: str): + """For handlers that build their coroutine inside a task.""" + + async def _run(): + try: + await make_coro() + except Exception as exc: # noqa: BLE001 + report_error(method, exc) + + page.run_task(_run) + + # ── Events ── + notifications.on_tap = lambda e: log_line(f"tap: id={e.id} payload={e.payload!r}") + notifications.on_action = lambda e: log_line( + f"action: {e.action_id!r} payload={e.payload!r} input={e.input!r}" + ) + notifications.on_dismissed = lambda e: log_line(f"dismissed: id={e.id}") + + # ── Handlers ── + def show_plain(e): + guarded( + notifications.show( + id=1, title="Hello", body="A plain notification.", payload="plain" + ), + "show", + ) + + def show_actions(e): + guarded( + notifications.show( + id=2, + title="Action buttons", + body="Tap a button — the reply field returns text.", + actions=[ + NotificationAction(id="open", title="Open"), + NotificationAction( + id="reply", + title="Reply", + foreground=False, + input=NotificationActionInput(label="Type a reply"), + ), + ], + ), + "show", + ) + + def show_big_text(e): + guarded( + notifications.show( + id=5, + title="Long read", + body="Tap to expand.", + style=BigTextStyle( + big_text="The quick brown fox jumps over the lazy dog. " * 3 + ), + ), + "show", + ) + + def show_inbox(e): + guarded( + notifications.show( + id=6, + title="Inbox", + body="3 unread.", + style=InboxStyle(lines=["First", "Second", "Third"]), + ), + "show", + ) + + def show_progress(e): + guarded( + notifications.show( + id=7, + title="Downloading", + body="0%", + show_progress=True, + max_progress=4, + progress=1, + ), + "show", + ) + + def show_grouped(e): + guarded( + notifications.show( + id=8, + title="Summary", + body="Group summary.", + group_key="demo_group", + set_as_group_summary=True, + ), + "show", + ) + + def show_options(e): + guarded( + notifications.show( + id=9, + title="Silent alarm", + body="Full platform options.", + options=NotificationOptions( + android=AndroidOptions( + silent=True, category="alarm", visibility="public" + ), + iOS=DarwinOptions(interruption_level="passive"), + ), + ), + "show", + ) + + def request_permissions(e): + async def _req(): + granted = await notifications.request_permissions() + log_line(f"permissions granted: {granted}") + + guarded_async(_req, "request_permissions") + + def schedule_delayed(e): + guarded( + notifications.schedule( + id=3, + when=datetime.timedelta(seconds=10), + title="Delayed", + body="10 seconds after you tapped.", + ), + "schedule", + ) + + def schedule_daily(e): + guarded( + notifications.schedule( + id=4, + when=(datetime.datetime.now() + datetime.timedelta(days=1)).replace( + hour=8, minute=30, second=0, microsecond=0 + ), + title="Daily reminder", + body="Every day at 08:30.", + match_components=DateTimeComponents.TIME, + schedule_mode=AndroidScheduleMode.EXACT_ALLOW_WHILE_IDLE, + ), + "schedule", + ) + + def schedule_hourly(e): + guarded( + notifications.periodically_show( + id=10, + repeat_interval=RepeatInterval.HOURLY, + title="Hourly", + body="An hourly notification.", + ), + "periodically_show", + ) + + def list_pending(e): + async def _list(): + pending = await notifications.get_pending_notifications() + log_line(f"pending: {[p.id for p in pending]}") + + guarded_async(_list, "get_pending_notifications") + + def cancel_pending(e): + guarded(notifications.cancel_pending(), "cancel_pending") + + def check_exact(e): + async def _check(): + allowed = await notifications.can_schedule_exact_notifications() + log_line(f"exact alarms allowed: {allowed}") + if not allowed: + await notifications.request_exact_alarm_permission() + + guarded_async(_check, "can_schedule_exact_notifications") + + def create_channel(e): + guarded( + notifications.create_notification_channel( + NotificationChannel( + id="alerts", name="Alerts", description="High-priority alerts" + ) + ), + "create_notification_channel", + ) + + def list_channels(e): + async def _list(): + channels = await notifications.get_notification_channels() + log_line(f"channels: {[c.id for c in channels]}") + + guarded_async(_list, "get_notification_channels") + + def start_fgs(e): + guarded( + notifications.start_foreground_service( + id=11, title="Foreground service", body="Work in progress…" + ), + "start_foreground_service", + ) + + def stop_fgs(e): + guarded(notifications.stop_foreground_service(), "stop_foreground_service") + + def check_enabled(e): + async def _check(): + log_line(f"enabled: {await notifications.are_notifications_enabled()}") + + guarded_async(_check, "are_notifications_enabled") + + def open_settings(e): + guarded( + notifications.open_app_notification_settings(), + "open_app_notification_settings", + ) + + def list_active(e): + async def _list(): + active = await notifications.get_active_notifications() + log_line(f"active: {[a.id for a in active]}") + + guarded_async(_list, "get_active_notifications") + + def check_launch(e): + async def _check(): + details = await notifications.get_notification_app_launch_details() + log_line(f"launched by notification: {details.did_notification_launch_app}") + + guarded_async(_check, "get_notification_app_launch_details") + + def check_policy(e): + async def _check(): + granted = await notifications.has_notification_policy_access() + log_line(f"DND access: {granted}") + if not granted: + await notifications.request_notification_policy_access() + + guarded_async(_check, "has_notification_policy_access") + + def channel_groups(e): + guarded( + notifications.create_notification_channel_group("demo", "Demo group"), + "create_notification_channel_group", + ) + + # ── Feature catalog ────────────────────────────────────────────────── + # (key, label, handler). The key decides platform availability. + FEATURES = [ + ("all", "Show", show_plain), + ("all", "Show with actions", show_actions), + ("all", "Request permissions", request_permissions), + ("all", "Big text style", show_big_text), + ("all", "Inbox style", show_inbox), + ("all", "Progress", show_progress), + ("all", "Grouped", show_grouped), + ("all", "Options (per-platform)", show_options), + ("all", "Are enabled", check_enabled), + ("all", "Open settings", open_settings), + ("all", "Active list", list_active), + ("all", "Launch details", check_launch), + ("schedule_oneshot", "Schedule in 10s", schedule_delayed), + ("schedule_recurring", "Schedule daily 08:30", schedule_daily), + ("periodic", "Schedule hourly", schedule_hourly), + ("cancel_pending", "List pending", list_pending), + ("cancel_pending", "Cancel pending", cancel_pending), + ("exact_alarms", "Check exact alarms", check_exact), + ("channels", "Create channel", create_channel), + ("channels", "List channels", list_channels), + ("foreground", "Start foreground service", start_fgs), + ("foreground", "Stop foreground service", stop_fgs), + ("dnd", "DND access", check_policy), + ("channel_groups", "Channel group", channel_groups), + ] + + def supported(key: str) -> bool: + required = REQUIRED.get(key) + return required is None or page.platform in required + + available = [f for f in FEATURES if supported(f[0])] + unavailable = [f for f in FEATURES if not supported(f[0])] + + # ── Layout: available features, then a revealable "elsewhere" section ── + show_unavailable = {"on": False} + + def rebuild(): + summary.value = ( + f"{page.platform.name}: {len(available)} of {len(FEATURES)} " + f"features available here" + ) + controls = [ + summary, + ft.Text("Available on this platform", weight=ft.FontWeight.BOLD), + ] + for _key, label, handler in available: + controls.append(ft.OutlinedButton(content=label, on_click=handler)) + toggle.content = ( + f"Show {len(unavailable)} features not available on this platform" + if not show_unavailable["on"] + else "Hide unavailable features" + ) + controls.append(toggle) + if show_unavailable["on"]: + controls.append( + ft.Text("Available on other platforms", weight=ft.FontWeight.BOLD) + ) + for key, label, _ in unavailable: + controls.append( + ft.OutlinedButton( + content=f"{label} — {REASON.get(key, 'other platforms')}", + disabled=True, + ) + ) + controls.append(ft.Divider()) + controls.append(ft.Text("Event log", weight=ft.FontWeight.BOLD)) + controls.append(log_terminal) + page.controls[0].controls = controls + page.update(page.controls[0]) + + def toggle_unavailable(e): + show_unavailable["on"] = not show_unavailable["on"] + rebuild() + + toggle = ft.OutlinedButton(content="", on_click=toggle_unavailable) + summary = ft.Text("", size=13) + page.add(ft.Column(controls=[], spacing=8)) + rebuild() + log_line(f"Running on {page.platform.name} — {len(available)} features available") + if unavailable: + log_line( + f"{len(unavailable)} features exist on other platforms " + "(see below the buttons)" + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/extensions/notifications/notifications/pyproject.toml b/sdk/python/examples/extensions/notifications/notifications/pyproject.toml new file mode 100644 index 0000000000..167bfc4943 --- /dev/null +++ b/sdk/python/examples/extensions/notifications/notifications/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "notifications" +version = "1.0.0" +description = "Shows, schedules and cancels local notifications, with channels, actions and typed tap handlers." +requires-python = ">=3.10" +keywords = ["services", "notifications", "scheduling", "channels", "actions", "async"] +authors = [{ name = "Flet team", email = "hello@flet.dev" }] +dependencies = ["flet", "flet-notifications"] + +[dependency-groups] +dev = ["flet-cli", "flet-desktop", "flet-web"] + +[tool.flet.gallery] +categories = ["Utility/Notifications"] + +[tool.flet.metadata] +title = "Notifications" +controls = ["Page", "AppBar", "Column", "Container", "Divider", "OutlinedButton", "Text", "LocalNotifications"] +layout_pattern = "control-panel" +complexity = "advanced" +features = ["local notifications", "scheduling", "channels", "actions", "typed tap handlers"] + +[tool.flet] +org = "dev.flet" +company = "Flet" +copyright = "Copyright (C) 2023-2026 by Flet" diff --git a/sdk/python/packages/flet-notifications/LICENSE b/sdk/python/packages/flet-notifications/LICENSE new file mode 100644 index 0000000000..f6b0638846 --- /dev/null +++ b/sdk/python/packages/flet-notifications/LICENSE @@ -0,0 +1,3 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ diff --git a/sdk/python/packages/flet-notifications/README.md b/sdk/python/packages/flet-notifications/README.md new file mode 100644 index 0000000000..e63af307a5 --- /dev/null +++ b/sdk/python/packages/flet-notifications/README.md @@ -0,0 +1,7 @@ +# Flet Notifications + +`LocalNotifications` service for Flet apps. Show, schedule and cancel system notifications on Android, iOS, macOS, Windows, Linux and Web via the [`flutter_local_notifications`](https://pub.dev/packages/flutter_local_notifications) Flutter plugin. + +> **[Try Flet online in Flet Studio](https://studio.flet.dev)** +> +> Prototype your Flet app interface in the browser, then add local notifications in your local project. Write your own Python code or get help from the AI agent. diff --git a/sdk/python/packages/flet-notifications/pyproject.toml b/sdk/python/packages/flet-notifications/pyproject.toml new file mode 100644 index 0000000000..f1af688b8c --- /dev/null +++ b/sdk/python/packages/flet-notifications/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "flet-notifications" +version = "0.1.0" +description = "Local notifications service for Flet" +authors = [{name = "Appveyor Systems Inc.", email = "hello@flet.dev"}] +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flet", +] + +[project.urls] +Homepage = "https://flet.dev" +Documentation = "https://flet.dev/docs/services/notifications" +Repository = "https://github.com/flet-dev/flet/tree/main/sdk/python/packages/flet-notifications" +Issues = "https://github.com/flet-dev/flet/issues" + +[project.scripts] +flet-notifications-patch = "flet_notifications.patcher:main" + +[tool.setuptools.package-data] +"flutter.flet_notifications" = [ + "pubspec.yaml", + "lib/**", +] + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" diff --git a/sdk/python/packages/flet-notifications/src/flet_notifications/__init__.py b/sdk/python/packages/flet-notifications/src/flet_notifications/__init__.py new file mode 100644 index 0000000000..75adb2a885 --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flet_notifications/__init__.py @@ -0,0 +1,91 @@ +"""flet-notifications: cross-platform local notifications for Flet apps. + +Wraps the battle-tested ``flutter_local_notifications`` Flutter plugin and +exposes it as a native Flet service (``ft.Service``), following the same +extension architecture as ``flet-ads`` / ``flet-yandex-ads``. +""" + +from flet_notifications.actions import NotificationAction, NotificationActionInput +from flet_notifications.base import ( + NotificationError, + NotificationErrorCode, + NotificationEvent, + ResponseType, +) +from flet_notifications.channels import NotificationChannel +from flet_notifications.events import ( + ActiveNotification, + LaunchDetails, + PendingNotification, +) +from flet_notifications.local_notifications import LocalNotifications +from flet_notifications.options import ( + AndroidOptions, + DarwinOptions, + InterruptionLevel, + LinuxOptions, + LinuxUrgency, + NotificationOptions, + Priority, + Visibility, + WebOptions, + WindowsOptions, + WindowsScenario, +) +from flet_notifications.styles import ( + AndroidIcon, + BigPictureStyle, + BigTextStyle, + InboxStyle, + Message, + MessagingStyle, + Person, + Style, +) +from flet_notifications.types import ( + AndroidScheduleMode, + DateTimeComponents, + GroupAlertBehavior, + Importance, + RepeatInterval, +) + +__version__ = "0.1.4" + +__all__ = [ + "ActiveNotification", + "AndroidIcon", + "AndroidOptions", + "AndroidScheduleMode", + "BigPictureStyle", + "BigTextStyle", + "DarwinOptions", + "DateTimeComponents", + "GroupAlertBehavior", + "Importance", + "InboxStyle", + "InterruptionLevel", + "LaunchDetails", + "LinuxOptions", + "LinuxUrgency", + "LocalNotifications", + "Message", + "MessagingStyle", + "NotificationAction", + "NotificationActionInput", + "NotificationChannel", + "NotificationError", + "NotificationErrorCode", + "NotificationEvent", + "NotificationOptions", + "PendingNotification", + "Person", + "Priority", + "RepeatInterval", + "ResponseType", + "Style", + "Visibility", + "WebOptions", + "WindowsOptions", + "WindowsScenario", +] diff --git a/sdk/python/packages/flet-notifications/src/flet_notifications/actions.py b/sdk/python/packages/flet-notifications/src/flet_notifications/actions.py new file mode 100644 index 0000000000..5fc5333f75 --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flet_notifications/actions.py @@ -0,0 +1,54 @@ +"""Action buttons and inline-reply inputs for notifications.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(kw_only=True) +class NotificationActionInput: + """Inline-reply input attached to a notification action. + + Maps to ``AndroidNotificationActionInput`` on Android, the text variant of + ``DarwinNotificationAction`` on iOS/macOS and ``WindowsInput`` on Windows. + """ + + label: str = "" # placeholder / button title over the text field + choices: list[str] = field(default_factory=list) # quick-reply suggestions + allow_free_form_input: bool = True + allowed_mime_types: list[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "label": self.label, + "choices": list(self.choices), + "allow_free_form_input": self.allow_free_form_input, + "allowed_mime_types": list(self.allowed_mime_types), + } + + +@dataclass(kw_only=True) +class NotificationAction: + """A button shown on a notification. + + When the button is pressed, ``on_action`` fires with ``action_id == id``. + If ``input`` is set, the OS shows an inline-reply field and the typed text + arrives as ``NotificationEvent.input``. + """ + + id: str + title: str + destructive: bool = False # iOS/macOS: render as destructive + foreground: bool = True # bring the app to the foreground on press + cancel_notification: bool = True # Android: dismiss the notification + input: NotificationActionInput | None = None + + def to_dict(self) -> dict: + return { + "id": self.id, + "title": self.title, + "destructive": self.destructive, + "foreground": self.foreground, + "cancel_notification": self.cancel_notification, + "input": self.input.to_dict() if self.input else None, + } diff --git a/sdk/python/packages/flet-notifications/src/flet_notifications/base.py b/sdk/python/packages/flet-notifications/src/flet_notifications/base.py new file mode 100644 index 0000000000..a2ce768540 --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flet_notifications/base.py @@ -0,0 +1,87 @@ +"""Shared types and helpers for flet-notifications.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +import flet as ft + + +class ResponseType(str, Enum): + """Type of a notification response delivered from the OS to the app.""" + + TAP = "tap" + ACTION = "action" + DISMISSED = "dismissed" + + +@dataclass +class NotificationEvent(ft.Event[ft.EventControlType]): + """A notification tap, action-button press or dismissal. + + Delivered to ``on_tap`` / ``on_action`` / ``on_dismissed`` handlers. + """ + + id: int = 0 + action_id: str = "" # "" for a plain tap; the button id for actions + payload: str = "" + input: str = "" # inline-reply text, when the action had an input + response_type: str = ResponseType.TAP.value + # Windows action inputs arrive in the inherited `data` field, keyed by + # the input id (Windows leaves `input` null). + + +class NotificationErrorCode: + """Stable machine-readable error codes raised by the service.""" + + UNSUPPORTED_PLATFORM = "unsupported_platform" + INIT_FAILED = "init_failed" + SHOW_FAILED = "show_failed" + SCHEDULE_FAILED = "schedule_failed" + INVALID_ACTION = "invalid_action" + INVALID_SCHEDULE = "invalid_schedule" + INVALID_STYLE = "invalid_style" + INVALID_CHANNEL = "invalid_channel" + INVALID_SERVICE_ID = "invalid_service_id" + INVALID_OPTION = "invalid_option" + CHANNEL_FAILED = "channel_failed" + FOREGROUND_SERVICE_FAILED = "foreground_service_failed" + QUERY_FAILED = "query_failed" + PERMISSION_FAILED = "permission_failed" + UNKNOWN_METHOD = "unknown_method" + + +class NotificationError(Exception): + """Raised when a notification operation fails. + + ``code`` is a stable machine-readable identifier — see + :class:`NotificationErrorCode` for the full list. + """ + + def __init__(self, code: str, message: str = "") -> None: + self.code = code + self.message = message + super().__init__(f"{code}: {message}" if message else code) + + def __repr__(self) -> str: + return f"NotificationError(code={self.code!r}, message={self.message!r})" + + +def check_result(result: object) -> None: + """Raise :class:`NotificationError` if a Dart invoke returned an error string. + + Dart service methods return ``"error::"`` on failure. + """ + if isinstance(result, str) and result.startswith("error:"): + _, _, rest = result.partition(":") + code, _, message = rest.partition(":") + raise NotificationError(code.strip(), message.strip()) + + +def to_bool(result: object) -> bool: + """Coerce a Dart invoke result to bool (``"true"``/``"false"``).""" + check_result(result) + if isinstance(result, bool): + return result + return str(result).strip().lower() == "true" diff --git a/sdk/python/packages/flet-notifications/src/flet_notifications/channels.py b/sdk/python/packages/flet-notifications/src/flet_notifications/channels.py new file mode 100644 index 0000000000..526e5bb6a3 --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flet_notifications/channels.py @@ -0,0 +1,86 @@ +"""Android notification channels.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from flet_notifications.types import Importance + + +@dataclass(kw_only=True) +class NotificationChannel: + """An Android notification channel (required since Android 8.0). + + Sound and vibration are fixed at channel creation; to change them you + create a new channel with a new id. + """ + + id: str + name: str + description: str = "" + group_id: str | None = None + importance: Importance = Importance.DEFAULT + bypass_dnd: bool = False + play_sound: bool = True + sound: str | None = None # raw resource name, e.g. "alert" + enable_vibration: bool = True + vibration_pattern: list[int] | None = None + show_badge: bool = True + enable_lights: bool = False + led_color: str | None = None # #RRGGBB + + def __post_init__(self) -> None: + if not self.id: + raise ValueError("channel id is required") + if not self.name: + raise ValueError("channel name is required") + if self.led_color is not None: + self.led_color = _validate_hex(self.led_color) + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "description": self.description, + "group_id": self.group_id, + "importance": self.importance.value, + "bypass_dnd": self.bypass_dnd, + "play_sound": self.play_sound, + "sound": self.sound, + "enable_vibration": self.enable_vibration, + "vibration_pattern": ( + list(self.vibration_pattern) if self.vibration_pattern else None + ), + "show_badge": self.show_badge, + "enable_lights": self.enable_lights, + "led_color": self.led_color, + } + + @classmethod + def from_dict(cls, data: dict) -> NotificationChannel: + return cls( + id=str(data.get("id", "")), + name=str(data.get("name", "")), + description=str(data.get("description") or ""), + group_id=data.get("groupId") or data.get("group_id"), + importance=Importance(data.get("importance", "defaultImportance")), + bypass_dnd=bool(data.get("bypass_dnd", False)), + play_sound=bool(data.get("play_sound", True)), + sound=data.get("sound"), + enable_vibration=bool(data.get("enable_vibration", True)), + vibration_pattern=data.get("vibration_pattern"), + show_badge=bool(data.get("show_badge", True)), + enable_lights=bool(data.get("enable_lights", False)), + led_color=data.get("led_color"), + ) + + +def _validate_hex(value: str) -> str: + v = value.strip() + if not v.startswith("#") or len(v) not in (7, 9): + raise ValueError(f"invalid hex color: {value!r}") + try: + int(v[1:], 16) + except ValueError as e: + raise ValueError(f"invalid hex color: {value!r}") from e + return v diff --git a/sdk/python/packages/flet-notifications/src/flet_notifications/events.py b/sdk/python/packages/flet-notifications/src/flet_notifications/events.py new file mode 100644 index 0000000000..71c47fab64 --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flet_notifications/events.py @@ -0,0 +1,42 @@ +"""Event payload types delivered from the Dart side.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from flet_notifications.base import NotificationEvent + + +@dataclass +class PendingNotification: + """A scheduled notification that has not been delivered yet. + + Returned by :meth:`LocalNotifications.get_pending_notifications`. + """ + + id: int + title: str = "" + body: str = "" + payload: str = "" + + +@dataclass +class ActiveNotification: + """A notification currently shown in the notification tray.""" + + id: int = 0 + channel_id: str = "" + group_key: str = "" + tag: str = "" + title: str = "" + body: str = "" + payload: str = "" + big_text: str = "" + + +@dataclass +class LaunchDetails: + """Why/how the app was launched, if a notification launched it.""" + + did_notification_launch_app: bool = False + notification: NotificationEvent | None = None diff --git a/sdk/python/packages/flet-notifications/src/flet_notifications/local_notifications.py b/sdk/python/packages/flet-notifications/src/flet_notifications/local_notifications.py new file mode 100644 index 0000000000..2bfc32dffe --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flet_notifications/local_notifications.py @@ -0,0 +1,612 @@ +"""The LocalNotifications service: cross-platform notifications for Flet.""" + +# NOTE: deliberately no ``from __future__ import annotations`` — Flet's event +# resolver (``get_event_field_type``) evaluates class-level handler +# annotations as real objects and raises on PEP 563 strings, which would +# silently break on_tap/on_action/on_dismissed dispatch. + +import json +from datetime import datetime, timedelta +from typing import Optional + +import flet as ft +from flet.utils import from_dict +from flet_notifications.actions import NotificationAction +from flet_notifications.base import ( + NotificationError, + NotificationEvent, + check_result, + to_bool, +) +from flet_notifications.channels import NotificationChannel +from flet_notifications.events import ( + ActiveNotification, + LaunchDetails, + PendingNotification, +) +from flet_notifications.options import NotificationOptions +from flet_notifications.scheduling import ( + build_periodic_args, + build_schedule_args, +) +from flet_notifications.styles import Style +from flet_notifications.types import ( + AndroidScheduleMode, + DateTimeComponents, + GroupAlertBehavior, + RepeatInterval, +) + + +@ft.control("LocalNotifications") +class LocalNotifications(ft.Service): + """Cross-platform local notifications (Android, iOS, macOS, Windows, + Linux, Web). + + A headless service — instantiate it once and it registers itself with the + page. No need to add it to the page's controls. + + Example: + .. code-block:: python + + import flet as ft + from flet_notifications import LocalNotifications + + + async def main(page: ft.Page): + notifications = LocalNotifications() + + async def on_tap(e): + print("tapped:", e.payload) + + notifications.on_tap = on_tap + + if await notifications.request_permissions(): + await notifications.show( + id=1, title="Hello", body="from flet-notifications" + ) + + + ft.run(main) + """ + + default_icon: Optional[str] = None + """Android default small icon drawable name (default: the app launcher icon).""" + + request_alert_permission: bool = True + """iOS/macOS: request the alert permission during initialization.""" + + request_sound_permission: bool = True + """iOS/macOS: request the sound permission during initialization.""" + + request_badge_permission: bool = True + """iOS/macOS: request the badge permission during initialization.""" + + request_critical_permission: bool = False + """iOS/macOS: request the critical-alert permission (needs an Apple entitlement).""" + + app_name: Optional[str] = None + """Windows: application name shown on toasts.""" + + app_user_model_id: Optional[str] = None + """Windows: AppUserModelID used to group toasts under the app.""" + + windows_guid: Optional[str] = None + """Windows: GUID identifying the toast notifier.""" + + on_tap: Optional[ft.EventHandler[NotificationEvent["LocalNotifications"]]] = None + """Fired when the notification body is tapped.""" + + on_action: Optional[ft.EventHandler[NotificationEvent["LocalNotifications"]]] = None + """Fired when an action button is pressed (``action_id`` names the button).""" + + on_dismissed: Optional[ft.EventHandler[NotificationEvent["LocalNotifications"]]] = ( + None + ) + """Fired when the OS reports the notification was dismissed/cleared.""" + + # ── Shared kwargs for show / schedule / periodically_show ──────────── + + @staticmethod + def _details_args( + style: Optional[Style], + show_progress: bool, + max_progress: int, + progress: int, + indeterminate: bool, + group_key: Optional[str], + set_as_group_summary: bool, + group_alert_behavior: GroupAlertBehavior, + channel_id: Optional[str], + options: Optional[NotificationOptions] = None, + ) -> dict: + """Collect the style/progress/grouping kwargs (unserialized).""" + return { + "style": style, + "options": options, + "show_progress": show_progress, + "max_progress": max_progress, + "progress": progress, + "indeterminate": indeterminate, + "group_key": group_key, + "set_as_group_summary": set_as_group_summary, + "group_alert_behavior": group_alert_behavior, + "channel_id": channel_id, + } + + @staticmethod + def _serialize_details(details: dict) -> dict: + """Convert details kwargs to JSON-ready wire form (for ``show``).""" + out = dict(details) + out["style"] = details["style"].to_dict() if details["style"] else None + out["options"] = details["options"].to_dict() if details["options"] else None + out["group_alert_behavior"] = details["group_alert_behavior"].value + return out + + async def initialize(self) -> None: + """Initialize the notification backend. + + Initialization happens automatically when the service is created; + call this only to force a re-initialization. + """ + check_result(await self._invoke_method("initialize")) + + async def show( + self, + id: int, + title: Optional[str] = None, + body: Optional[str] = None, + payload: str = "", + actions: Optional[list[NotificationAction]] = None, + style: Optional[Style] = None, + show_progress: bool = False, + max_progress: int = 0, + progress: int = 0, + indeterminate: bool = False, + group_key: Optional[str] = None, + set_as_group_summary: bool = False, + group_alert_behavior: GroupAlertBehavior = GroupAlertBehavior.ALL, + channel_id: Optional[str] = None, + options: Optional[NotificationOptions] = None, + ) -> None: + """Show a notification immediately. + + Args: + id: Notification id; showing again with the same id updates it — + this is how live progress bars are advanced. + title: Notification title. + body: Notification body text. + payload: Opaque string handed back on tap (e.g. a deep link). + actions: Action buttons shown on the notification. + style: Rich style (big text / big picture / inbox / messaging). + show_progress: Show a determinate (or indeterminate) progress bar. + group_key: Group related notifications together. + set_as_group_summary: Use this notification as the group summary. + group_alert_behavior: Which notifications in the group alert. + channel_id: Android channel to post on (created if missing). + """ + check_result( + await self._invoke_method( + "show", + { + "id": id, + "title": title, + "body": body, + "payload": payload, + "actions": [a.to_dict() for a in actions] if actions else None, + **self._serialize_details( + self._details_args( + style, + show_progress, + max_progress, + progress, + indeterminate, + group_key, + set_as_group_summary, + group_alert_behavior, + channel_id, + options, + ) + ), + }, + ) + ) + + async def cancel(self, id: int, tag: Optional[str] = None) -> None: + """Cancel a previously shown or scheduled notification. + + Args: + id: Notification id. + tag: Android notification tag (for grouped notifications posted + with a tag). + """ + check_result( + await self._invoke_method( + "cancel", {"id": id, "tag": tag} if tag else {"id": id} + ) + ) + + async def cancel_all(self) -> None: + """Cancel all shown notifications.""" + check_result(await self._invoke_method("cancel_all")) + + async def request_permissions(self) -> bool: + """Request the OS notification permission. + + On platforms where permission is implicit (Windows, Linux, Web-before- + prompt) this returns the current status. Windows raises the + browser prompt; Android 13+ shows the runtime dialog. + """ + # Permission dialogs can take a while; allow more than the default. + return to_bool(await self._invoke_method("request_permissions", timeout=300)) + + async def schedule( + self, + id: int, + when: datetime | timedelta, + title: Optional[str] = None, + body: Optional[str] = None, + payload: str = "", + actions: Optional[list[NotificationAction]] = None, + match_components: Optional[DateTimeComponents] = None, + options: Optional[NotificationOptions] = None, + schedule_mode: AndroidScheduleMode = AndroidScheduleMode.EXACT_ALLOW_WHILE_IDLE, + time_zone: Optional[str] = None, + style: Optional[Style] = None, + show_progress: bool = False, + max_progress: int = 0, + progress: int = 0, + indeterminate: bool = False, + group_key: Optional[str] = None, + set_as_group_summary: bool = False, + group_alert_behavior: GroupAlertBehavior = GroupAlertBehavior.ALL, + channel_id: Optional[str] = None, + ) -> None: + """Schedule a notification for a future time, handled by the OS. + + Survives app restarts (and, on Android, reboots — see the README for + the required manifest entries). + + Args: + id: Notification id. + when: When to deliver — a ``datetime`` (naive values are + interpreted in ``time_zone`` or the device timezone) or a + ``timedelta`` from now. + match_components: Repeat on a calendar component (e.g. daily at + this time); use :meth:`periodically_show` for interval + recurrence. + schedule_mode: Android alarm exactness mode. + time_zone: IANA timezone name for naive ``when`` and recurrence + (e.g. ``"Africa/Lagos"``); defaults to the device timezone. + style: Rich style (big text / big picture / inbox / messaging). + show_progress: Show a determinate (or indeterminate) progress bar. + group_key: Group related notifications together. + set_as_group_summary: Use this notification as the group summary. + group_alert_behavior: Which notifications in the group alert. + channel_id: Android channel to post on (created if missing). + """ + check_result( + await self._invoke_method( + "schedule", + build_schedule_args( + id=id, + when=when, + title=title, + body=body, + payload=payload, + actions=actions or [], + match_components=match_components, + schedule_mode=schedule_mode, + time_zone=time_zone, + **self._details_args( + style, + show_progress, + max_progress, + progress, + indeterminate, + group_key, + set_as_group_summary, + group_alert_behavior, + channel_id, + options, + ), + ), + ) + ) + + async def periodically_show( + self, + id: int, + repeat_interval: RepeatInterval, + title: Optional[str] = None, + body: Optional[str] = None, + payload: str = "", + actions: Optional[list[NotificationAction]] = None, + schedule_mode: AndroidScheduleMode = AndroidScheduleMode.EXACT_ALLOW_WHILE_IDLE, + repeat_duration_seconds: Optional[int] = None, + style: Optional[Style] = None, + show_progress: bool = False, + max_progress: int = 0, + progress: int = 0, + indeterminate: bool = False, + group_key: Optional[str] = None, + set_as_group_summary: bool = False, + group_alert_behavior: GroupAlertBehavior = GroupAlertBehavior.ALL, + channel_id: Optional[str] = None, + options: Optional[NotificationOptions] = None, + ) -> None: + """Show a notification repeating at a fixed interval. + + The first delivery happens one interval after the call. Not supported + on Linux/Web (no OS scheduler) or Windows (one-shot only). + """ + check_result( + await self._invoke_method( + "periodically_show", + build_periodic_args( + id=id, + repeat_interval=repeat_interval, + title=title, + body=body, + payload=payload, + actions=actions or [], + schedule_mode=schedule_mode, + repeat_duration_seconds=repeat_duration_seconds, + **self._details_args( + style, + show_progress, + max_progress, + progress, + indeterminate, + group_key, + set_as_group_summary, + group_alert_behavior, + channel_id, + options, + ), + ), + ) + ) + + async def cancel_pending(self) -> None: + """Cancel all scheduled notifications that haven't been delivered.""" + check_result(await self._invoke_method("cancel_pending")) + + async def get_pending_notifications(self) -> list[PendingNotification]: + """Return notifications scheduled for future delivery. + + Supported on Android and iOS/macOS; empty on other platforms. + """ + result = await self._invoke_method("get_pending") + check_result(result) + return self._parse_pending(self._as_list(result)) + + @staticmethod + def _parse_pending(result: object) -> list[PendingNotification]: + if not isinstance(result, list): + return [] + pending = [] + for entry in result: + if not isinstance(entry, dict): + continue + pending.append( + PendingNotification( + id=int(entry.get("id", 0)), + title=str(entry.get("title") or ""), + body=str(entry.get("body") or ""), + payload=str(entry.get("payload") or ""), + ) + ) + return pending + + @staticmethod + def _as_list(result: object) -> list: + """Decode a query result: Dart returns decoded collections or a JSON + string depending on the platform (MsgPack vs method-channel transport).""" + if isinstance(result, str): + try: + result = json.loads(result) + except json.JSONDecodeError as e: + raise NotificationError("query_failed", str(e)) from e + return result if isinstance(result, list) else [] + + @staticmethod + def _as_dict(result: object) -> dict: + if isinstance(result, str): + try: + result = json.loads(result) + except json.JSONDecodeError as e: + raise NotificationError("query_failed", str(e)) from e + return result if isinstance(result, dict) else {} + + async def can_schedule_exact_notifications(self) -> bool: + """Android: whether the app may schedule exact alarms (14+).""" + return to_bool(await self._invoke_method("can_schedule_exact_notifications")) + + async def request_exact_alarm_permission(self) -> bool: + """Android: open the exact-alarm permission screen (14+).""" + return to_bool( + await self._invoke_method("request_exact_alarm_permission", timeout=300) + ) + + # ── Channels (Android 8.0+) ───────────────────────────────────────── + + async def create_notification_channel(self, channel: NotificationChannel) -> None: + """Create or update an Android notification channel.""" + check_result( + await self._invoke_method("create_notification_channel", channel.to_dict()) + ) + + async def delete_notification_channel(self, channel_id: str) -> None: + """Delete an Android notification channel.""" + check_result( + await self._invoke_method( + "delete_notification_channel", {"channel_id": channel_id} + ) + ) + + async def get_notification_channels(self) -> list[NotificationChannel]: + """List the app's Android notification channels.""" + result = await self._invoke_method("get_notification_channels") + check_result(result) + return [ + NotificationChannel.from_dict(entry) + for entry in self._as_list(result) + if isinstance(entry, dict) + ] + + # ── Foreground service (Android) ──────────────────────────────────── + + async def start_foreground_service( + self, + id: int, + title: Optional[str] = None, + body: Optional[str] = None, + payload: str = "", + actions: Optional[list[NotificationAction]] = None, + style: Optional[Style] = None, + show_progress: bool = False, + max_progress: int = 0, + progress: int = 0, + indeterminate: bool = False, + channel_id: Optional[str] = None, + options: Optional[NotificationOptions] = None, + start_type: Optional[str] = None, + foreground_service_types: Optional[list[str]] = None, + ) -> None: + """Start a foreground service with a persistent notification. + + Keeps the process alive for long-running work while showing the + notification. Requires manifest entries on Android — see the README. + """ + if id == 0: + raise ValueError("foreground service id must be non-zero") + check_result( + await self._invoke_method( + "start_foreground_service", + { + "id": id, + "title": title, + "body": body, + "payload": payload, + "start_type": start_type, + "foreground_service_types": foreground_service_types, + "actions": [a.to_dict() for a in actions] if actions else None, + **self._serialize_details( + self._details_args( + style, + show_progress, + max_progress, + progress, + indeterminate, + None, + False, + GroupAlertBehavior.ALL, + channel_id, + options, + ) + ), + }, + ) + ) + + async def stop_foreground_service(self) -> None: + """Stop the foreground service started by this app.""" + check_result(await self._invoke_method("stop_foreground_service")) + + # ── Probes & OS integration ───────────────────────────────────────── + + async def are_notifications_enabled(self) -> bool: + """Whether the OS will deliver this app's notifications.""" + return to_bool(await self._invoke_method("are_notifications_enabled")) + + async def open_app_notification_settings(self) -> None: + """Open the OS notification settings page for this app.""" + check_result(await self._invoke_method("open_app_settings")) + + async def get_active_notifications(self) -> list[ActiveNotification]: + """List notifications currently shown in the tray. + + Supported on Android 6+, iOS 10+ and macOS 10.14+; empty elsewhere. + """ + result = await self._invoke_method("get_active") + check_result(result) + entries = self._as_list(result) + return [ + ActiveNotification( + id=int(e.get("id", 0)), + channel_id=str(e.get("channel_id") or ""), + group_key=str(e.get("group_key") or ""), + tag=str(e.get("tag") or ""), + title=str(e.get("title") or ""), + body=str(e.get("body") or ""), + payload=str(e.get("payload") or ""), + big_text=str(e.get("big_text") or ""), + ) + for e in entries + if isinstance(e, dict) + ] + + async def get_notification_app_launch_details(self) -> LaunchDetails: + """How the app was launched — if a notification launched it. + + Background taps are replayed automatically to ``on_tap`` / + ``on_action``; this is for apps that want the cold-start details + themselves. + """ + result = await self._invoke_method("get_launch_details") + check_result(result) + result = self._as_dict(result) + launched = bool(result.get("did_notification_launch_app", False)) + notification = result.get("notification") + if not isinstance(notification, dict): + return LaunchDetails(did_notification_launch_app=launched) + return LaunchDetails( + did_notification_launch_app=launched, + notification=from_dict( + NotificationEvent, + {"control": self, "name": "tap", **notification}, + ), + ) + + async def request_full_screen_intent_permission(self) -> bool: + """Android 14+: request the full-screen-intent permission.""" + return to_bool( + await self._invoke_method("request_full_screen_intent", timeout=300) + ) + + async def has_notification_policy_access(self) -> bool: + """Android: whether the app may bypass Do Not Disturb.""" + return to_bool(await self._invoke_method("has_notification_policy_access")) + + async def request_notification_policy_access(self) -> bool: + """Android: open the Do-Not-Disturb access settings screen.""" + return to_bool( + await self._invoke_method("request_notification_policy_access", timeout=300) + ) + + # ── Channel groups (Android 8.0+) ─────────────────────────────────── + + async def create_notification_channel_group( + self, group_id: str, name: str, description: str = "" + ) -> None: + """Create an Android notification-channel group.""" + check_result( + await self._invoke_method( + "create_notification_channel_group", + { + "group_id": group_id, + "name": name, + "description": description, + }, + ) + ) + + async def delete_notification_channel_group(self, group_id: str) -> None: + """Delete an Android notification-channel group and its channels.""" + check_result( + await self._invoke_method( + "delete_notification_channel_group", + {"group_id": group_id}, + ) + ) diff --git a/sdk/python/packages/flet-notifications/src/flet_notifications/options.py b/sdk/python/packages/flet-notifications/src/flet_notifications/options.py new file mode 100644 index 0000000000..829b610ffd --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flet_notifications/options.py @@ -0,0 +1,359 @@ +"""Per-platform notification options — the full detail surface. + +Mirrors ``flutter_local_notifications``' ``NotificationDetails`` one-to-one: +one options object per platform, merged into the notification payload. +The everyday knobs (title, body, payload, actions, style, progress, +grouping, channel) stay as flat method kwargs; this is the complete +escape hatch for everything else. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +class Priority(str): + """Android notification priority (pre-8.0 semantics).""" + + MIN = "min" + LOW = "low" + DEFAULT = "default" + HIGH = "high" + MAX = "max" + + @classmethod + def values(cls) -> tuple[str, ...]: + return (cls.MIN, cls.LOW, cls.DEFAULT, cls.HIGH, cls.MAX) + + +class Visibility(str): + """Android notification visibility (lock-screen disclosure).""" + + PUBLIC = "public" + PRIVATE = "private" + SECRET = "secret" + + @classmethod + def values(cls) -> tuple[str, ...]: + return (cls.PUBLIC, cls.PRIVATE, cls.SECRET) + + +class InterruptionLevel(str): + """iOS/macOS interruption level (iOS 15+/macOS 12+).""" + + PASSIVE = "passive" + ACTIVE = "active" + TIME_SENSITIVE = "timeSensitive" # needs the Time Sensitive capability + CRITICAL = "critical" # needs an Apple entitlement + + @classmethod + def values(cls) -> tuple[str, ...]: + return (cls.PASSIVE, cls.ACTIVE, cls.TIME_SENSITIVE, cls.CRITICAL) + + +class LinuxUrgency(str): + """Linux notification-server urgency.""" + + LOW = "low" + NORMAL = "normal" + CRITICAL = "critical" + + @classmethod + def values(cls) -> tuple[str, ...]: + return (cls.LOW, cls.NORMAL, cls.CRITICAL) + + +class WindowsScenario(str): + """Windows toast scenario.""" + + REMINDER = "reminder" + ALARM = "alarm" + INCOMING_CALL = "incomingCall" + URGENT = "urgent" + + @classmethod + def values(cls) -> tuple[str, ...]: + return (cls.REMINDER, cls.ALARM, cls.INCOMING_CALL, cls.URGENT) + + +def _validate_hex(value: str, what: str) -> str: + v = value.strip() + if not v.startswith("#") or len(v) not in (7, 9): + raise ValueError(f"{what} must be #RRGGBB or #AARRGGBB, got {value!r}") + try: + int(v[1:], 16) + except ValueError as e: + raise ValueError(f"{what} must be #RRGGBB or #AARRGGBB, got {value!r}") from e + return v + + +@dataclass(kw_only=True) +class AndroidOptions: + """Android notification and channel details. + + Channel fields (``channel_description``, ``channel_bypass_dnd``, + ``audio_attributes_usage``) apply when the channel is created — on + Android 8+ sound/vibration are immutable after creation, so changing + them needs a new channel id. + """ + + channel_id: str | None = None + channel_description: str | None = None + channel_bypass_dnd: bool | None = None + audio_attributes_usage: str | None = None + icon: str | None = None # drawable resource name + importance: str | None = None # Importance enum value + priority: str | None = None # Priority value + play_sound: bool | None = None + sound: str | None = None # raw resource name or URI + enable_vibration: bool | None = None + vibration_pattern: list[int] | None = None + channel_show_badge: bool | None = None + auto_cancel: bool | None = None + ongoing: bool | None = None + silent: bool | None = None + color: str | None = None + colorized: bool | None = None + large_icon: str | None = None # drawable / file path / content URI + only_alert_once: bool | None = None + show_when: bool | None = None + when: int | None = None # epoch ms override + uses_chronometer: bool | None = None + chronometer_count_down: bool | None = None + sub_text: str | None = None + number: int | None = None + visibility: str | None = None # Visibility value + timeout_after: int | None = None # ms + category: str | None = None # AndroidNotificationCategory value + full_screen_intent: bool | None = None + shortcut_id: str | None = None + additional_flags: list[str] | None = None + ticker: str | None = None + tag: str | None = None + enable_lights: bool | None = None + led_color: str | None = None + led_on_ms: int | None = None + led_off_ms: int | None = None + + def __post_init__(self) -> None: + for name in ("color", "led_color"): + value = getattr(self, name) + if value is not None: + setattr(self, name, _validate_hex(value, name)) + + def to_dict(self) -> dict: + return { + k: v + for k, v in { + "channel_id": self.channel_id, + "channel_description": self.channel_description, + "channel_bypass_dnd": self.channel_bypass_dnd, + "audio_attributes_usage": self.audio_attributes_usage, + "icon": self.icon, + "importance": self.importance, + "priority": self.priority, + "play_sound": self.play_sound, + "sound": self.sound, + "enable_vibration": self.enable_vibration, + "vibration_pattern": list(self.vibration_pattern) + if self.vibration_pattern + else None, + "channel_show_badge": self.channel_show_badge, + "auto_cancel": self.auto_cancel, + "ongoing": self.ongoing, + "silent": self.silent, + "color": self.color, + "colorized": self.colorized, + "large_icon": self.large_icon, + "only_alert_once": self.only_alert_once, + "show_when": self.show_when, + "when": self.when, + "uses_chronometer": self.uses_chronometer, + "chronometer_count_down": self.chronometer_count_down, + "sub_text": self.sub_text, + "number": self.number, + "visibility": self.visibility, + "timeout_after": self.timeout_after, + "category": self.category, + "full_screen_intent": self.full_screen_intent, + "shortcut_id": self.shortcut_id, + "additional_flags": list(self.additional_flags) + if self.additional_flags + else None, + "ticker": self.ticker, + "tag": self.tag, + "enable_lights": self.enable_lights, + "led_color": self.led_color, + "led_on_ms": self.led_on_ms, + "led_off_ms": self.led_off_ms, + }.items() + if v is not None + } + + +@dataclass(kw_only=True) +class DarwinOptions: + """iOS and macOS notification details.""" + + sound: str | None = None # named sound from the app bundle + badge_number: int | None = None # 0 clears the badge + subtitle: str | None = None + thread_identifier: str | None = None # grouping + interruption_level: str | None = None # InterruptionLevel value + critical_sound_volume: float | None = None # 0.0 - 1.0 + attachments: list[str] | None = None # file paths + present_alert: bool | None = None + present_badge: bool | None = None + present_sound: bool | None = None + present_banner: bool | None = None + present_list: bool | None = None + + def to_dict(self) -> dict: + return { + k: v + for k, v in { + "sound": self.sound, + "badge_number": self.badge_number, + "subtitle": self.subtitle, + "thread_identifier": self.thread_identifier, + "interruption_level": self.interruption_level, + "critical_sound_volume": self.critical_sound_volume, + "attachments": list(self.attachments) if self.attachments else None, + "present_alert": self.present_alert, + "present_badge": self.present_badge, + "present_sound": self.present_sound, + "present_banner": self.present_banner, + "present_list": self.present_list, + }.items() + if v is not None + } + + +@dataclass(kw_only=True) +class LinuxOptions: + """Linux (freedesktop) notification details.""" + + icon: str | None = None + sound: str | None = None + category: str | None = None + urgency: str | None = None # LinuxUrgency value + timeout_ms: int | None = None + resident: bool | None = None + transient: bool | None = None + suppress_sound: bool | None = None + action_key_as_icon_name: bool | None = None + + def to_dict(self) -> dict: + return { + k: v + for k, v in { + "icon": self.icon, + "sound": self.sound, + "category": self.category, + "urgency": self.urgency, + "timeout_ms": self.timeout_ms, + "resident": self.resident, + "transient": self.transient, + "suppress_sound": self.suppress_sound, + "action_key_as_icon_name": self.action_key_as_icon_name, + }.items() + if v is not None + } + + +@dataclass(kw_only=True) +class WindowsOptions: + """Windows toast details.""" + + header: str | None = None + audio: str | None = None # URI or ms-winsoundevent name + duration: str | None = None # "long" | "short" + scenario: str | None = None # WindowsScenario value + timestamp: int | None = None # epoch ms + subtitle: str | None = None + images: list[str] | None = None # image URIs + bindings: dict[str, str] | None = None # {name: value} substitutions + + def to_dict(self) -> dict: + return { + k: v + for k, v in { + "header": self.header, + "audio": self.audio, + "duration": self.duration, + "scenario": self.scenario, + "timestamp": self.timestamp, + "subtitle": self.subtitle, + "images": list(self.images) if self.images else None, + "bindings": dict(self.bindings) if self.bindings else None, + }.items() + if v is not None + } + + +@dataclass(kw_only=True) +class WebOptions: + """Web (Browser Notification API) details.""" + + direction: str | None = None # "auto" | "ltr" | "rtl" + badge_url: str | None = None + icon_url: str | None = None + image_url: str | None = None + lang: str | None = None + require_interaction: bool | None = None + is_silent: bool | None = None + renotify: bool | None = None + timestamp: int | None = None + + def to_dict(self) -> dict: + return { + k: v + for k, v in { + "direction": self.direction, + "badge_url": self.badge_url, + "icon_url": self.icon_url, + "image_url": self.image_url, + "lang": self.lang, + "require_interaction": self.require_interaction, + "is_silent": self.is_silent, + "renotify": self.renotify, + "timestamp": self.timestamp, + }.items() + if v is not None + } + + +@dataclass(kw_only=True) +class NotificationOptions: + """Per-platform notification details, mirroring FLN's + ``NotificationDetails``. + + Example:: + + await notifications.show( + id=1, + title="Quiet", + body="Shh", + options=NotificationOptions( + android=AndroidOptions(silent=True, category="alarm"), + iOS=DarwinOptions(interruption_level="passive"), + ), + ) + """ + + android: AndroidOptions | None = None + iOS: DarwinOptions | None = None + macOS: DarwinOptions | None = None + linux: LinuxOptions | None = None + windows: WindowsOptions | None = None + web: WebOptions | None = None + + def to_dict(self) -> dict: + return { + "android": self.android.to_dict() if self.android else None, + "iOS": self.iOS.to_dict() if self.iOS else None, + "macOS": self.macOS.to_dict() if self.macOS else None, + "linux": self.linux.to_dict() if self.linux else None, + "windows": self.windows.to_dict() if self.windows else None, + "web": self.web.to_dict() if self.web else None, + } diff --git a/sdk/python/packages/flet-notifications/src/flet_notifications/patcher.py b/sdk/python/packages/flet-notifications/src/flet_notifications/patcher.py new file mode 100644 index 0000000000..c377082e6c --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flet_notifications/patcher.py @@ -0,0 +1,284 @@ +"""Post-build patcher for Flet Android apps using flet-notifications. + +``flet build apk`` generates the Flutter/Android project but +``[tool.flet.android.*]`` can only express permissions, features, meta-data +and providers — not the ```` entries or the Gradle desugaring +flags that ``flutter_local_notifications`` needs. This script applies them +to the generated project, idempotently. + +Usage:: + + uv run flet build apk --yes --project myapp --org com.example -v + uv run flet-notifications-patch --project-root build/flutter + cd build/flutter/android && ./gradlew assembleRelease + +Options: + --foreground-service-type TYPE Opt in to the foreground-service + manifest entry (e.g. ``specialUse``). + Without it, only the notification + receivers and permissions are patched. +""" + +from __future__ import annotations + +import argparse +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +ANDROID_NS = "http://schemas.android.com/apk/res/android" +NS = {"android": ANDROID_NS} + +RECEIVERS = [ + # name, needs_boot_filter + ("com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver", False), + ("com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver", False), + ("com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver", True), +] + +PERMISSIONS = [ + "android.permission.POST_NOTIFICATIONS", + "android.permission.VIBRATE", + "android.permission.RECEIVE_BOOT_COMPLETED", + "android.permission.SCHEDULE_EXACT_ALARM", +] + +BOOT_ACTIONS = [ + "android.intent.action.BOOT_COMPLETED", + "android.intent.action.MY_PACKAGE_REPLACED", + "android.intent.action.QUICKBOOT_POWERON", + "com.htc.intent.action.QUICKBOOT_POWERON", +] + + +def _android_attr(name: str) -> str: + return f"{{{ANDROID_NS}}}{name}" + + +def patch_manifest(manifest_path: Path) -> list[str]: + """Ensure receivers and permissions exist in the app manifest. + + Returns a list of human-readable descriptions of what changed. + """ + ET.register_namespace("android", ANDROID_NS) + tree = ET.parse(manifest_path) + root = tree.getroot() + changed: list[str] = [] + + for permission in PERMISSIONS: + existing = root.findall(f"uses-permission[@android:name='{permission}']", NS) + if not existing: + element = ET.Element("uses-permission") + element.set(_android_attr("name"), permission) + root.insert(0, element) + changed.append(f"permission {permission.split('.')[-1]}") + + application = root.find("application") + if application is None: + print( + f"warning: no in {manifest_path}; skipped receivers", + file=sys.stderr, + ) + return changed + + for name, boot_filter in RECEIVERS: + existing = application.findall(f"receiver[@android:name='{name}']", NS) + if existing: + # Repair an incomplete receiver: a boot receiver without its + # intent-filter set never receives boot events. + receiver = existing[0] + if boot_filter and not receiver.findall("intent-filter"): + for action in BOOT_ACTIONS: + intent = ET.SubElement(receiver, "intent-filter") + ET.SubElement(intent, "action").set(_android_attr("name"), action) + changed.append(f"receiver {name.rsplit('.', 1)[-1]} filters") + if receiver.get(_android_attr("exported")) != "false": + receiver.set(_android_attr("exported"), "false") + changed.append(f"receiver {name.rsplit('.', 1)[-1]} exported") + continue + receiver = ET.SubElement(application, "receiver") + receiver.set(_android_attr("name"), name) + receiver.set(_android_attr("exported"), "false") + if boot_filter: + for action in BOOT_ACTIONS: + intent = ET.SubElement(receiver, "intent-filter") + ET.SubElement(intent, "action").set(_android_attr("name"), action) + changed.append(f"receiver {name.rsplit('.', 1)[-1]}") + + if changed: + tree.write(manifest_path, encoding="utf-8", xml_declaration=True) + return changed + + +def patch_gradle(gradle_path: Path) -> list[str]: + """Enable core-library desugaring (FLN v19+ requirement). + + Real Flet projects have a non-empty ``dependencies { ... }`` block, so + the desugaring line is inserted after the opening brace rather than + matching only the literal empty form. Missing anchors are reported on + stderr instead of silently succeeding. + """ + if not gradle_path.exists(): + return [] + text = gradle_path.read_text(encoding="utf-8") + changed: list[str] = [] + warnings: list[str] = [] + + if "isCoreLibraryDesugaringEnabled" not in text: + if "compileOptions {" in text: + text = text.replace( + "compileOptions {", + "compileOptions {\n isCoreLibraryDesugaringEnabled = true", + 1, + ) + changed.append("isCoreLibraryDesugaringEnabled") + else: + warnings.append( + "no `compileOptions {` block found; add " + "`isCoreLibraryDesugaringEnabled = true` manually" + ) + + if "coreLibraryDesugaring" not in text: + if "dependencies {" in text: + text = text.replace( + "dependencies {", + "dependencies {\n " + 'coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")', + 1, + ) + changed.append("coreLibraryDesugaring") + else: + warnings.append( + "no `dependencies {` block found; add " + 'coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") ' + "manually" + ) + + if "multiDexEnabled" not in text: + if "defaultConfig {" in text: + text = text.replace( + "defaultConfig {", + "defaultConfig {\n multiDexEnabled = true", + 1, + ) + changed.append("multiDexEnabled") + else: + warnings.append( + "no `defaultConfig {` block found; add `multiDexEnabled = true` " + "manually" + ) + + if changed: + gradle_path.write_text(text, encoding="utf-8") + for warning in warnings: + print(f"warning: {warning}", file=sys.stderr) + return changed + + +# Android 14+ foreground-service types that map to FOREGROUND_SERVICE_* +# permissions (shortService and dataSync-style values map to real +# permissions; everything else must be a known type). +_FGS_TYPES = { + "camera": "FOREGROUND_SERVICE_CAMERA", + "connectedDevice": "FOREGROUND_SERVICE_CONNECTED_DEVICE", + "dataSync": "FOREGROUND_SERVICE_DATA_SYNC", + "health": "FOREGROUND_SERVICE_HEALTH", + "location": "FOREGROUND_SERVICE_LOCATION", + "mediaPlayback": "FOREGROUND_SERVICE_MEDIA_PLAYBACK", + "mediaProjection": "FOREGROUND_SERVICE_MEDIA_PROJECTION", + "microphone": "FOREGROUND_SERVICE_MICROPHONE", + "phoneCall": "FOREGROUND_SERVICE_PHONE_CALL", + "remoteMessaging": "FOREGROUND_SERVICE_REMOTE_MESSAGING", + "shortService": "FOREGROUND_SERVICE_SHORT_SERVICE", + "specialUse": "FOREGROUND_SERVICE_SPECIAL_USE", + "systemExempted": "FOREGROUND_SERVICE_SYSTEM_EXEMPTED", +} + + +def patch_foreground_service(manifest_path: Path, service_type: str) -> list[str]: + """Add the ForegroundService declaration and permissions (opt-in).""" + if service_type not in _FGS_TYPES: + raise ValueError( + f"unknown foreground-service type {service_type!r}; " + f"expected one of: {', '.join(_FGS_TYPES)}" + ) + ET.register_namespace("android", ANDROID_NS) + tree = ET.parse(manifest_path) + root = tree.getroot() + application = root.find("application") + if application is None: + return [] + service_name = "com.dexterous.flutterlocalnotifications.ForegroundService" + existing = application.findall(f"service[@android:name='{service_name}']", NS) + if existing: + return [] + service = ET.SubElement(application, "service") + service.set(_android_attr("name"), service_name) + service.set(_android_attr("exported"), "false") + service.set(_android_attr("foregroundServiceType"), service_type) + if service_type == "specialUse": + # Android 14 requires the subtype property for specialUse. + prop = ET.SubElement(service, "property") + prop.set( + _android_attr("name"), + "android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE", + ) + prop.set( + _android_attr("value"), + "flet-notifications foreground service", + ) + for permission in ( + "android.permission.FOREGROUND_SERVICE", + f"android.permission.{_FGS_TYPES[service_type]}", + ): + if not root.findall(f"uses-permission[@android:name='{permission}']", NS): + element = ET.Element("uses-permission") + element.set(_android_attr("name"), permission) + root.insert(0, element) + tree.write(manifest_path, encoding="utf-8", xml_declaration=True) + return [f"foreground service ({service_type})"] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="flet-notifications-patch", + description="Patch a generated Flet Android project for " + "flet-notifications (receivers, permissions, desugaring).", + ) + parser.add_argument( + "--project-root", + default="build/flutter", + help="Path to the generated Flutter project (default: build/flutter)", + ) + parser.add_argument( + "--foreground-service-type", + default=None, + help="Opt in to the ForegroundService manifest entry with this " + "type (e.g. specialUse). Omit to skip foreground-service setup.", + ) + args = parser.parse_args(argv) + + project = Path(args.project_root) + manifest = project / "android" / "app" / "src" / "main" / "AndroidManifest.xml" + gradle = project / "android" / "app" / "build.gradle.kts" + + if not manifest.exists(): + print(f"error: manifest not found: {manifest}", file=sys.stderr) + return 1 + + changes = patch_manifest(manifest) + changes += patch_gradle(gradle) + if args.foreground_service_type: + changes += patch_foreground_service(manifest, args.foreground_service_type) + + if changes: + print("flet-notifications-patch applied:") + for change in changes: + print(f" + {change}") + else: + print("flet-notifications-patch: nothing to do (already patched)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sdk/python/packages/flet-notifications/src/flet_notifications/scheduling.py b/sdk/python/packages/flet-notifications/src/flet_notifications/scheduling.py new file mode 100644 index 0000000000..d11de318ae --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flet_notifications/scheduling.py @@ -0,0 +1,123 @@ +"""Schedule argument serialization (pure — unit tested).""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from flet_notifications.styles import Style +from flet_notifications.types import ( + AndroidScheduleMode, + DateTimeComponents, + GroupAlertBehavior, + RepeatInterval, +) + + +def build_schedule_args( + *, + id: int, + when: datetime | timedelta, + title: str | None, + body: str | None, + payload: str, + actions: list, + match_components: DateTimeComponents | None, + schedule_mode: AndroidScheduleMode, + time_zone: str | None, + options: object | None = None, + style: Style | None = None, + show_progress: bool = False, + max_progress: int = 0, + progress: int = 0, + indeterminate: bool = False, + group_key: str | None = None, + set_as_group_summary: bool = False, + group_alert_behavior: GroupAlertBehavior = GroupAlertBehavior.ALL, + channel_id: str | None = None, +) -> dict: + """Build the ``schedule`` invoke payload. + + Naive datetimes are wall-clock times interpreted in ``time_zone`` (or the + device timezone when ``time_zone`` is None); aware datetimes carry their + offset so the exact instant is preserved. + """ + if isinstance(when, timedelta): + when_payload: dict = { + "delay_seconds": when.total_seconds(), + "time_zone": time_zone, + } + else: + when_payload = { + "iso": when.isoformat(), + "utc_offset_seconds": when.utcoffset().total_seconds() + if when.utcoffset() is not None + else None, + "time_zone": time_zone, + } + + return { + "id": id, + "title": title, + "body": body, + "payload": payload, + "actions": [a.to_dict() for a in actions] if actions else None, + "when": when_payload, + "match_components": match_components.value if match_components else None, + "options": options.to_dict() if options else None, + "schedule_mode": schedule_mode.value, + "style": style.to_dict() if style else None, + "show_progress": show_progress, + "max_progress": max_progress, + "progress": progress, + "indeterminate": indeterminate, + "group_key": group_key, + "set_as_group_summary": set_as_group_summary, + "group_alert_behavior": group_alert_behavior.value, + "channel_id": channel_id, + } + + +def build_periodic_args( + *, + id: int, + repeat_interval: RepeatInterval, + title: str | None, + body: str | None, + payload: str, + actions: list, + schedule_mode: AndroidScheduleMode, + repeat_duration_seconds: int | None, + options: object | None = None, + style: Style | None = None, + show_progress: bool = False, + max_progress: int = 0, + progress: int = 0, + indeterminate: bool = False, + group_key: str | None = None, + set_as_group_summary: bool = False, + group_alert_behavior: GroupAlertBehavior = GroupAlertBehavior.ALL, + channel_id: str | None = None, +) -> dict: + """Build the ``periodically_show`` invoke payload.""" + if repeat_duration_seconds is not None and repeat_duration_seconds < 60: + raise ValueError("repeat_duration_seconds must be at least 60") + return { + "id": id, + "title": title, + "body": body, + "payload": payload, + "actions": [a.to_dict() for a in actions] if actions else None, + "repeat_interval": repeat_interval.value, + "schedule_mode": schedule_mode.value, + "repeat_duration_seconds": repeat_duration_seconds, + "options": options.to_dict() if options else None, + "style": style.to_dict() if style else None, + "show_progress": show_progress, + "max_progress": max_progress, + "progress": progress, + "indeterminate": indeterminate, + "group_key": group_key, + "set_as_group_summary": set_as_group_summary, + "group_alert_behavior": group_alert_behavior.value, + "channel_id": channel_id, + } diff --git a/sdk/python/packages/flet-notifications/src/flet_notifications/styles.py b/sdk/python/packages/flet-notifications/src/flet_notifications/styles.py new file mode 100644 index 0000000000..4fe46a34ab --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flet_notifications/styles.py @@ -0,0 +1,159 @@ +"""Rich notification styles (big text, big picture, inbox, messaging).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime + + +@dataclass(kw_only=True) +class AndroidIcon: + """An Android notification icon or image. + + Exactly one source must be set: a bundled drawable resource name, a file + path, a content URI, a Flutter asset path, or raw bytes (base64). + """ + + drawable_resource: str | None = None + file_path: str | None = None + content_uri: str | None = None + flutter_asset: str | None = None + data_base64: str | None = None + + def __post_init__(self) -> None: + sources = [ + self.drawable_resource, + self.file_path, + self.content_uri, + self.flutter_asset, + self.data_base64, + ] + if sum(s is not None for s in sources) != 1: + raise ValueError("AndroidIcon requires exactly one source") + + def to_dict(self) -> dict: + return { + "drawable_resource": self.drawable_resource, + "file_path": self.file_path, + "content_uri": self.content_uri, + "flutter_asset": self.flutter_asset, + "data_base64": self.data_base64, + } + + +@dataclass(kw_only=True) +class BigTextStyle: + """Expandable text (the Android "big text" style).""" + + big_text: str + content_title: str | None = None + summary_text: str | None = None + + def to_dict(self) -> dict: + return { + "type": "big_text", + "big_text": self.big_text, + "content_title": self.content_title, + "summary_text": self.summary_text, + } + + +@dataclass(kw_only=True) +class BigPictureStyle: + """Expandable image (the Android "big picture" style).""" + + image: AndroidIcon + large_icon: AndroidIcon | None = None + content_title: str | None = None + summary_text: str | None = None + + def to_dict(self) -> dict: + return { + "type": "big_picture", + "image": self.image.to_dict(), + "large_icon": self.large_icon.to_dict() if self.large_icon else None, + "content_title": self.content_title, + "summary_text": self.summary_text, + } + + +@dataclass(kw_only=True) +class InboxStyle: + """Up to seven expandable lines (the Android "inbox" style).""" + + lines: list[str] = field(default_factory=list) + content_title: str | None = None + summary_text: str | None = None + + def __post_init__(self) -> None: + if not self.lines: + raise ValueError("InboxStyle requires at least one line") + + def to_dict(self) -> dict: + return { + "type": "inbox", + "lines": list(self.lines), + "content_title": self.content_title, + "summary_text": self.summary_text, + } + + +@dataclass(kw_only=True) +class Person: + """A participant in a messaging-style conversation.""" + + name: str + key: str | None = None + bot: bool = False + important: bool = False + uri: str | None = None + icon: AndroidIcon | None = None + + def to_dict(self) -> dict: + return { + "name": self.name, + "key": self.key, + "bot": self.bot, + "important": self.important, + "uri": self.uri, + "icon": self.icon.to_dict() if self.icon else None, + } + + +@dataclass(kw_only=True) +class Message: + """One message in a messaging-style conversation.""" + + text: str + timestamp: datetime + person: Person + + def to_dict(self) -> dict: + return { + "text": self.text, + "timestamp": self.timestamp.isoformat(), + "person": self.person.to_dict(), + } + + +@dataclass(kw_only=True) +class MessagingStyle: + """Chat conversation (the Android/iOS messaging style).""" + + person: Person + messages: list[Message] = field(default_factory=list) + conversation_title: str | None = None + group_conversation: bool = False + + def to_dict(self) -> dict: + return { + "type": "messaging", + "person": self.person.to_dict(), + "messages": [m.to_dict() for m in self.messages], + "conversation_title": self.conversation_title, + "group_conversation": self.group_conversation, + } + + +Style = BigTextStyle | BigPictureStyle | InboxStyle | MessagingStyle +"""Union of the supported notification styles.""" diff --git a/sdk/python/packages/flet-notifications/src/flet_notifications/types.py b/sdk/python/packages/flet-notifications/src/flet_notifications/types.py new file mode 100644 index 0000000000..32ffe66c7e --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flet_notifications/types.py @@ -0,0 +1,53 @@ +"""Enums for scheduling and notification parameters.""" + +from __future__ import annotations + +from enum import Enum + + +class RepeatInterval(str, Enum): + """Repeat cadence for periodic notifications (Android/iOS/macOS).""" + + EVERY_MINUTE = "everyMinute" + HOURLY = "hourly" + DAILY = "daily" + WEEKLY = "weekly" + + +class DateTimeComponents(str, Enum): + """Which parts of the scheduled date repeat (daily/weekly/monthly/yearly).""" + + TIME = "time" # every day at the given time + DAY_OF_WEEK_AND_TIME = "dayOfWeekAndTime" + DAY_OF_MONTH_AND_TIME = "dayOfMonthAndTime" + DATE_AND_TIME = "dateAndTime" + + +class AndroidScheduleMode(str, Enum): + """Android alarm exactness mode.""" + + ALARM_CLOCK = "alarmClock" # exact + shows in alarm-clock UI + EXACT = "exact" + EXACT_ALLOW_WHILE_IDLE = "exactAllowWhileIdle" # default; survives doze + INEXACT = "inexact" + INEXACT_ALLOW_WHILE_IDLE = "inexactAllowWhileIdle" + + +class Importance(str, Enum): + """Android notification-channel importance (also drives priority).""" + + UNSPECIFIED = "unspecified" + MIN = "min" + NONE = "none" + LOW = "low" + DEFAULT = "defaultImportance" + HIGH = "high" + MAX = "max" + + +class GroupAlertBehavior(str, Enum): + """Which notifications in a group alert the user.""" + + ALL = "all" + SUMMARY = "summary" + CHILDREN = "children" diff --git a/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/.gitignore b/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/.gitignore new file mode 100644 index 0000000000..694a0e2d4a --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/.gitignore @@ -0,0 +1,25 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Flutter/Dart/Pub related +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ +.flutter-plugins +.flutter-plugins-dependencies diff --git a/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/analysis_options.yaml b/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/analysis_options.yaml new file mode 100644 index 0000000000..f9b303465f --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/pubspec.yaml b/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/pubspec.yaml new file mode 100644 index 0000000000..2d2eecd8e8 --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/pubspec.yaml @@ -0,0 +1,26 @@ +name: flet_notifications +description: Flet Local Notifications service +version: 0.1.0 +publish_to: none + +environment: + sdk: '>=3.3.0 <4.0.0' + flutter: ">=3.38.1" + +dependencies: + flutter: + sdk: flutter + + flutter_local_notifications: ^22.3.1 + flutter_timezone: ^5.1.1 + shared_preferences: ^2.5.6 + timezone: ^0.11.0 + + flet: + path: ../../../../../../../packages/flet + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + shared_preferences_platform_interface: ^2.4.2 diff --git a/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/notifications_service_test.dart b/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/notifications_service_test.dart new file mode 100644 index 0000000000..517c41e2a0 --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/notifications_service_test.dart @@ -0,0 +1,937 @@ +import 'dart:convert'; +import 'dart:ui' show Color; + +import 'package:flet/flet.dart'; +import 'package:flutter/foundation.dart' + show debugDefaultTargetPlatformOverride, TargetPlatform; +import 'package:flutter_local_notifications/flutter_local_notifications.dart' + as fln; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flet_notifications/src/notifications_service.dart'; +import 'package:timezone/timezone.dart' as tz; + +/// Fake Android platform implementation recording what the service sends. +class _FakeAndroidPlugin extends fln.AndroidFlutterLocalNotificationsPlugin { + final List> shown = []; + final List> scheduled = []; + final List> periodic = []; + final List cancelled = []; + int cancelAllCalls = 0; + int cancelPendingCalls = 0; + int permissionRequests = 0; + bool permissionResult = true; + bool exactAlarmsAllowed = true; + int exactAlarmRequests = 0; + final List pending = []; + bool rejectPastSchedules = false; + + @override + Future initialize({ + required fln.AndroidInitializationSettings settings, + fln.DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse, + fln.DidReceiveBackgroundNotificationResponseCallback? + onDidReceiveBackgroundNotificationResponse, + }) async { + return true; + } + + @override + Future show({ + required int id, + String? title, + String? body, + fln.AndroidNotificationDetails? notificationDetails, + String? payload, + }) async { + shown.add({ + "id": id, + "title": title, + "body": body, + "payload": payload, + "details": notificationDetails, + }); + } + + @override + Future zonedSchedule({ + required int id, + String? title, + String? body, + required tz.TZDateTime scheduledDate, + String? payload, + fln.DateTimeComponents? matchDateTimeComponents, + fln.AndroidNotificationDetails? notificationDetails, + fln.AndroidScheduleMode scheduleMode = + fln.AndroidScheduleMode.exact, + }) async { + if (rejectPastSchedules) { + // Mirrors the real platform's validateDateIsInTheFuture. + throw ArgumentError("scheduled date must be in the future"); + } + scheduled.add({ + "id": id, + "title": title, + "body": body, + "payload": payload, + "scheduledDate": scheduledDate, + "matchDateTimeComponents": matchDateTimeComponents, + "scheduleMode": scheduleMode, + "details": notificationDetails, + }); + } + + @override + Future periodicallyShow({ + required int id, + String? title, + String? body, + required fln.RepeatInterval repeatInterval, + fln.AndroidNotificationDetails? notificationDetails, + String? payload, + fln.AndroidScheduleMode scheduleMode = fln.AndroidScheduleMode.exact, + }) async { + periodic.add({ + "id": id, + "title": title, + "body": body, + "payload": payload, + "repeatInterval": repeatInterval, + "scheduleMode": scheduleMode, + "details": notificationDetails, + }); + } + + @override + Future periodicallyShowWithDuration({ + required int id, + String? title, + String? body, + required Duration repeatDurationInterval, + fln.AndroidNotificationDetails? notificationDetails, + String? payload, + fln.AndroidScheduleMode scheduleMode = fln.AndroidScheduleMode.exact, + }) async { + periodic.add({ + "id": id, + "title": title, + "body": body, + "payload": payload, + "repeatDurationInterval": repeatDurationInterval, + "scheduleMode": scheduleMode, + "details": notificationDetails, + }); + } + + @override + Future cancel({required int id, String? tag}) async { + cancelled.add(id); + } + + @override + Future cancelAll() async { + cancelAllCalls++; + } + + @override + Future cancelAllPendingNotifications() async { + cancelPendingCalls++; + } + + @override + Future> pendingNotificationRequests() async { + return pending; + } + + @override + Future requestNotificationsPermission() async { + permissionRequests++; + return permissionResult; + } + + @override + Future canScheduleExactNotifications() async { + return exactAlarmsAllowed; + } + + @override + Future requestExactAlarmsPermission() async { + exactAlarmRequests++; + return true; + } +} + +LocalNotificationsService _makeService({Map? properties}) { + final backend = FletBackend( + pageUri: Uri.parse("uri"), + assetsDir: "", + extensions: [], + multiView: false, + ); + final control = Control( + id: 1, + type: "LocalNotifications", + properties: properties ?? const {}, + backend: backend, + ); + return LocalNotificationsService(control: control); +} + +Map _whenIso(String iso, {String? timeZone}) { + return { + "iso": iso, + "utc_offset_seconds": null, + "time_zone": timeZone, + }; +} + +void main() { + late _FakeAndroidPlugin plugin; + + setUp(() { + plugin = _FakeAndroidPlugin(); + fln.FlutterLocalNotificationsPlatform.instance = plugin; + }); + + test("show forwards id/title/body/payload to the platform", () async { + final service = _makeService(); + final result = await service.invokeMethod("show", { + "id": 42, + "title": "Title", + "body": "Body", + "payload": "deep.link", + "actions": null, + }); + expect(result, "ok"); + expect(plugin.shown, hasLength(1)); + final call = plugin.shown.single; + expect(call["id"], 42); + expect(call["title"], "Title"); + expect(call["body"], "Body"); + expect(call["payload"], "deep.link"); + final details = call["details"] as fln.AndroidNotificationDetails?; + expect(details, isNotNull); + expect(details!.channelId, "flet_notifications"); + expect(details.channelName, "Notifications"); + expect(details.actions ?? const [], isEmpty); + }); + + test("show maps actions onto AndroidNotificationAction", () async { + final service = _makeService(); + final result = await service.invokeMethod("show", { + "id": 1, + "title": "t", + "body": "b", + "payload": "", + "actions": [ + { + "id": "open", + "title": "Open", + "destructive": false, + "foreground": true, + "cancel_notification": true, + "input": null, + }, + { + "id": "reply", + "title": "Reply", + "destructive": false, + "foreground": false, + "cancel_notification": true, + "input": { + "label": "Say", + "choices": [], + "allow_free_form_input": true, + "allowed_mime_types": [], + }, + }, + ], + }); + expect(result, "ok"); + final details = + plugin.shown.single["details"] as fln.AndroidNotificationDetails; + final actions = details.actions!; + expect(actions, hasLength(2)); + expect(actions[0].id, "open"); + expect(actions[0].title, "Open"); + expect(actions[0].showsUserInterface, isTrue); + expect(actions[1].id, "reply"); + expect(actions[1].showsUserInterface, isFalse); + expect(actions[1].allowGeneratedReplies, isTrue); + expect(actions[1].inputs, hasLength(1)); + expect(actions[1].inputs.single.label, "Say"); + }); + + test("show with invalid action returns an error string", () async { + final service = _makeService(); + final result = await service.invokeMethod("show", { + "id": 1, + "title": "t", + "body": "b", + "payload": "", + "actions": [ + {"title": "no id"}, + ], + }); + expect(result, startsWith("error:invalid_action:")); + expect(plugin.shown, isEmpty); + }); + + test("cancel and cancel_all route to the platform", () async { + final service = _makeService(); + expect(await service.invokeMethod("cancel", {"id": 9}), "ok"); + expect(plugin.cancelled, [9]); + expect(await service.invokeMethod("cancel_all", null), "ok"); + expect(plugin.cancelAllCalls, 1); + }); + + test("request_permissions uses the Android runtime permission", () async { + final service = _makeService(); + expect(await service.invokeMethod("request_permissions", null), "true"); + expect(plugin.permissionRequests, 1); + + plugin.permissionResult = false; + expect(await service.invokeMethod("request_permissions", null), "false"); + }); + + // ── Scheduling ──────────────────────────────────────────────────────── + + test("schedule converts naive wall-clock time in the target zone", + () async { + final service = _makeService(); + final result = await service.invokeMethod("schedule", { + "id": 5, + "title": "Reminder", + "body": "Later", + "payload": "", + "actions": null, + "when": _whenIso("2030-01-15T09:30:00", timeZone: "Africa/Lagos"), + "repeat_interval": null, + "match_components": "time", + "schedule_mode": "exactAllowWhileIdle", + }); + expect(result, "ok"); + final call = plugin.scheduled.single; + final date = call["scheduledDate"] as tz.TZDateTime; + expect(date.year, 2030); + expect(date.month, 1); + expect(date.day, 15); + expect(date.hour, 9); + expect(date.minute, 30); + expect(date.location.name, "Africa/Lagos"); + expect( + call["matchDateTimeComponents"], + fln.DateTimeComponents.time, + ); + expect(call["scheduleMode"], fln.AndroidScheduleMode.exactAllowWhileIdle); + }); + + test("schedule preserves the instant for aware datetimes", () async { + final service = _makeService(); + final result = await service.invokeMethod("schedule", { + "id": 6, + "title": "t", + "body": "b", + "payload": "", + "actions": null, + "when": { + "iso": "2030-06-01T12:00:00+01:00", + "utc_offset_seconds": 3600, + "time_zone": null, + }, + "repeat_interval": null, + "match_components": null, + "schedule_mode": "exact", + }); + expect(result, "ok"); + final date = + plugin.scheduled.single["scheduledDate"] as tz.TZDateTime; + // 12:00 +01:00 is 11:00 UTC — the instant must survive the conversion. + expect(date.hour, 11); + expect(date.minute, 0); + expect(plugin.scheduled.single["scheduleMode"], fln.AndroidScheduleMode.exact); + }); + + test("schedule accepts a delay from now", () async { + final service = _makeService(); + final result = await service.invokeMethod("schedule", { + "id": 7, + "title": "t", + "body": "b", + "payload": "", + "actions": null, + "when": {"delay_seconds": 30, "time_zone": null}, + "repeat_interval": null, + "match_components": null, + "schedule_mode": "exactAllowWhileIdle", + }); + expect(result, "ok"); + final date = + plugin.scheduled.single["scheduledDate"] as tz.TZDateTime; + final delta = date.difference(tz.TZDateTime.now(tz.local)); + expect(delta.inSeconds.abs(), greaterThan(25)); + }); + + test("schedule failure returns an error string", () async { + final service = _makeService(); + final result = await service.invokeMethod("schedule", { + "id": 8, + "title": "t", + "body": "b", + "payload": "", + "actions": null, + "when": {"nonsense": true}, + "repeat_interval": null, + "match_components": null, + "schedule_mode": "exactAllowWhileIdle", + }); + expect(result, startsWith("error:invalid_schedule:")); + expect(plugin.scheduled, isEmpty); + }); + + test("schedule in the past surfaces as schedule_failed", () async { + // The real platform rejects past dates via validateDateIsInTheFuture. + plugin.rejectPastSchedules = true; + final service = _makeService(); + final result = await service.invokeMethod("schedule", { + "id": 99, + "title": "t", + "body": "b", + "payload": "", + "actions": null, + "when": {"iso": "2020-01-01T09:00:00", "utc_offset_seconds": null}, + "repeat_interval": null, + "match_components": null, + "schedule_mode": "exactAllowWhileIdle", + }); + expect(result, startsWith("error:schedule_failed:")); + expect(plugin.scheduled, isEmpty); + }); + + test("periodically_show maps the repeat interval", () async { + final service = _makeService(); + final result = await service.invokeMethod("periodically_show", { + "id": 9, + "title": "t", + "body": "b", + "payload": "", + "actions": null, + "repeat_interval": "daily", + "schedule_mode": "exactAllowWhileIdle", + "repeat_duration_seconds": null, + }); + expect(result, "ok"); + final call = plugin.periodic.single; + expect(call["id"], 9); + expect(call["repeatInterval"], fln.RepeatInterval.daily); + }); + + test("periodically_show with a custom duration", () async { + final service = _makeService(); + final result = await service.invokeMethod("periodically_show", { + "id": 10, + "title": "t", + "body": "b", + "payload": "", + "actions": null, + "repeat_interval": "daily", + "schedule_mode": "exactAllowWhileIdle", + "repeat_duration_seconds": 900, + }); + expect(result, "ok"); + final call = plugin.periodic.single; + expect( + call["repeatDurationInterval"], + const Duration(seconds: 900), + ); + }); + + test("cancel_pending routes to the platform", () async { + final service = _makeService(); + expect(await service.invokeMethod("cancel_pending", null), "ok"); + expect(plugin.cancelPendingCalls, 1); + }); + + test("get_pending returns JSON records", () async { + plugin.pending.add( + const fln.PendingNotificationRequest(3, "Pending", "Body", "p"), + ); + final service = _makeService(); + final result = await service.invokeMethod("get_pending", null); + final decoded = result is String ? jsonDecode(result) : result; + expect(decoded, isA()); + final first = (decoded as List).single as Map; + expect(first["id"], 3); + expect(first["title"], "Pending"); + expect(first["payload"], "p"); + }); + + test("exact-alarm probes and request route to Android", () async { + final service = _makeService(); + expect( + await service.invokeMethod("can_schedule_exact_notifications", null), + "true", + ); + plugin.exactAlarmsAllowed = false; + expect( + await service.invokeMethod("can_schedule_exact_notifications", null), + "false", + ); + expect( + await service.invokeMethod("request_exact_alarm_permission", null), + "true", + ); + expect(plugin.exactAlarmRequests, 1); + }); + + test("unknown method returns the unknown_method error code", () async { + final service = _makeService(); + expect( + await service.invokeMethod("nope", null), + "error:unknown_method:nope", + ); + }); + + registerPhase3Tests(); +} + +/// Fake macOS platform implementation recording Darwin notification details. +class _FakeMacOsPlugin extends fln.MacOSFlutterLocalNotificationsPlugin { + fln.DarwinNotificationDetails? shownDarwin; + + @override + Future initialize({ + required fln.DarwinInitializationSettings settings, + fln.DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse, + }) async { + return true; + } + + @override + Future show({ + required int id, + String? title, + String? body, + fln.DarwinNotificationDetails? notificationDetails, + String? payload, + }) async { + shownDarwin = notificationDetails; + } +} + +// ── Phase 3: channels, styles, progress, foreground service ──────────── + +class _ChannelRecordingPlugin extends _FakeAndroidPlugin { + final List channelsCreated = []; + final List channelsDeleted = []; + final List channelsStored = []; + int foregroundStarts = 0; + int foregroundStops = 0; + + @override + Future createNotificationChannel( + fln.AndroidNotificationChannel channel, + ) async { + channelsCreated.add(channel); + } + + @override + Future deleteNotificationChannel({required String channelId}) async { + channelsDeleted.add(channelId); + } + + @override + Future> getNotificationChannels() async { + return channelsStored; + } + + @override + Future startForegroundService({ + required int id, + String? title, + String? body, + fln.AndroidNotificationDetails? notificationDetails, + String? payload, + fln.AndroidServiceStartType startType = fln.AndroidServiceStartType.startSticky, + Set? foregroundServiceTypes, + }) async { + foregroundStarts++; + lastStartType = startType; + lastForegroundServiceTypes = foregroundServiceTypes; + } + + @override + Future createNotificationChannelGroup( + fln.AndroidNotificationChannelGroup notificationChannelGroup, + ) async { + groupsCreated.add(notificationChannelGroup); + } + + @override + Future stopForegroundService() async { + foregroundStops++; + } + + bool notificationsEnabled = true; + int settingsOpened = 0; + final List activeNotifications = []; + final List groupsCreated = []; + fln.AndroidServiceStartType? lastStartType; + Set? lastForegroundServiceTypes; + + @override + Future areNotificationsEnabled() async { + return notificationsEnabled; + } + + @override + Future openAppNotificationSettings() async { + settingsOpened++; + return true; + } + + @override + Future> getActiveNotifications() async { + return activeNotifications; + } +} + +void registerPhase3Tests() { + group("Phase 3: rich notifications", () { + late _ChannelRecordingPlugin plugin; + + setUp(() { + plugin = _ChannelRecordingPlugin(); + fln.FlutterLocalNotificationsPlatform.instance = plugin; + }); + + test("show forwards style onto styleInformation", () async { + final service = _makeService(); + final result = await service.invokeMethod("show", { + "id": 1, + "title": "t", + "body": "b", + "payload": "", + "actions": null, + "style": {"type": "big_text", "big_text": "long text"}, + "channel_id": null, + "group_key": "grp", + "set_as_group_summary": false, + "group_alert_behavior": "summary", + "show_progress": true, + "max_progress": 100, + "progress": 30, + "indeterminate": false, + }); + expect(result, "ok"); + final details = + plugin.shown.single["details"] as fln.AndroidNotificationDetails; + final style = details.styleInformation; + expect(style, isA()); + expect((style as fln.BigTextStyleInformation).bigText, "long text"); + expect(details.groupKey, "grp"); + expect(details.groupAlertBehavior, fln.GroupAlertBehavior.summary); + expect(details.showProgress, isTrue); + expect(details.maxProgress, 100); + expect(details.progress, 30); + }); + + test("show with an invalid style returns invalid_style", () async { + final service = _makeService(); + final result = await service.invokeMethod("show", { + "id": 1, + "title": "t", + "body": "b", + "payload": "", + "actions": null, + "style": {"type": "nope"}, + "channel_id": null, + }); + expect(result, startsWith("error:invalid_style:")); + expect(plugin.shown, isEmpty); + }); + + test("create_notification_channel maps the config", () async { + final service = _makeService(); + final result = await service.invokeMethod("create_notification_channel", { + "id": "alerts", + "name": "Alerts", + "description": "Important stuff", + "importance": "high", + "bypass_dnd": true, + "play_sound": true, + "sound": "alert_tone", + "enable_vibration": true, + "vibration_pattern": [0, 250, 100], + "show_badge": true, + "enable_lights": false, + "led_color": null, + }); + expect(result, "ok"); + final channel = plugin.channelsCreated.single; + expect(channel.id, "alerts"); + expect(channel.name, "Alerts"); + expect(channel.description, "Important stuff"); + expect(channel.importance, fln.Importance.high); + expect(channel.bypassDnd, isTrue); + expect(channel.sound!.sound, "alert_tone"); + expect(channel.vibrationPattern!.toList(), [0, 250, 100]); + }); + + test("create_notification_channel rejects empty id", () async { + final service = _makeService(); + final result = await service.invokeMethod("create_notification_channel", { + "id": "", + "name": "x", + }); + expect(result, "error:invalid_channel:id and name are required"); + }); + + test("delete_notification_channel routes by id", () async { + final service = _makeService(); + final result = await service.invokeMethod("delete_notification_channel", { + "channel_id": "alerts", + }); + expect(result, "ok"); + expect(plugin.channelsDeleted, ["alerts"]); + }); + + test("get_notification_channels returns JSON", () async { + plugin.channelsStored.add( + const fln.AndroidNotificationChannel("alerts", "Alerts"), + ); + final service = _makeService(); + final result = await service.invokeMethod( + "get_notification_channels", + null, + ); + final decoded = jsonDecode(result as String) as List; + expect(decoded.single["id"], "alerts"); + expect(decoded.single["name"], "Alerts"); + }); + + test("foreground service start and stop", () async { + final service = _makeService(); + expect( + await service.invokeMethod("start_foreground_service", { + "id": 7, + "title": "Syncing", + "body": "Working…", + "payload": "", + "actions": null, + "style": null, + "show_progress": true, + "max_progress": 100, + "progress": 10, + "indeterminate": false, + "channel_id": null, + }), + "ok", + ); + expect(plugin.foregroundStarts, 1); + expect(await service.invokeMethod("stop_foreground_service", null), "ok"); + expect(plugin.foregroundStops, 1); + }); + + test("are_notifications_enabled routes to the platform probe", () async { + final service = _makeService(); + expect( + await service.invokeMethod("are_notifications_enabled", null), + "true", + ); + plugin.notificationsEnabled = false; + expect( + await service.invokeMethod("are_notifications_enabled", null), + "false", + ); + }); + + test("open_app_settings routes to the platform", () async { + final service = _makeService(); + expect(await service.invokeMethod("open_app_settings", null), "true"); + expect(plugin.settingsOpened, 1); + }); + + test("get_active returns JSON records", () async { + plugin.activeNotifications.add( + const fln.ActiveNotification( + id: 3, + channelId: "alerts", + groupKey: "g", + tag: "t", + title: "T", + body: "B", + payload: "p", + bigText: "long", + ), + ); + final service = _makeService(); + final result = await service.invokeMethod("get_active", null); + final decoded = jsonDecode(result as String) as List; + final first = decoded.single as Map; + expect(first["id"], 3); + expect(first["title"], "T"); + expect(first["payload"], "p"); + expect(first["big_text"], "long"); + }); + + test("foreground service rejects id zero", () async { + final service = _makeService(); + final result = await service.invokeMethod("start_foreground_service", { + "id": 0, + "title": "t", + "body": "b", + "payload": "", + "actions": null, + "style": null, + "channel_id": null, + }); + expect(result, startsWith("error:invalid_service_id:")); + }); + + // ── Options surface (per-platform details) ─────────────────────────── + + test("show applies AndroidOptions onto the details", () async { + final service = _makeService(); + final result = await service.invokeMethod("show", { + "id": 1, + "title": "t", + "body": "b", + "payload": "", + "actions": null, + "style": null, + "channel_id": null, + "options": { + "android": { + "ongoing": true, + "silent": true, + "category": "alarm", + "importance": "high", + "priority": "max", + "color": "#FF0000", + "sub_text": "details", + "number": 4, + "visibility": "private", + "timeout_after": 60000, + "full_screen_intent": true, + "vibration_pattern": [0, 250, 100], + "play_sound": false, + "sound": "alert_tone", + }, + }, + }); + expect(result, "ok"); + final details = + plugin.shown.single["details"] as fln.AndroidNotificationDetails; + expect(details.ongoing, isTrue); + expect(details.silent, isTrue); + expect(details.category, fln.AndroidNotificationCategory.alarm); + expect(details.importance, fln.Importance.high); + expect(details.priority, fln.Priority.max); + expect(details.color, const Color(0xFFFF0000)); + expect(details.subText, "details"); + expect(details.number, 4); + expect(details.visibility, fln.NotificationVisibility.private); + expect(details.timeoutAfter, 60000); + expect(details.fullScreenIntent, isTrue); + expect(details.vibrationPattern!.toList(), [0, 250, 100]); + expect(details.playSound, isFalse); + expect(details.sound!.sound, "alert_tone"); + }); + + test("show applies DarwinOptions on the macOS platform", () async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final macos = _FakeMacOsPlugin(); + fln.FlutterLocalNotificationsPlatform.instance = macos; + final service = _makeService(); + final result = await service.invokeMethod("show", { + "id": 2, + "title": "t", + "body": "b", + "payload": "", + "actions": null, + "style": null, + "channel_id": null, + "options": { + "iOS": { + "badge_number": 3, + "subtitle": "sub", + "thread_identifier": "thread1", + "interruption_level": "passive", + "sound": "chime", + }, + "macOS": { + "badge_number": 1, + "subtitle": "sub", + "thread_identifier": "thread1", + "interruption_level": "passive", + "sound": "chime", + }, + }, + }); + expect(result, "ok"); + // Running on macOS applies the *macOS* options (badge 1), not iOS's. + final details = macos.shownDarwin!; + expect(details.badgeNumber, 1); + expect(details.subtitle, "sub"); + expect(details.threadIdentifier, "thread1"); + expect(details.interruptionLevel, fln.InterruptionLevel.passive); + expect(details.sound, "chime"); + }); + + test("unknown Android category surfaces as an error", () async { + final service = _makeService(); + final result = await service.invokeMethod("show", { + "id": 3, + "title": "t", + "body": "b", + "payload": "", + "actions": null, + "style": null, + "channel_id": null, + "options": { + "android": {"category": "bogus"}, + }, + }); + expect(result, startsWith("error:invalid_option:")); + expect(plugin.shown, isEmpty); + }); + + test("foreground service forwards start type and service types", + () async { + final service = _makeService(); + final result = await service.invokeMethod("start_foreground_service", { + "id": 4, + "title": "t", + "body": "b", + "payload": "", + "actions": null, + "style": null, + "channel_id": null, + "start_type": "startStickyCompatibility", + "foreground_service_types": ["dataSync", "specialUse"], + }); + expect(result, "ok"); + expect(plugin.foregroundStarts, 1); + expect(plugin.lastStartType, fln.AndroidServiceStartType.startStickyCompatibility); + expect(plugin.lastForegroundServiceTypes, { + fln.AndroidServiceForegroundType.foregroundServiceTypeDataSync, + fln.AndroidServiceForegroundType.foregroundServiceTypeSpecialUse, + }); + }); + + test("channel group carries its description", () async { + final service = _makeService(); + final result = await service.invokeMethod( + "create_notification_channel_group", + { + "group_id": "g", + "name": "G", + "description": "the description", + }, + ); + expect(result, "ok"); + expect(plugin.groupsCreated.single.description, "the description"); + }); +}); +} diff --git a/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/parsers_test.dart b/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/parsers_test.dart new file mode 100644 index 0000000000..e41020cd57 --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/parsers_test.dart @@ -0,0 +1,205 @@ +import 'package:flet_notifications/src/parsers.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group("parseActions", () { + test("null yields an empty list", () { + expect(parseActions(null), isEmpty); + }); + + test("parses defaults", () { + final actions = parseActions([ + {"id": "open", "title": "Open"}, + ]); + expect(actions, hasLength(1)); + expect(actions.single.id, "open"); + expect(actions.single.title, "Open"); + expect(actions.single.destructive, isFalse); + expect(actions.single.foreground, isTrue); + expect(actions.single.cancelNotification, isTrue); + expect(actions.single.input, isNull); + }); + + test("parses flags and nested input", () { + final actions = parseActions([ + { + "id": "reply", + "title": "Reply", + "destructive": true, + "foreground": false, + "cancel_notification": false, + "input": { + "label": "Type here", + "choices": ["Yes", "No"], + "allow_free_form_input": false, + "allowed_mime_types": ["image/png"], + }, + }, + ]); + final action = actions.single; + expect(action.destructive, isTrue); + expect(action.foreground, isFalse); + expect(action.cancelNotification, isFalse); + final input = action.input!; + expect(input.label, "Type here"); + expect(input.choices, ["Yes", "No"]); + expect(input.allowFreeFormInput, isFalse); + expect(input.allowedMimeTypes, {"image/png"}); + }); + + test("rejects non-list input", () { + expect(() => parseActions("nope"), throwsFormatException); + }); + + test("rejects action without id", () { + expect( + () => parseActions([ + {"title": "Open"}, + ]), + throwsFormatException, + ); + }); + + test("rejects action without title", () { + expect( + () => parseActions([ + {"id": "open"}, + ]), + throwsFormatException, + ); + }); + + test("rejects non-map entry", () { + expect(() => parseActions(["open"]), throwsFormatException); + }); + }); + + group("parseInput", () { + test("null yields null", () { + expect(parseInput(null), isNull); + }); + + test("defaults", () { + final input = parseInput({})!; + expect(input.label, ""); + expect(input.choices, isEmpty); + expect(input.allowFreeFormInput, isTrue); + expect(input.allowedMimeTypes, isEmpty); + }); + + test("rejects non-map", () { + expect(() => parseInput("nope"), throwsFormatException); + }); + }); + group("parseStyle", () { + test("null yields null", () { + expect(parseStyle(null), isNull); + }); + + test("big_text", () { + final style = parseStyle({"type": "big_text", "big_text": "long"})!; + expect(style.type, "big_text"); + expect(style.bigText, "long"); + }); + + test("big_text without text throws", () { + expect(() => parseStyle({"type": "big_text"}), throwsFormatException); + }); + + test("big_picture requires an image", () { + expect( + () => parseStyle({"type": "big_picture"}), + throwsFormatException, + ); + final style = parseStyle({ + "type": "big_picture", + "image": {"file_path": "/tmp/x.png"}, + })!; + expect(style.image!.filePath, "/tmp/x.png"); + }); + + test("inbox", () { + final style = parseStyle({ + "type": "inbox", + "lines": ["a", "b"], + })!; + expect(style.lines, ["a", "b"]); + expect( + () => parseStyle({"type": "inbox", "lines": []}), + throwsFormatException, + ); + }); + + test("messaging with person and messages", () { + final style = parseStyle({ + "type": "messaging", + "person": {"name": "Ada"}, + "messages": [ + { + "text": "hi", + "timestamp": "2030-01-01T10:00:00", + "person": {"name": "Ada"}, + }, + ], + "conversation_title": "Chat", + "group_conversation": true, + })!; + expect(style.person!.name, "Ada"); + expect(style.messages.single.text, "hi"); + expect(style.conversationTitle, "Chat"); + expect(style.groupConversation, isTrue); + }); + + test("messaging without person throws", () { + expect(() => parseStyle({"type": "messaging"}), throwsFormatException); + }); + + test("unknown style type throws", () { + expect(() => parseStyle({"type": "carousel"}), throwsFormatException); + }); + }); + + group("parseIcon", () { + test("null yields null", () { + expect(parseIcon(null), isNull); + }); + + test("drawable source", () { + expect( + parseIcon({"drawable_resource": "ic_star"})!.drawableResource, + "ic_star", + ); + }); + + test("requires exactly one source", () { + expect(() => parseIcon({}), throwsFormatException); + expect( + () => parseIcon({"drawable_resource": "ic", "file_path": "/x"}), + throwsFormatException, + ); + }); + + test("base64 data is decoded", () { + // "hi" in base64 + final icon = parseIcon({"data_base64": "aGk="})!; + expect(icon.data, [104, 105]); + }); + }); + + group("parsePerson", () { + test("null yields null", () { + expect(parsePerson(null), isNull); + }); + + test("defaults", () { + final person = parsePerson({"name": "Bob"})!; + expect(person.name, "Bob"); + expect(person.bot, isFalse); + expect(person.important, isFalse); + }); + + test("requires a name", () { + expect(() => parsePerson({"key": "x"}), throwsFormatException); + }); + }); +} diff --git a/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/response_events_test.dart b/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/response_events_test.dart new file mode 100644 index 0000000000..f44e3932b0 --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/response_events_test.dart @@ -0,0 +1,222 @@ +import 'dart:convert'; + +import 'package:flet_notifications/src/background_handler.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart' + as fln; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; + +/// In-memory store so the tap queue can be tested without platform channels. +class _FakeStore extends SharedPreferencesStorePlatform { + final Map values = {}; + + @override + Future remove(String key) async { + values.remove(key); + return true; + } + + @override + Future setValue(String valueType, String key, Object value) async { + values[key] = value; + return true; + } + + @override + Future clear() async { + values.clear(); + return true; + } + + @override + Future> getAll() async { + return { + for (final entry in values.entries) entry.key: entry.value, + }; + } +} + +fln.NotificationResponse _response({ + int? id = 7, + String? actionId, + String? input, + String? payload, + fln.NotificationResponseType type = + fln.NotificationResponseType.selectedNotification, +}) { + return fln.NotificationResponse( + id: id, + actionId: actionId, + input: input, + payload: payload, + notificationResponseType: type, + ); +} + +void main() { + late _FakeStore store; + + setUp(() { + store = _FakeStore(); + SharedPreferencesStorePlatform.instance = store; + SharedPreferences.resetStatic(); + }); + + group("responseToEvent", () { + test("plain tap", () { + final event = responseToEvent(_response(payload: "hello")); + expect(event["id"], 7); + expect(event["action_id"], ""); + expect(event["payload"], "hello"); + expect(event["input"], ""); + expect(event["response_type"], "tap"); + }); + + test("action with inline reply", () { + final event = responseToEvent( + _response( + actionId: "reply", + input: "my answer", + payload: "hello", + type: fln.NotificationResponseType.selectedNotificationAction, + ), + ); + expect(event["action_id"], "reply"); + expect(event["input"], "my answer"); + expect(event["response_type"], "action"); + }); + + test("dismissed", () { + final event = responseToEvent( + _response( + type: fln.NotificationResponseType.notificationDismissed, + ), + ); + expect(event["action_id"], ""); + expect(event["response_type"], "dismissed"); + }); + + test("null payload and input become empty strings", () { + final event = responseToEvent(_response()); + expect(event["payload"], ""); + expect(event["input"], ""); + }); + }); + + group("drainTapQueue", () { + test("returns events in key order and removes them", () async { + final older = responseToEvent(_response(payload: "first")); + final newer = responseToEvent(_response(payload: "second")); + // The store contract returns keys with the legacy "flutter." prefix + // (the real method-channel store does the same). + store.setValue( + "String", + "flutter.flet_notifications_queue_100-1", + jsonEncode(older), + ); + store.setValue( + "String", + "flutter.flet_notifications_queue_200-2", + jsonEncode(newer), + ); + store.setValue("String", "flutter.unrelated_key", "keep me"); + + // Populate the store first: the SharedPreferences instance snapshots + // its keys at construction. + final prefs = await SharedPreferences.getInstance(); + final drained = await drainTapQueue(prefs); + expect(drained, hasLength(2)); + expect(drained.first["payload"], "first"); + expect(drained.last["payload"], "second"); + expect( + prefs.getKeys().where( + (k) => k.startsWith("flet_notifications_queue_"), + ), + isEmpty, + ); + expect(prefs.getString("unrelated_key"), "keep me"); + }); + + test("empty queue yields empty list", () async { + final prefs = await SharedPreferences.getInstance(); + expect(await drainTapQueue(prefs), isEmpty); + }); + + test("unreadable entries are dropped, not fatal", () async { + store.setValue( + "String", + "flutter.flet_notifications_queue_1-1", + "not json", + ); + final prefs = await SharedPreferences.getInstance(); + final drained = await drainTapQueue(prefs); + expect(drained, isEmpty); + // The bad entry is removed rather than left to wedge the queue. + expect(prefs.getKeys(), isNot(contains("flet_notifications_queue_1-1"))); + }); + }); + + group("launch fingerprint dedupe", () { + test("emits once per unique payload", () async { + final prefs = await SharedPreferences.getInstance(); + final event = responseToEvent(_response(payload: "hello")); + expect(await shouldEmitLaunch(prefs, event), isTrue); + expect(await shouldEmitLaunch(prefs, event), isFalse); + final other = responseToEvent(_response(payload: "changed")); + expect(await shouldEmitLaunch(prefs, other), isTrue); + }); + }); + + // ── Windows normalization ───────────────────────────────────────────── + +group("Windows response normalization", () { + test("actionId envelope is unwrapped to the button id", () { + // The Windows plugin returns the launch string as both actionId and + // payload; our Dart envelope carries id + payload as JSON. + const response = fln.NotificationResponse( + id: 7, + actionId: '{"id": "reply", "payload": "actions"}', + input: null, + payload: '{"id": "reply", "payload": "actions"}', + notificationResponseType: + fln.NotificationResponseType.selectedNotificationAction, + data: {"reply": "typed on windows"}, + ); + final event = responseToEvent(response); + expect(event["action_id"], "reply"); + expect(event["payload"], "actions"); + expect(event["input"], "typed on windows"); // normalized from data + expect(event["response_type"], "action"); + }); + + test("plain Android/Darwin action ids pass through untouched", () { + const response = fln.NotificationResponse( + id: 8, + actionId: "reply", + input: "typed text", + payload: "actions", + notificationResponseType: + fln.NotificationResponseType.selectedNotificationAction, + ); + final event = responseToEvent(response); + expect(event["action_id"], "reply"); + expect(event["input"], "typed text"); + expect(event["payload"], "actions"); + }); + + test("Windows tap with no data keeps an empty input", () { + const response = fln.NotificationResponse( + id: 9, + actionId: '', + input: null, + payload: '', + notificationResponseType: + fln.NotificationResponseType.selectedNotification, + ); + final event = responseToEvent(response); + expect(event["response_type"], "tap"); + expect(event["input"], ""); + }); +}); +} diff --git a/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/scheduling_test.dart b/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/scheduling_test.dart new file mode 100644 index 0000000000..6ffbb58767 --- /dev/null +++ b/sdk/python/packages/flet-notifications/src/flutter/flet_notifications/test/scheduling_test.dart @@ -0,0 +1,121 @@ +import 'package:flet_notifications/src/scheduling.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart' + as fln; +import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/data/latest_all.dart' as tz; +import 'package:timezone/timezone.dart' as tz; + +void main() { + setUpAll(() { + // getLocation() requires the database, which the service loads at init. + tz.initializeTimeZones(); + }); + group("parseWhen", () { + test("naive wall clock in the target zone", () { + final date = parseWhen({ + "when": {"iso": "2030-01-15T09:30:00", "time_zone": "Africa/Lagos"}, + }, tz.UTC); + expect(date.location.name, "Africa/Lagos"); + expect(date.year, 2030); + expect(date.month, 1); + expect(date.day, 15); + expect(date.hour, 9); + expect(date.minute, 30); + }); + + test("naive without a zone uses the fallback location", () { + tz.setLocalLocation(tz.getLocation("Europe/Berlin")); + addTearDown(() => tz.setLocalLocation(tz.getLocation("UTC"))); + final date = parseWhen({ + "when": {"iso": "2030-03-01T08:00:00", "time_zone": null}, + }, tz.local); + expect(date.location.name, "Europe/Berlin"); + expect(date.hour, 8); + }); + + test("aware datetime preserves the instant", () { + // 12:00+01:00 must stay 11:00 UTC in the target zone. + final date = parseWhen({ + "when": { + "iso": "2030-06-01T12:00:00+01:00", + "utc_offset_seconds": 3600, + "time_zone": "UTC", + }, + }, tz.local); + expect(date.hour, 11); + expect(date.minute, 0); + expect(date.location.name, "UTC"); + }); + + test("delay from now lands in the future", () { + final date = parseWhen({ + "when": {"delay_seconds": 90, "time_zone": "UTC"}, + }, tz.UTC); + expect( + date.difference(tz.TZDateTime.now(tz.UTC)).inSeconds, + greaterThanOrEqualTo(85), + ); + }); + + test("missing when payload throws", () { + expect(() => parseWhen({"nope": 1}, tz.UTC), throwsFormatException); + }); + + test("when without iso or delay throws", () { + expect( + () => parseWhen({"when": {"time_zone": "UTC"}}, tz.UTC), + throwsFormatException, + ); + }); + }); + + group("enum maps", () { + test("schedule mode defaults to exactAllowWhileIdle", () { + expect( + parseScheduleMode(null), + fln.AndroidScheduleMode.exactAllowWhileIdle, + ); + expect(parseScheduleMode("alarmClock"), fln.AndroidScheduleMode.alarmClock); + expect(parseScheduleMode("exact"), fln.AndroidScheduleMode.exact); + expect(parseScheduleMode("inexact"), fln.AndroidScheduleMode.inexact); + expect( + parseScheduleMode("inexactAllowWhileIdle"), + fln.AndroidScheduleMode.inexactAllowWhileIdle, + ); + }); + + test("match components", () { + expect(parseMatchComponents(null), isNull); + expect(parseMatchComponents("time"), fln.DateTimeComponents.time); + expect( + parseMatchComponents("dayOfWeekAndTime"), + fln.DateTimeComponents.dayOfWeekAndTime, + ); + expect( + parseMatchComponents("dayOfMonthAndTime"), + fln.DateTimeComponents.dayOfMonthAndTime, + ); + expect( + parseMatchComponents("dateAndTime"), + fln.DateTimeComponents.dateAndTime, + ); + }); + + test("repeat interval defaults to daily", () { + expect(parseRepeatInterval(null), fln.RepeatInterval.daily); + expect( + parseRepeatInterval("everyMinute"), + fln.RepeatInterval.everyMinute, + ); + expect(parseRepeatInterval("hourly"), fln.RepeatInterval.hourly); + expect(parseRepeatInterval("weekly"), fln.RepeatInterval.weekly); + }); + + test("resolveLocation", () { + expect(resolveLocation("Africa/Lagos").name, "Africa/Lagos"); + expect(resolveLocation("UTC").name, "UTC"); + expect(resolveLocation("").name, tz.local.name); + expect(resolveLocation(null).name, tz.local.name); + }); + }); +} diff --git a/sdk/python/packages/flet/integration_tests/extensions/notifications/test_actions.py b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_actions.py new file mode 100644 index 0000000000..bf9c42ed4e --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_actions.py @@ -0,0 +1,96 @@ +"""Tests for notification actions and shared helpers.""" + +import pytest + +from flet_notifications import NotificationAction, NotificationActionInput +from flet_notifications.base import NotificationError, check_result, to_bool + + +def test_action_defaults(): + action = NotificationAction(id="open", title="Open") + assert action.destructive is False + assert action.foreground is True + assert action.cancel_notification is True + assert action.input is None + + +def test_action_to_dict(): + action = NotificationAction( + id="delete", + title="Delete", + destructive=True, + foreground=False, + cancel_notification=False, + ) + assert action.to_dict() == { + "id": "delete", + "title": "Delete", + "destructive": True, + "foreground": False, + "cancel_notification": False, + "input": None, + } + + +def test_action_with_input_to_dict(): + action = NotificationAction( + id="reply", + title="Reply", + input=NotificationActionInput( + label="Type here", + choices=["Yes", "No"], + allow_free_form_input=True, + allowed_mime_types=["image/png"], + ), + ) + d = action.to_dict() + assert d["input"] == { + "label": "Type here", + "choices": ["Yes", "No"], + "allow_free_form_input": True, + "allowed_mime_types": ["image/png"], + } + + +def test_input_defaults(): + inp = NotificationActionInput() + d = inp.to_dict() + assert d == { + "label": "", + "choices": [], + "allow_free_form_input": True, + "allowed_mime_types": [], + } + + +def test_check_result_ok(): + check_result("ok") # must not raise + check_result("true") + check_result(None) + + +def test_check_result_error(): + with pytest.raises(NotificationError) as exc: + check_result("error:cancel_failed:no such id") + assert exc.value.code == "cancel_failed" + assert exc.value.message == "no such id" + + +def test_check_result_error_without_message(): + with pytest.raises(NotificationError) as exc: + check_result("error:init_failed") + assert exc.value.code == "init_failed" + assert exc.value.message == "" + + +def test_to_bool_variants(): + assert to_bool("true") is True + assert to_bool("false") is False + assert to_bool(True) is True + assert to_bool(False) is False + + +def test_notification_error_repr(): + err = NotificationError("show_failed", "boom") + assert "show_failed" in repr(err) + assert "boom" in repr(err) diff --git a/sdk/python/packages/flet/integration_tests/extensions/notifications/test_channels.py b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_channels.py new file mode 100644 index 0000000000..3bb3fbfead --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_channels.py @@ -0,0 +1,256 @@ +"""Tests for notification channels.""" + +import pytest + +from flet_notifications import ( + BigTextStyle, + Importance, + LocalNotifications, + NotificationAction, + NotificationChannel, + NotificationError, +) + + +def test_channel_defaults(): + channel = NotificationChannel(id="alerts", name="Alerts") + d = channel.to_dict() + assert d["importance"] == "defaultImportance" + assert d["play_sound"] is True + assert d["enable_vibration"] is True + assert d["show_badge"] is True + assert d["bypass_dnd"] is False + + +def test_channel_requires_id_and_name(): + with pytest.raises(ValueError, match="id"): + NotificationChannel(id="", name="Alerts") + with pytest.raises(ValueError, match="name"): + NotificationChannel(id="alerts", name="") + + +def test_channel_vibration_pattern(): + channel = NotificationChannel( + id="vib", name="Vibrate", vibration_pattern=[0, 250, 100] + ) + assert channel.to_dict()["vibration_pattern"] == [0, 250, 100] + + +def test_channel_led_color_validated(): + channel = NotificationChannel( + id="led", name="LED", led_color="#FF0000", enable_lights=True + ) + assert channel.led_color == "#FF0000" + with pytest.raises(ValueError, match="hex"): + NotificationChannel(id="led", name="LED", led_color="red") + with pytest.raises(ValueError, match="hex"): + NotificationChannel(id="led", name="LED", led_color="#FFF") + + +def test_channel_from_dict_round_trip(): + original = NotificationChannel( + id="c1", + name="Channel One", + description="desc", + importance=Importance.HIGH, + bypass_dnd=True, + sound="alert", + ) + restored = NotificationChannel.from_dict(original.to_dict()) + assert restored == original + + +def test_importance_enum_values(): + assert Importance.HIGH.value == "high" + assert Importance.DEFAULT.value == "defaultImportance" + assert Importance.MAX.value == "max" + + +# ── Channel CRUD via the service ─────────────────────────────────────── + + +class _Recording(LocalNotifications): + def __init__(self): + super().__init__() + self.calls: list[tuple[str, dict | None]] = [] + + async def _invoke_method(self, method_name, arguments=None, timeout=None): + self.calls.append((method_name, arguments)) + return "ok" + + +@pytest.mark.asyncio +async def test_create_channel_invokes_wire_payload(): + service = _Recording() + channel = NotificationChannel( + id="alerts", + name="Alerts", + description="Important", + importance=Importance.HIGH, + sound="alert_tone", + vibration_pattern=[0, 250, 100], + group_id="updates", + ) + await service.create_notification_channel(channel) + name, args = service.calls[-1] + assert name == "create_notification_channel" + assert args == { + "id": "alerts", + "name": "Alerts", + "description": "Important", + "group_id": "updates", + "importance": "high", + "bypass_dnd": False, + "play_sound": True, + "sound": "alert_tone", + "enable_vibration": True, + "vibration_pattern": [0, 250, 100], + "show_badge": True, + "enable_lights": False, + "led_color": None, + } + + +@pytest.mark.asyncio +async def test_delete_channel_invokes_id(): + service = _Recording() + await service.delete_notification_channel("alerts") + assert service.calls[-1] == ( + "delete_notification_channel", + {"channel_id": "alerts"}, + ) + + +@pytest.mark.asyncio +async def test_get_channels_parses_and_error_raises(): + service = _Recording() + + async def invoke(method_name, arguments=None, timeout=None): + return [{"id": "a", "name": "A", "groupId": "updates", "importance": "low"}] + + service._invoke_method = invoke + channels = await service.get_notification_channels() + assert channels[0].group_id == "updates" + assert channels[0].importance == Importance.LOW + + async def fail(method_name, arguments=None, timeout=None): + return "error:channel_failed:android only" + + service._invoke_method = fail + with pytest.raises(NotificationError) as exc: + await service.get_notification_channels() + assert exc.value.code == "channel_failed" + + +@pytest.mark.asyncio +async def test_cancel_with_tag_forwards_tag(): + service = _Recording() + await service.cancel(5, tag="group") + assert service.calls[-1] == ( + "cancel", + {"id": 5, "tag": "group"}, + ) + await service.cancel(5) + assert service.calls[-1] == ("cancel", {"id": 5}) + + +@pytest.mark.asyncio +async def test_foreground_service_start_stop_and_guard(): + service = _Recording() + await service.start_foreground_service( + id=1, + title="Syncing", + body="…", + actions=[NotificationAction(id="done", title="Done")], + style=BigTextStyle(big_text="long"), + show_progress=True, + max_progress=50, + progress=10, + channel_id="c", + ) + name, args = service.calls[-1] + assert name == "start_foreground_service" + assert args["actions"] == [ + { + "id": "done", + "title": "Done", + "destructive": False, + "foreground": True, + "cancel_notification": True, + "input": None, + } + ] + assert args["style"]["type"] == "big_text" + assert args["show_progress"] is True + assert args["max_progress"] == 50 + assert args["channel_id"] == "c" + + await service.stop_foreground_service() + assert service.calls[-1] == ("stop_foreground_service", None) + + with pytest.raises(ValueError, match="non-zero"): + await service.start_foreground_service(id=0) + + +@pytest.mark.asyncio +async def test_full_screen_intent_permission(): + service = _Recording() + + async def invoke(method_name, arguments=None, timeout=None): + service.calls.append((method_name, timeout)) + return "true" + + service._invoke_method = invoke + assert await service.request_full_screen_intent_permission() is True + assert service.calls[-1] == ("request_full_screen_intent", 300) + + +def test_all_error_codes_declared(): + from flet_notifications.base import NotificationErrorCode as C + + codes = { + "unsupported_platform", + "init_failed", + "show_failed", + "schedule_failed", + "invalid_action", + "invalid_schedule", + "invalid_style", + "invalid_channel", + "invalid_service_id", + "invalid_option", + "channel_failed", + "foreground_service_failed", + "query_failed", + "permission_failed", + "unknown_method", + } + assert { + C.UNSUPPORTED_PLATFORM, + C.INIT_FAILED, + C.SHOW_FAILED, + C.SCHEDULE_FAILED, + C.INVALID_ACTION, + C.INVALID_SCHEDULE, + C.INVALID_STYLE, + C.INVALID_CHANNEL, + C.INVALID_SERVICE_ID, + C.INVALID_OPTION, + C.CHANNEL_FAILED, + C.FOREGROUND_SERVICE_FAILED, + C.QUERY_FAILED, + C.PERMISSION_FAILED, + C.UNKNOWN_METHOD, + } == codes + + +def test_hex_color_eight_digit(): + channel = NotificationChannel( + id="led", name="LED", led_color="#80FF0000", enable_lights=True + ) + assert channel.led_color == "#80FF0000" + + +def test_channel_from_dict_unknown_importance_rejected(): + with pytest.raises(ValueError): + NotificationChannel.from_dict({"id": "x", "name": "X", "importance": "bogus"}) diff --git a/sdk/python/packages/flet/integration_tests/extensions/notifications/test_events.py b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_events.py new file mode 100644 index 0000000000..678cc62cf0 --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_events.py @@ -0,0 +1,169 @@ +"""Tests for NotificationEvent hydration from Dart payloads.""" + + +from flet.utils import from_dict +from flet_notifications import LocalNotifications, NotificationEvent, ResponseType + + +def _control(): + return LocalNotifications() + + +def test_event_from_tap_payload(): + control = _control() + event = from_dict( + NotificationEvent, + { + "control": control, + "name": "tap", + "id": 5, + "action_id": "", + "payload": "hello", + "input": "", + "response_type": "tap", + }, + ) + assert event.id == 5 + assert event.action_id == "" + assert event.payload == "hello" + assert event.input == "" + assert event.response_type == ResponseType.TAP.value + + +def test_event_from_action_payload(): + control = _control() + event = from_dict( + NotificationEvent, + { + "control": control, + "name": "action", + "id": 5, + "action_id": "reply", + "payload": "hello", + "input": "my reply text", + "response_type": "action", + }, + ) + assert event.action_id == "reply" + assert event.input == "my reply text" + assert event.response_type == ResponseType.ACTION.value + + +def test_event_from_dismissed_payload(): + control = _control() + event = from_dict( + NotificationEvent, + { + "control": control, + "name": "dismissed", + "id": 5, + "response_type": "dismissed", + }, + ) + assert event.response_type == ResponseType.DISMISSED.value + + +def test_event_defaults(): + control = _control() + event = from_dict(NotificationEvent, {"control": control, "name": "tap"}) + assert event.id == 0 + assert event.action_id == "" + assert event.payload == "" + assert event.input == "" + assert event.response_type == ResponseType.TAP.value + + +# ── Typed-event resolution regressions ────────────────────────────────── +# Guards against the ControlEventHandler[X] double-wrap bug: typed payload +# events must resolve to the dataclass, and dispatch must call the handler +# with a hydrated event (not a wrapper). + + +def test_event_field_types_resolve_to_dataclass(): + """Typed payload events must resolve to the dataclass, not Event[...]. + + Regression guard: ``from __future__ import annotations`` in the service + module makes flet's resolver raise, silently breaking dispatch. + """ + from flet.controls.control_event import ControlEvent, get_event_field_type + + service = LocalNotifications( + on_tap=lambda e: None, on_action=lambda e: None, on_dismissed=lambda e: None + ) + for field_name in ("on_tap", "on_action", "on_dismissed"): + resolved = get_event_field_type(service, field_name) + assert resolved is not None, field_name + assert resolved != ControlEvent, field_name + base = getattr(resolved, "__origin__", resolved) + assert base is NotificationEvent, ( + f"{field_name} resolved to {resolved!r}, expected " + "ft.EventHandler[NotificationEvent[...]]" + ) + + +def test_handlers_use_event_handler_not_control_event_handler(): + """The annotation must be real objects (not PEP 563 strings) of the + typed-payload form.""" + service = LocalNotifications() + for field_name in ("on_tap", "on_action", "on_dismissed"): + handler = getattr(service, field_name) + assert handler is None + hints = LocalNotifications.__annotations__ + for field_name in ("on_tap", "on_action", "on_dismissed"): + annotation = hints[field_name] + assert not isinstance(annotation, str), ( + f"{field_name} annotation is a string — Flet's event resolver " + "cannot evaluate it (remove future annotations)" + ) + # EventHandler[T] expands to Callable[[T], Any]; the payload class + # must appear directly as the callable's first parameter. + text = str(annotation) + assert "NotificationEvent" in text, field_name + assert "Callable[[flet_notifications.base.NotificationEvent" in text, ( + f"{field_name} is not typed on NotificationEvent: {text}" + ) + + +def test_handler_assignment_and_hydrated_event(): + service = LocalNotifications() + + async def handler(event): + pass + + service.on_tap = handler + assert service.on_tap is handler + # The handler is invoked by the base control with the event hydrated from + # the Dart payload; hydrate the same payload directly to verify the + # mapping the handler receives. + event = from_dict( + NotificationEvent, + { + "control": service, + "name": "tap", + "id": 5, + "payload": "p", + }, + ) + assert isinstance(event, NotificationEvent) + assert event.payload == "p" + + +def test_windows_input_data_reaches_event(): + from flet.utils import from_dict + + service = LocalNotifications() + event = from_dict( + NotificationEvent, + { + "control": service, + "name": "action", + "id": 1, + "action_id": "reply", + "payload": "", + "response_type": "action", + "input": "", + "data": {"textBox": "typed on windows"}, + }, + ) + # The inherited `data` field carries Windows input values by input id. + assert event.data == {"textBox": "typed on windows"} diff --git a/sdk/python/packages/flet/integration_tests/extensions/notifications/test_options.py b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_options.py new file mode 100644 index 0000000000..e48764cc55 --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_options.py @@ -0,0 +1,184 @@ +"""Tests for the per-platform options surface.""" + +import pytest + +from flet_notifications import ( + AndroidOptions, + DarwinOptions, + LinuxOptions, + LocalNotifications, + NotificationOptions, + WebOptions, + WindowsOptions, +) + + +def test_options_to_dict_omits_none(): + options = NotificationOptions( + android=AndroidOptions(silent=True), + iOS=DarwinOptions(badge_number=3), + ) + d = options.to_dict() + assert d["android"] == {"silent": True} + assert d["iOS"] == {"badge_number": 3} + assert d["macOS"] is None + assert d["linux"] is None + + +def test_android_options_full_round_trip(): + options = AndroidOptions( + channel_description="Alerts channel", + channel_bypass_dnd=True, + icon="ic_stat", + importance="high", + priority="max", + play_sound=False, + sound="alert_tone", + enable_vibration=False, + vibration_pattern=[0, 250, 100], + channel_show_badge=False, + auto_cancel=False, + ongoing=True, + silent=True, + color="#FF0000", + colorized=True, + large_icon="content://icon", + only_alert_once=True, + show_when=False, + when=1700000000000, + uses_chronometer=True, + chronometer_count_down=True, + sub_text="details", + number=4, + visibility="private", + timeout_after=60000, + category="alarm", + full_screen_intent=True, + shortcut_id="sc", + additional_flags=["FLAG_INSISTENT"], + ticker="tick", + tag="tag1", + enable_lights=True, + led_color="#8000FF00", + led_on_ms=500, + led_off_ms=500, + ) + d = options.to_dict() + assert d["ongoing"] is True + assert d["color"] == "#FF0000" + assert d["vibration_pattern"] == [0, 250, 100] + assert d["category"] == "alarm" + assert d["led_color"] == "#8000FF00" + assert len(d) == 35 + + +def test_android_options_hex_validation(): + with pytest.raises(ValueError, match="#RRGGBB"): + AndroidOptions(color="red") + with pytest.raises(ValueError, match="#RRGGBB"): + AndroidOptions(led_color="#FFF") + + +def test_darwin_options(): + d = DarwinOptions( + sound="chime", + badge_number=0, + subtitle="sub", + thread_identifier="thread1", + interruption_level="timeSensitive", + critical_sound_volume=0.5, + attachments=["/tmp/a.png"], + present_banner=False, + ).to_dict() + assert d["badge_number"] == 0 + assert d["interruption_level"] == "timeSensitive" + assert d["attachments"] == ["/tmp/a.png"] + assert d["present_banner"] is False + + +def test_linux_windows_web_options(): + assert LinuxOptions(urgency="critical", timeout_ms=5000).to_dict() == { + "urgency": "critical", + "timeout_ms": 5000, + } + assert WindowsOptions(scenario="alarm", duration="long").to_dict() == { + "scenario": "alarm", + "duration": "long", + } + assert WebOptions(require_interaction=True, icon_url="x").to_dict() == { + "require_interaction": True, + "icon_url": "x", + } + + +@pytest.mark.asyncio +async def test_show_forwards_options(): + class Recording(LocalNotifications): + def __init__(self): + super().__init__() + self.calls = [] + + async def _invoke_method(self, method_name, arguments=None, timeout=None): + self.calls.append((method_name, arguments)) + return "ok" + + service = Recording() + options = NotificationOptions(android=AndroidOptions(ongoing=True)) + await service.show(id=1, title="t", options=options) + _, args = service.calls[-1] + assert args["options"] == { + "android": {"ongoing": True}, + "iOS": None, + "macOS": None, + "linux": None, + "windows": None, + "web": None, + } + + +@pytest.mark.asyncio +async def test_schedule_and_periodic_forward_options(): + from datetime import timedelta + + from flet_notifications import RepeatInterval + + class Recording(LocalNotifications): + def __init__(self): + super().__init__() + self.calls = [] + + async def _invoke_method(self, method_name, arguments=None, timeout=None): + self.calls.append((method_name, arguments)) + return "ok" + + service = Recording() + options = NotificationOptions(iOS=DarwinOptions(interruption_level="passive")) + await service.schedule(id=1, when=timedelta(seconds=5), options=options) + assert service.calls[-1][1]["options"]["iOS"] == {"interruption_level": "passive"} + await service.periodically_show( + id=2, repeat_interval=RepeatInterval.HOURLY, options=options + ) + assert service.calls[-1][1]["options"]["iOS"] == {"interruption_level": "passive"} + + +@pytest.mark.asyncio +async def test_fgs_forwards_start_type_and_types(): + class Recording(LocalNotifications): + def __init__(self): + super().__init__() + self.calls = [] + + async def _invoke_method(self, method_name, arguments=None, timeout=None): + self.calls.append((method_name, arguments)) + return "ok" + + service = Recording() + await service.start_foreground_service( + id=1, + title="t", + start_type="startStickyForeground", + foreground_service_types=["dataSync", "specialUse"], + ) + _, args = service.calls[-1] + assert args["start_type"] == "startStickyForeground" + assert args["foreground_service_types"] == ["dataSync", "specialUse"] diff --git a/sdk/python/packages/flet/integration_tests/extensions/notifications/test_patcher.py b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_patcher.py new file mode 100644 index 0000000000..98de8992d2 --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_patcher.py @@ -0,0 +1,268 @@ +"""Tests for the Android post-build patcher.""" + +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +from flet_notifications.patcher import patch_gradle, patch_manifest + +ANDROID_NS = "http://schemas.android.com/apk/res/android" + +MANIFEST_TEMPLATE = """ + + + + + + +""" + +GRADLE_TEMPLATE = """android { + namespace = "com.example.app" + defaultConfig { + applicationId = "com.example.app" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + } + dependencies {} +} +""" + + +@pytest.fixture +def manifest(tmp_path: Path) -> Path: + path = tmp_path / "AndroidManifest.xml" + path.write_text(MANIFEST_TEMPLATE, encoding="utf-8") + return path + + +@pytest.fixture +def gradle(tmp_path: Path) -> Path: + path = tmp_path / "build.gradle.kts" + path.write_text(GRADLE_TEMPLATE, encoding="utf-8") + return path + + +def _root(manifest: Path) -> ET.Element: + return ET.parse(manifest).getroot() + + +def _receiver_names(manifest: Path) -> set[str]: + app = _root(manifest).find("application") + return {r.get(f"{{{ANDROID_NS}}}name") for r in app.findall("receiver")} + + +def test_patch_adds_receivers_and_permissions(manifest: Path): + changed = patch_manifest(manifest) + receivers = _receiver_names(manifest) + assert ( + "com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver" in receivers + ) + assert ( + "com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" + in receivers + ) + assert ( + "com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver" + in receivers + ) + root = _root(manifest) + perms = {e.get(f"{{{ANDROID_NS}}}name") for e in root.findall("uses-permission")} + assert "android.permission.POST_NOTIFICATIONS" in perms + assert "android.permission.RECEIVE_BOOT_COMPLETED" in perms + assert "android.permission.SCHEDULE_EXACT_ALARM" in perms + assert "android.permission.INTERNET" in perms # untouched + assert len(changed) >= 7 + + +def test_patch_is_idempotent(manifest: Path): + first = patch_manifest(manifest) + assert first # something changed + snapshot = manifest.read_text(encoding="utf-8") + second = patch_manifest(manifest) + assert second == [] + assert manifest.read_text(encoding="utf-8") == snapshot + + +def test_boot_receiver_gets_intent_filters(manifest: Path): + patch_manifest(manifest) + app = _root(manifest).find("application") + boot = next( + r + for r in app.findall("receiver") + if r.get(f"{{{ANDROID_NS}}}name", "").endswith( + "ScheduledNotificationBootReceiver" + ) + ) + actions = { + e.get(f"{{{ANDROID_NS}}}name") + for f in boot.findall("intent-filter") + for e in f.findall("action") + } + assert "android.intent.action.BOOT_COMPLETED" in actions + assert "android.intent.action.QUICKBOOT_POWERON" in actions + assert boot.get(f"{{{ANDROID_NS}}}exported") == "false" + + +def test_gradle_patch_enables_desugaring(gradle: Path): + changed = patch_gradle(gradle) + text = gradle.read_text(encoding="utf-8") + assert "isCoreLibraryDesugaringEnabled = true" in text + assert "desugar_jdk_libs:2.1.4" in text + assert "multiDexEnabled = true" in text + assert len(changed) == 3 + + +def test_gradle_patch_is_idempotent(gradle: Path): + patch_gradle(gradle) + snapshot = gradle.read_text(encoding="utf-8") + assert patch_gradle(gradle) == [] + assert gradle.read_text(encoding="utf-8") == snapshot + + +def test_gradle_patch_missing_file_is_noop(tmp_path: Path): + assert patch_gradle(tmp_path / "nope.kts") == [] + + +REALISTIC_GRADLE = """android { + namespace = "com.example.app" + defaultConfig { + applicationId = "com.example.app" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + } + dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib:1.9.0") + } +} +""" + +FGS_TYPES = { + "camera", + "connectedDevice", + "dataSync", + "health", + "location", + "mediaPlayback", + "mediaProjection", + "microphone", + "phoneCall", + "remoteMessaging", + "shortService", + "specialUse", + "systemExempted", +} + + +def test_gradle_patch_handles_realistic_dependencies_block(tmp_path: Path): + from flet_notifications.patcher import patch_gradle + + gradle = tmp_path / "build.gradle.kts" + gradle.write_text(REALISTIC_GRADLE, encoding="utf-8") + changed = patch_gradle(gradle) + text = gradle.read_text(encoding="utf-8") + assert "isCoreLibraryDesugaringEnabled = true" in text + assert "desugar_jdk_libs:2.1.4" in text + assert "multiDexEnabled = true" in text + # The original dependency must survive the insertion. + assert 'implementation("org.jetbrains.kotlin:kotlin-stdlib:1.9.0")' in text + assert len(changed) == 3 + # Idempotent on the realistic file too. + assert patch_gradle(gradle) == [] + + +def test_gradle_patch_no_dependencies_block_reports_nothing(capsys): + import tempfile + + from flet_notifications.patcher import patch_gradle + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "build.gradle.kts" + path.write_text('android {\n namespace = "x"\n}\n', encoding="utf-8") + changed = patch_gradle(path) + assert changed == [] + captured = capsys.readouterr() + assert "warning" in captured.err + assert "no `dependencies {`" in captured.err + + +def test_patch_manifest_repairs_incomplete_boot_receiver(tmp_path: Path): + from flet_notifications.patcher import patch_manifest + + manifest = tmp_path / "AndroidManifest.xml" + manifest.write_text( + MANIFEST_TEMPLATE.replace( + ' + ", + """ + """, + ) + manifest.write_text(partial, encoding="utf-8") + patch_manifest(manifest) + root = ET.parse(manifest).getroot() + app = root.find("application") + boot = next( + r + for r in app.findall("receiver") + if r.get(f"{{{ANDROID_NS}}}name", "").endswith("BootReceiver") + ) + actions = { + e.get(f"{{{ANDROID_NS}}}name") + for f in boot.findall("intent-filter") + for e in f.findall("action") + } + assert "android.intent.action.BOOT_COMPLETED" in actions + assert "android.intent.action.QUICKBOOT_POWERON" in actions + + +def test_foreground_service_adds_service_and_permissions(manifest: Path): + from flet_notifications.patcher import patch_foreground_service + + changed = patch_foreground_service(manifest, "dataSync") + root = ET.parse(manifest).getroot() + app = root.find("application") + service = next( + s + for s in app.findall("service") + if s.get(f"{{{ANDROID_NS}}}name", "").endswith("ForegroundService") + ) + assert service.get(f"{{{ANDROID_NS}}}foregroundServiceType") == "dataSync" + perms = {e.get(f"{{{ANDROID_NS}}}name") for e in root.findall("uses-permission")} + assert "android.permission.FOREGROUND_SERVICE" in perms + assert "android.permission.FOREGROUND_SERVICE_DATA_SYNC" in perms + assert changed == ["foreground service (dataSync)"] + + +def test_foreground_service_rejects_unknown_type(manifest: Path): + from flet_notifications.patcher import patch_foreground_service + + with pytest.raises(ValueError, match="unknown foreground-service type"): + patch_foreground_service(manifest, "magic") + + +def test_foreground_service_is_idempotent(manifest: Path): + from flet_notifications.patcher import patch_foreground_service + + patch_foreground_service(manifest, "specialUse") + snapshot = manifest.read_text(encoding="utf-8") + assert patch_foreground_service(manifest, "specialUse") == [] + assert manifest.read_text(encoding="utf-8") == snapshot diff --git a/sdk/python/packages/flet/integration_tests/extensions/notifications/test_probes.py b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_probes.py new file mode 100644 index 0000000000..9f33f61886 --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_probes.py @@ -0,0 +1,269 @@ +"""Tests for the platform probes and OS-integration methods.""" + +import json + +import pytest + +from flet_notifications import ( + ActiveNotification, + Importance, + LocalNotifications, + NotificationError, + NotificationErrorCode, + PendingNotification, +) + + +class RecordingService(LocalNotifications): + def __init__(self): + super().__init__() + self.calls: list[tuple[str, dict | None]] = [] + + async def _invoke_method(self, method_name, arguments=None, timeout=None): + self.calls.append((method_name, arguments)) + return "ok" + + +@pytest.mark.asyncio +async def test_are_notifications_enabled(): + service = LocalNotifications() + + async def invoke(method_name, arguments=None, timeout=None): + return "true" + + service._invoke_method = invoke + assert await service.are_notifications_enabled() is True + + +@pytest.mark.asyncio +async def test_open_app_settings(): + service = RecordingService() + await service.open_app_notification_settings() + assert service.calls[-1] == ("open_app_settings", None) + + +@pytest.mark.asyncio +async def test_get_active_notifications_parses_records(): + service = LocalNotifications() + + async def invoke(method_name, arguments=None, timeout=None): + assert method_name == "get_active" + return [ + { + "id": 5, + "channel_id": "alerts", + "group_key": "g", + "tag": "t", + "title": "T", + "body": "B", + "payload": "p", + "big_text": "long", + } + ] + + service._invoke_method = invoke + active = await service.get_active_notifications() + assert active == [ + ActiveNotification( + id=5, + channel_id="alerts", + group_key="g", + tag="t", + title="T", + body="B", + payload="p", + big_text="long", + ) + ] + + +@pytest.mark.asyncio +async def test_get_active_notifications_empty_on_other_platforms(): + service = LocalNotifications() + + async def invoke(method_name, arguments=None, timeout=None): + return [] + + service._invoke_method = invoke + assert await service.get_active_notifications() == [] + + +@pytest.mark.asyncio +async def test_launch_details_not_from_notification(): + service = LocalNotifications() + + async def invoke(method_name, arguments=None, timeout=None): + return {"did_notification_launch_app": False} + + service._invoke_method = invoke + details = await service.get_notification_app_launch_details() + assert details.did_notification_launch_app is False + assert details.notification is None + + +@pytest.mark.asyncio +async def test_launch_details_from_notification(): + from flet.utils import from_dict + from flet_notifications import NotificationEvent + + service = LocalNotifications() + + async def invoke(method_name, arguments=None, timeout=None): + return { + "did_notification_launch_app": True, + "notification": { + "id": 9, + "action_id": "open", + "payload": "deep", + "input": "", + "response_type": "action", + }, + } + + service._invoke_method = invoke + details = await service.get_notification_app_launch_details() + assert details.did_notification_launch_app is True + assert details.notification is not None + assert isinstance(details.notification, NotificationEvent) + event = from_dict( + NotificationEvent, + {"control": service, "name": "tap", "payload": "deep"}, + ) + assert event.payload == "deep" + + +@pytest.mark.asyncio +async def test_launch_details_error_raises(): + service = LocalNotifications() + + async def invoke(method_name, arguments=None, timeout=None): + return "error:query_failed:nope" + + service._invoke_method = invoke + with pytest.raises(NotificationError) as exc: + await service.get_notification_app_launch_details() + assert exc.value.code == NotificationErrorCode.QUERY_FAILED + + +@pytest.mark.asyncio +async def test_policy_probes(): + service = LocalNotifications() + seen = [] + + async def invoke(method_name, arguments=None, timeout=None): + seen.append((method_name, timeout)) + return "false" + + service._invoke_method = invoke + assert await service.has_notification_policy_access() is False + assert await service.request_notification_policy_access() is False + assert seen[0][0] == "has_notification_policy_access" + assert seen[1][0] == "request_notification_policy_access" + assert seen[1][1] == 300 # permission screens take a while + + +@pytest.mark.asyncio +async def test_channel_groups(): + service = RecordingService() + await service.create_notification_channel_group("updates", "Updates") + assert service.calls[-1] == ( + "create_notification_channel_group", + { + "group_id": "updates", + "name": "Updates", + "description": "", + }, + ) + await service.delete_notification_channel_group("updates") + assert service.calls[-1] == ( + "delete_notification_channel_group", + {"group_id": "updates"}, + ) + + +def test_error_codes_documented(): + assert NotificationErrorCode.UNSUPPORTED_PLATFORM == "unsupported_platform" + assert NotificationErrorCode.INVALID_STYLE == "invalid_style" + assert NotificationErrorCode.INVALID_SCHEDULE == "invalid_schedule" + + +# ── Regression: the real transport returns JSON strings, not collections ── + + +@pytest.mark.asyncio +async def test_get_pending_decodes_json_string(): + service = LocalNotifications() + + async def invoke(method_name, arguments=None, timeout=None): + return json.dumps([{"id": 1, "title": "A", "body": "b", "payload": "p"}]) + + service._invoke_method = invoke + pending = await service.get_pending_notifications() + assert pending == [PendingNotification(id=1, title="A", body="b", payload="p")] + + +@pytest.mark.asyncio +async def test_get_active_decodes_json_string(): + service = LocalNotifications() + + async def invoke(method_name, arguments=None, timeout=None): + return json.dumps([{"id": 2, "title": "T", "payload": "p", "big_text": "long"}]) + + service._invoke_method = invoke + active = await service.get_active_notifications() + assert active[0].id == 2 + assert active[0].big_text == "long" + + +@pytest.mark.asyncio +async def test_get_channels_decodes_json_string(): + from flet_notifications import NotificationChannel + + service = LocalNotifications() + + async def invoke(method_name, arguments=None, timeout=None): + return json.dumps([{"id": "alerts", "name": "Alerts", "importance": "high"}]) + + service._invoke_method = invoke + channels = await service.get_notification_channels() + assert channels == [ + NotificationChannel(id="alerts", name="Alerts", importance=Importance.HIGH) + ] + + +@pytest.mark.asyncio +async def test_launch_details_decodes_json_string(): + service = LocalNotifications() + + async def invoke(method_name, arguments=None, timeout=None): + return json.dumps( + { + "did_notification_launch_app": True, + "notification": { + "id": 9, + "action_id": "open", + "payload": "deep", + "input": "", + "response_type": "action", + }, + } + ) + + service._invoke_method = invoke + details = await service.get_notification_app_launch_details() + assert details.did_notification_launch_app is True + assert details.notification is not None + assert details.notification.payload == "deep" + + +@pytest.mark.asyncio +async def test_malformed_json_raises_query_failed(): + service = LocalNotifications() + + async def invoke(method_name, arguments=None, timeout=None): + return "not json" + + service._invoke_method = invoke + with pytest.raises(NotificationError) as exc: + await service.get_pending_notifications() + assert exc.value.code == "query_failed" diff --git a/sdk/python/packages/flet/integration_tests/extensions/notifications/test_scheduling.py b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_scheduling.py new file mode 100644 index 0000000000..7949e1060e --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_scheduling.py @@ -0,0 +1,290 @@ +"""Tests for scheduling APIs.""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from flet_notifications import ( + AndroidScheduleMode, + DateTimeComponents, + LocalNotifications, + NotificationAction, + PendingNotification, + RepeatInterval, +) +from flet_notifications.scheduling import ( + build_periodic_args, + build_schedule_args, +) + + +class RecordingService(LocalNotifications): + def __init__(self): + super().__init__() + self.calls: list[tuple[str, dict | None]] = [] + + async def _invoke_method(self, method_name, arguments=None, timeout=None): + self.calls.append((method_name, arguments)) + return "ok" + + +def test_schedule_enum_wire_values(): + assert RepeatInterval.EVERY_MINUTE.value == "everyMinute" + assert RepeatInterval.HOURLY.value == "hourly" + assert RepeatInterval.DAILY.value == "daily" + assert RepeatInterval.WEEKLY.value == "weekly" + assert DateTimeComponents.TIME.value == "time" + assert DateTimeComponents.DAY_OF_WEEK_AND_TIME.value == "dayOfWeekAndTime" + assert DateTimeComponents.DAY_OF_MONTH_AND_TIME.value == "dayOfMonthAndTime" + assert DateTimeComponents.DATE_AND_TIME.value == "dateAndTime" + assert AndroidScheduleMode.ALARM_CLOCK.value == "alarmClock" + assert AndroidScheduleMode.EXACT.value == "exact" + assert AndroidScheduleMode.EXACT_ALLOW_WHILE_IDLE.value == "exactAllowWhileIdle" + assert AndroidScheduleMode.INEXACT.value == "inexact" + assert AndroidScheduleMode.INEXACT_ALLOW_WHILE_IDLE.value == ( + "inexactAllowWhileIdle" + ) + + +@pytest.mark.asyncio +async def test_schedule_naive_datetime_with_timezone(): + service = RecordingService() + when = datetime(2030, 1, 15, 9, 30) + await service.schedule( + id=1, + when=when, + title="t", + body="b", + time_zone="Africa/Lagos", + match_components=DateTimeComponents.TIME, + ) + name, args = service.calls[-1] + assert name == "schedule" + assert args["when"] == { + "iso": "2030-01-15T09:30:00", + "utc_offset_seconds": None, + "time_zone": "Africa/Lagos", + } + assert args["match_components"] == "time" + assert args["schedule_mode"] == "exactAllowWhileIdle" + assert args["options"] is None + + +@pytest.mark.asyncio +async def test_schedule_aware_datetime_carries_offset(): + service = RecordingService() + when = datetime(2030, 6, 1, 12, 0, tzinfo=timezone(timedelta(hours=1))) + await service.schedule(id=2, when=when) + _, args = service.calls[-1] + assert args["when"]["iso"] == "2030-06-01T12:00:00+01:00" + assert args["when"]["utc_offset_seconds"] == 3600 + assert args["when"]["time_zone"] is None + + +@pytest.mark.asyncio +async def test_schedule_timedelta_becomes_delay(): + service = RecordingService() + await service.schedule(id=3, when=timedelta(seconds=30)) + _, args = service.calls[-1] + assert args["when"] == { + "delay_seconds": 30.0, + "time_zone": None, + } + + +def test_schedule_signature_has_no_repeat_interval(): + import inspect + + params = inspect.signature(LocalNotifications.schedule).parameters + assert "repeat_interval" not in params + assert "match_components" in params + assert "options" in params + + +@pytest.mark.asyncio +async def test_schedule_with_actions(): + service = RecordingService() + await service.schedule( + id=5, + when=timedelta(minutes=5), + actions=[NotificationAction(id="open", title="Open")], + ) + _, args = service.calls[-1] + assert args["actions"] == [ + { + "id": "open", + "title": "Open", + "destructive": False, + "foreground": True, + "cancel_notification": True, + "input": None, + } + ] + + +@pytest.mark.asyncio +async def test_periodically_show_default(): + service = RecordingService() + await service.periodically_show(id=6, repeat_interval=RepeatInterval.HOURLY) + name, args = service.calls[-1] + assert name == "periodically_show" + assert args["repeat_interval"] == "hourly" + assert args["schedule_mode"] == "exactAllowWhileIdle" + assert args["repeat_duration_seconds"] is None + + +@pytest.mark.asyncio +async def test_periodically_show_custom_duration(): + service = RecordingService() + await service.periodically_show( + id=7, + repeat_interval=RepeatInterval.DAILY, + repeat_duration_seconds=900, + ) + _, args = service.calls[-1] + assert args["repeat_duration_seconds"] == 900 + + +def test_periodic_builder_rejects_sub_minute_duration(): + with pytest.raises(ValueError, match="at least 60"): + build_periodic_args( + id=1, + repeat_interval=RepeatInterval.DAILY, + title=None, + body=None, + payload="", + actions=[], + schedule_mode=AndroidScheduleMode.EXACT_ALLOW_WHILE_IDLE, + repeat_duration_seconds=30, + ) + + +@pytest.mark.asyncio +async def test_cancel_pending_and_probes(): + service = RecordingService() + await service.cancel_pending() + assert service.calls[-1] == ("cancel_pending", None) + + async def invoke(method_name, arguments=None, timeout=None): + service.calls.append((method_name, arguments)) + return "true" + + service._invoke_method = invoke + assert await service.can_schedule_exact_notifications() is True + assert await service.request_exact_alarm_permission() is True + assert service.calls[-1] == ("request_exact_alarm_permission", None) + + +@pytest.mark.asyncio +async def test_get_pending_parses_records(): + service = LocalNotifications() + + async def invoke(method_name, arguments=None, timeout=None): + if method_name == "get_pending": + return [ + {"id": 1, "title": "A", "body": "b", "payload": "p"}, + {"id": 2, "title": "B", "body": "", "payload": None}, + ] + return "ok" + + service._invoke_method = invoke + pending = await service.get_pending_notifications() + assert pending == [ + PendingNotification(id=1, title="A", body="b", payload="p"), + PendingNotification(id=2, title="B", body="", payload=""), + ] + + +@pytest.mark.asyncio +async def test_get_pending_error_raises(): + from flet_notifications import NotificationError + + service = LocalNotifications() + + async def invoke(method_name, arguments=None, timeout=None): + return "error:query_failed:unsupported" + + service._invoke_method = invoke + with pytest.raises(NotificationError) as exc: + await service.get_pending_notifications() + assert exc.value.code == "query_failed" + + +def test_build_schedule_args_defaults(): + args = build_schedule_args( + id=1, + when=timedelta(seconds=5), + title="t", + body="b", + payload="", + actions=[], + match_components=None, + schedule_mode=AndroidScheduleMode.EXACT_ALLOW_WHILE_IDLE, + time_zone=None, + ) + assert args["actions"] is None + assert args["match_components"] is None + assert args["schedule_mode"] == "exactAllowWhileIdle" + assert args["options"] is None + + +# ── Phase 3: rich details forwarding ────────────────────────────────── + + +def test_schedule_forwards_style_progress_grouping(): + from flet_notifications import BigTextStyle, GroupAlertBehavior + + service = RecordingService() + service.calls.clear() + # recorded via the synchronous RecordingService path + import asyncio + + asyncio.run( + service.schedule( + id=11, + when=timedelta(seconds=5), + style=BigTextStyle(big_text="long"), + show_progress=True, + max_progress=100, + progress=25, + group_key="grp", + set_as_group_summary=True, + group_alert_behavior=GroupAlertBehavior.SUMMARY, + channel_id="c1", + ) + ) + _, args = service.calls[-1] + assert args["style"] == { + "type": "big_text", + "big_text": "long", + "content_title": None, + "summary_text": None, + } + assert args["show_progress"] is True + assert args["max_progress"] == 100 + assert args["progress"] == 25 + assert args["group_key"] == "grp" + assert args["set_as_group_summary"] is True + assert args["group_alert_behavior"] == "summary" + assert args["channel_id"] == "c1" + + +def test_periodic_forwards_details(): + from flet_notifications import AndroidScheduleMode + + service = RecordingService() + service.calls.clear() + import asyncio + + asyncio.run( + service.periodically_show( + id=12, + repeat_interval=RepeatInterval.DAILY, + schedule_mode=AndroidScheduleMode.INEXACT, + channel_id="c2", + ) + ) + _, args = service.calls[-1] + assert args["schedule_mode"] == "inexact" + assert args["channel_id"] == "c2" + assert args["style"] is None diff --git a/sdk/python/packages/flet/integration_tests/extensions/notifications/test_service.py b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_service.py new file mode 100644 index 0000000000..d20615a882 --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_service.py @@ -0,0 +1,183 @@ +"""Tests for the LocalNotifications service.""" + +import pytest + +import flet_notifications as fn +from flet_notifications import ( + LocalNotifications, + NotificationAction, + NotificationActionInput, + NotificationError, +) + + +class RecordingService(LocalNotifications): + """Service whose Dart invokes are recorded instead of sent.""" + + def __init__(self): + super().__init__() + self.calls: list[tuple[str, dict | None, int | None]] = [] + + async def _invoke_method(self, method_name, arguments=None, timeout=None): + self.calls.append((method_name, arguments, timeout)) + return "ok" + + +def test_control_type_string(): + service = LocalNotifications() + assert service._c == "LocalNotifications" + + +def test_exports(): + assert fn.__all__ == [ + "ActiveNotification", + "AndroidIcon", + "AndroidOptions", + "AndroidScheduleMode", + "BigPictureStyle", + "BigTextStyle", + "DarwinOptions", + "DateTimeComponents", + "GroupAlertBehavior", + "Importance", + "InboxStyle", + "InterruptionLevel", + "LaunchDetails", + "LinuxOptions", + "LinuxUrgency", + "LocalNotifications", + "Message", + "MessagingStyle", + "NotificationAction", + "NotificationActionInput", + "NotificationChannel", + "NotificationError", + "NotificationErrorCode", + "NotificationEvent", + "NotificationOptions", + "PendingNotification", + "Person", + "Priority", + "RepeatInterval", + "ResponseType", + "Style", + "Visibility", + "WebOptions", + "WindowsOptions", + "WindowsScenario", + ] + assert fn.__version__ == "0.1.4" + + +def test_before_update_does_not_raise(): + """Notifications are supported on every platform; no guard expected.""" + service = LocalNotifications() + service.before_update() # must not raise + + +@pytest.mark.asyncio +async def test_show_invokes_dart_with_wire_args(): + service = RecordingService() + action = NotificationAction( + id="open", title="Open", input=NotificationActionInput(label="Say") + ) + await service.show(id=7, title="Hi", body="There", payload="x", actions=[action]) + name, args, _ = service.calls[-1] + assert name == "show" + assert args == { + "id": 7, + "title": "Hi", + "body": "There", + "payload": "x", + "actions": [ + { + "id": "open", + "title": "Open", + "destructive": False, + "foreground": True, + "cancel_notification": True, + "input": { + "label": "Say", + "choices": [], + "allow_free_form_input": True, + "allowed_mime_types": [], + }, + } + ], + "style": None, + "show_progress": False, + "max_progress": 0, + "progress": 0, + "indeterminate": False, + "group_key": None, + "set_as_group_summary": False, + "group_alert_behavior": "all", + "channel_id": None, + "options": None, + } + + +@pytest.mark.asyncio +async def test_show_without_actions(): + service = RecordingService() + await service.show(id=1, title="t", body="b") + name, args, _ = service.calls[-1] + assert name == "show" + assert args["actions"] is None + assert args["payload"] == "" + + +@pytest.mark.asyncio +async def test_cancel_and_cancel_all(): + service = RecordingService() + await service.cancel(3) + assert service.calls[-1] == ("cancel", {"id": 3}, None) + await service.cancel_all() + assert service.calls[-1] == ("cancel_all", None, None) + + +@pytest.mark.asyncio +async def test_request_permissions_uses_long_timeout(): + service = RecordingService() + + async def invoke(method_name, arguments=None, timeout=None): + service.calls.append((method_name, arguments, timeout)) + return "true" + + service._invoke_method = invoke + assert await service.request_permissions() is True + name, _, timeout = service.calls[-1] + assert name == "request_permissions" + assert timeout == 300 + + +@pytest.mark.asyncio +async def test_error_result_raises(): + service = LocalNotifications() + + async def fail(method_name, arguments=None, timeout=None): + return "error:show_failed:boom" + + service._invoke_method = fail + with pytest.raises(NotificationError) as exc: + await service.show(id=1, title="t") + assert exc.value.code == "show_failed" + assert exc.value.message == "boom" + + +@pytest.mark.asyncio +async def test_request_permissions_false(): + service = LocalNotifications() + + async def deny(method_name, arguments=None, timeout=None): + return "false" + + service._invoke_method = deny + assert await service.request_permissions() is False + + +def test_service_constructs_outside_page(): + """Services are headless; constructing without a page must not raise + (registration is simply deferred until a page exists).""" + service = LocalNotifications() + assert service._c == "LocalNotifications" diff --git a/sdk/python/packages/flet/integration_tests/extensions/notifications/test_styles.py b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_styles.py new file mode 100644 index 0000000000..95d4e4e446 --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/extensions/notifications/test_styles.py @@ -0,0 +1,108 @@ +"""Tests for notification styles.""" + +from datetime import datetime + +import pytest + +from flet_notifications import ( + AndroidIcon, + BigPictureStyle, + BigTextStyle, + InboxStyle, + Message, + MessagingStyle, + Person, + Style, +) + + +def test_android_icon_requires_exactly_one_source(): + with pytest.raises(ValueError, match="exactly one"): + AndroidIcon() + with pytest.raises(ValueError, match="exactly one"): + AndroidIcon(drawable_resource="ic", file_path="/tmp/x.png") + + +def test_android_icon_sources(): + assert AndroidIcon(drawable_resource="ic_star").to_dict() == { + "drawable_resource": "ic_star", + "file_path": None, + "content_uri": None, + "flutter_asset": None, + "data_base64": None, + } + assert AndroidIcon(flutter_asset="assets/a.png").to_dict()["flutter_asset"] == ( + "assets/a.png" + ) + + +def test_big_text_style_to_dict(): + style = BigTextStyle( + big_text="Long text", content_title="Title", summary_text="Summary" + ) + assert style.to_dict() == { + "type": "big_text", + "big_text": "Long text", + "content_title": "Title", + "summary_text": "Summary", + } + + +def test_big_picture_style_to_dict(): + style = BigPictureStyle( + image=AndroidIcon(file_path="/tmp/photo.jpg"), + large_icon=AndroidIcon(drawable_resource="ic"), + summary_text="2 photos", + ) + d = style.to_dict() + assert d["type"] == "big_picture" + assert d["image"] == AndroidIcon(file_path="/tmp/photo.jpg").to_dict() + assert d["large_icon"]["drawable_resource"] == "ic" + assert d["summary_text"] == "2 photos" + + +def test_inbox_style_requires_lines(): + with pytest.raises(ValueError, match="at least one line"): + InboxStyle(lines=[]) + style = InboxStyle(lines=["one", "two"]) + assert style.to_dict()["lines"] == ["one", "two"] + + +def test_messaging_style_to_dict(): + person = Person(name="Ada") + style = MessagingStyle( + person=person, + messages=[ + Message( + text="hi", + timestamp=datetime(2030, 1, 1, 10, 0), + person=person, + ) + ], + conversation_title="Team chat", + group_conversation=True, + ) + d = style.to_dict() + assert d["type"] == "messaging" + assert d["person"] == person.to_dict() + assert d["person"]["name"] == "Ada" + assert d["messages"][0]["timestamp"] == "2030-01-01T10:00:00" + assert d["conversation_title"] == "Team chat" + assert d["group_conversation"] is True + + +def test_person_to_dict(): + person = Person(name="Bob", key="b1", bot=True, uri="tel:123") + assert person.to_dict() == { + "name": "Bob", + "key": "b1", + "bot": True, + "important": False, + "uri": "tel:123", + "icon": None, + } + + +def test_style_union_membership(): + assert isinstance(BigTextStyle(big_text="x"), Style) + assert isinstance(InboxStyle(lines=["a"]), Style) diff --git a/sdk/python/packages/flet/pyproject.toml b/sdk/python/packages/flet/pyproject.toml index 8c3b3adf02..52e184c286 100644 --- a/sdk/python/packages/flet/pyproject.toml +++ b/sdk/python/packages/flet/pyproject.toml @@ -64,6 +64,7 @@ extensions = [ "flet-lottie", "flet-local-auth", "flet-map", + "flet-notifications", "flet-permission-handler", "flet-rive", "flet-secure-storage", diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index de13afe2ee..5b0d3fd572 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "flet-local-auth", "flet-map", "flet-mcp", + "flet-notifications", "flet-permission-handler", "flet-rive", "flet-secure-storage", @@ -54,6 +55,7 @@ flet-lottie = { workspace = true } flet-local-auth = { workspace = true } flet-map = { workspace = true } flet-mcp = { workspace = true } +flet-notifications = { workspace = true } flet-permission-handler = { workspace = true } flet-rive = { workspace = true } flet-secure-storage = { workspace = true } @@ -130,6 +132,7 @@ isort = { known-first-party = [ "flet_local_auth", "flet_map", "flet_mcp", + "flet_notifications", "flet_permission_handler", "flet_rive", "flet_secure_storage", diff --git a/tools/crocodocs/pyproject.toml b/tools/crocodocs/pyproject.toml index 8371906eaf..6fc9c3e23e 100644 --- a/tools/crocodocs/pyproject.toml +++ b/tools/crocodocs/pyproject.toml @@ -72,6 +72,7 @@ flet_geolocator = "../../sdk/python/packages/flet-geolocator/src" flet_lottie = "../../sdk/python/packages/flet-lottie/src" flet_local_auth = "../../sdk/python/packages/flet-local-auth/src" flet_map = "../../sdk/python/packages/flet-map/src" +flet_notifications = "../../sdk/python/packages/flet-notifications/src" flet_permission_handler = "../../sdk/python/packages/flet-permission-handler/src" flet_rive = "../../sdk/python/packages/flet-rive/src" flet_secure_storage = "../../sdk/python/packages/flet-secure-storage/src" diff --git a/website/docs/services/notifications/index.md b/website/docs/services/notifications/index.md new file mode 100644 index 0000000000..35ae713d65 --- /dev/null +++ b/website/docs/services/notifications/index.md @@ -0,0 +1,102 @@ +--- +class_name: "flet_notifications.LocalNotifications" +examples: "extensions/notifications" +title: "Notifications" +--- + +import TabItem from '@theme/TabItem'; +import Tabs from '@theme/Tabs'; +import {ClassAll, CodeExample} from '@site/src/components/crocodocs'; + +# Notifications + +Show, schedule and cancel system notifications with Flutter's [`flutter_local_notifications`](https://pub.dev/packages/flutter_local_notifications) plugin. + +## Platform Support + +| Platform | Windows | macOS | Linux | iOS | Android | Web | +|----------|---------|-------|-------|-----|---------|-----| +| Supported| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | + +## Setup + +### Android + +Scheduled notifications need permissions in the app's `pyproject.toml`: + +```toml +[tool.flet.android.permission] +RECEIVE_BOOT_COMPLETED = true # restore alarms after reboot +SCHEDULE_EXACT_ALARM = true # exact schedule modes +``` + +The plugin also needs its scheduled-notification receivers and the core-library-desugaring Gradle flags, which `[tool.flet.android.*]` cannot declare. Build the Android project, then inject them with the bundled `flet-notifications-patch` script: + +```bash +flet build apk --yes --project myapp --org com.example -v +flet-notifications-patch --project-root build/flutter +cd build/flutter/android && ./gradlew assembleRelease -Psplit-per-abi=true +``` + +### Web + +The browser only accepts `Notification.requestPermission()` from a user gesture. Call `request_permissions()` from an `on_click` handler. Notification action buttons work in Chrome and Edge only. + +## Usage + +Add `flet-notifications` to your project dependencies: + + + +```bash +uv add flet-notifications +``` + + + +```bash +pip install flet-notifications # (1)! +``` + +1. After this, you will have to manually add this package to your `requirements.txt` or `pyproject.toml`. + + + +```python +import flet as ft +from flet_notifications import LocalNotifications + + +def main(page: ft.Page): + notifications = LocalNotifications() + + async def on_tap(e): + print("tapped:", e.payload) + + notifications.on_tap = on_tap + + async def notify(e): + if await notifications.request_permissions(): + await notifications.show(id=1, title="Build finished", body="Android #42 succeeded") + + page.add(ft.OutlinedButton("Notify", on_click=notify)) + + +ft.run(main) +``` + +The service registers itself with the page when constructed. Taps that arrive while the app was not running are replayed on the next start. + +## Platform Notes + +- Linux and Web have no OS scheduler, and Windows schedules one-shot notifications only. +- `cancel` and the query methods need package identity (MSIX) on Windows. +- Interruption levels beyond `passive` on Apple platforms need Xcode capabilities. + +## Example + + + +## Description + + diff --git a/website/docs/services/notifications/types/activenotification.md b/website/docs/services/notifications/types/activenotification.md new file mode 100644 index 0000000000..1e07341e7b --- /dev/null +++ b/website/docs/services/notifications/types/activenotification.md @@ -0,0 +1,7 @@ +--- +title: "ActiveNotification" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/androidicon.md b/website/docs/services/notifications/types/androidicon.md new file mode 100644 index 0000000000..382fef1bbe --- /dev/null +++ b/website/docs/services/notifications/types/androidicon.md @@ -0,0 +1,7 @@ +--- +title: "AndroidIcon" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/androidoptions.md b/website/docs/services/notifications/types/androidoptions.md new file mode 100644 index 0000000000..171b07eb97 --- /dev/null +++ b/website/docs/services/notifications/types/androidoptions.md @@ -0,0 +1,7 @@ +--- +title: "AndroidOptions" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/androidschedulemode.md b/website/docs/services/notifications/types/androidschedulemode.md new file mode 100644 index 0000000000..6f480bd9e7 --- /dev/null +++ b/website/docs/services/notifications/types/androidschedulemode.md @@ -0,0 +1,7 @@ +--- +title: "AndroidScheduleMode" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/bigpicturestyle.md b/website/docs/services/notifications/types/bigpicturestyle.md new file mode 100644 index 0000000000..90ff67221b --- /dev/null +++ b/website/docs/services/notifications/types/bigpicturestyle.md @@ -0,0 +1,7 @@ +--- +title: "BigPictureStyle" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/bigtextstyle.md b/website/docs/services/notifications/types/bigtextstyle.md new file mode 100644 index 0000000000..3b55fd9b46 --- /dev/null +++ b/website/docs/services/notifications/types/bigtextstyle.md @@ -0,0 +1,7 @@ +--- +title: "BigTextStyle" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/darwinoptions.md b/website/docs/services/notifications/types/darwinoptions.md new file mode 100644 index 0000000000..071042376f --- /dev/null +++ b/website/docs/services/notifications/types/darwinoptions.md @@ -0,0 +1,7 @@ +--- +title: "DarwinOptions" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/datetimecomponents.md b/website/docs/services/notifications/types/datetimecomponents.md new file mode 100644 index 0000000000..9fc33d8bde --- /dev/null +++ b/website/docs/services/notifications/types/datetimecomponents.md @@ -0,0 +1,7 @@ +--- +title: "DateTimeComponents" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/groupalertbehavior.md b/website/docs/services/notifications/types/groupalertbehavior.md new file mode 100644 index 0000000000..0b86f9da54 --- /dev/null +++ b/website/docs/services/notifications/types/groupalertbehavior.md @@ -0,0 +1,7 @@ +--- +title: "GroupAlertBehavior" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/importance.md b/website/docs/services/notifications/types/importance.md new file mode 100644 index 0000000000..0e6b69f628 --- /dev/null +++ b/website/docs/services/notifications/types/importance.md @@ -0,0 +1,7 @@ +--- +title: "Importance" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/inboxstyle.md b/website/docs/services/notifications/types/inboxstyle.md new file mode 100644 index 0000000000..94f2883dab --- /dev/null +++ b/website/docs/services/notifications/types/inboxstyle.md @@ -0,0 +1,7 @@ +--- +title: "InboxStyle" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/interruptionlevel.md b/website/docs/services/notifications/types/interruptionlevel.md new file mode 100644 index 0000000000..c2b34a47a7 --- /dev/null +++ b/website/docs/services/notifications/types/interruptionlevel.md @@ -0,0 +1,7 @@ +--- +title: "InterruptionLevel" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/launchdetails.md b/website/docs/services/notifications/types/launchdetails.md new file mode 100644 index 0000000000..74378f37ee --- /dev/null +++ b/website/docs/services/notifications/types/launchdetails.md @@ -0,0 +1,7 @@ +--- +title: "LaunchDetails" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/linuxoptions.md b/website/docs/services/notifications/types/linuxoptions.md new file mode 100644 index 0000000000..f03a6b4b97 --- /dev/null +++ b/website/docs/services/notifications/types/linuxoptions.md @@ -0,0 +1,7 @@ +--- +title: "LinuxOptions" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/linuxurgency.md b/website/docs/services/notifications/types/linuxurgency.md new file mode 100644 index 0000000000..9cd7b11b0c --- /dev/null +++ b/website/docs/services/notifications/types/linuxurgency.md @@ -0,0 +1,7 @@ +--- +title: "LinuxUrgency" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/message.md b/website/docs/services/notifications/types/message.md new file mode 100644 index 0000000000..758c118658 --- /dev/null +++ b/website/docs/services/notifications/types/message.md @@ -0,0 +1,7 @@ +--- +title: "Message" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/messagingstyle.md b/website/docs/services/notifications/types/messagingstyle.md new file mode 100644 index 0000000000..b2e32cdbc6 --- /dev/null +++ b/website/docs/services/notifications/types/messagingstyle.md @@ -0,0 +1,7 @@ +--- +title: "MessagingStyle" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/notificationaction.md b/website/docs/services/notifications/types/notificationaction.md new file mode 100644 index 0000000000..2ba91afec4 --- /dev/null +++ b/website/docs/services/notifications/types/notificationaction.md @@ -0,0 +1,7 @@ +--- +title: "NotificationAction" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/notificationactioninput.md b/website/docs/services/notifications/types/notificationactioninput.md new file mode 100644 index 0000000000..edb3258b90 --- /dev/null +++ b/website/docs/services/notifications/types/notificationactioninput.md @@ -0,0 +1,7 @@ +--- +title: "NotificationActionInput" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/notificationchannel.md b/website/docs/services/notifications/types/notificationchannel.md new file mode 100644 index 0000000000..26de22d99a --- /dev/null +++ b/website/docs/services/notifications/types/notificationchannel.md @@ -0,0 +1,7 @@ +--- +title: "NotificationChannel" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/notificationerror.md b/website/docs/services/notifications/types/notificationerror.md new file mode 100644 index 0000000000..c49fc3e75a --- /dev/null +++ b/website/docs/services/notifications/types/notificationerror.md @@ -0,0 +1,7 @@ +--- +title: "NotificationError" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/notificationerrorcode.md b/website/docs/services/notifications/types/notificationerrorcode.md new file mode 100644 index 0000000000..2e733e4c74 --- /dev/null +++ b/website/docs/services/notifications/types/notificationerrorcode.md @@ -0,0 +1,7 @@ +--- +title: "NotificationErrorCode" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/notificationevent.md b/website/docs/services/notifications/types/notificationevent.md new file mode 100644 index 0000000000..a446b1b2b7 --- /dev/null +++ b/website/docs/services/notifications/types/notificationevent.md @@ -0,0 +1,7 @@ +--- +title: "NotificationEvent" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/notificationoptions.md b/website/docs/services/notifications/types/notificationoptions.md new file mode 100644 index 0000000000..dc52673359 --- /dev/null +++ b/website/docs/services/notifications/types/notificationoptions.md @@ -0,0 +1,7 @@ +--- +title: "NotificationOptions" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/pendingnotification.md b/website/docs/services/notifications/types/pendingnotification.md new file mode 100644 index 0000000000..53b3be1a96 --- /dev/null +++ b/website/docs/services/notifications/types/pendingnotification.md @@ -0,0 +1,7 @@ +--- +title: "PendingNotification" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/person.md b/website/docs/services/notifications/types/person.md new file mode 100644 index 0000000000..11b2af3dab --- /dev/null +++ b/website/docs/services/notifications/types/person.md @@ -0,0 +1,7 @@ +--- +title: "Person" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/priority.md b/website/docs/services/notifications/types/priority.md new file mode 100644 index 0000000000..60cf422eb9 --- /dev/null +++ b/website/docs/services/notifications/types/priority.md @@ -0,0 +1,7 @@ +--- +title: "Priority" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/repeatinterval.md b/website/docs/services/notifications/types/repeatinterval.md new file mode 100644 index 0000000000..d1e316e158 --- /dev/null +++ b/website/docs/services/notifications/types/repeatinterval.md @@ -0,0 +1,7 @@ +--- +title: "RepeatInterval" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/responsetype.md b/website/docs/services/notifications/types/responsetype.md new file mode 100644 index 0000000000..576e50b6ae --- /dev/null +++ b/website/docs/services/notifications/types/responsetype.md @@ -0,0 +1,7 @@ +--- +title: "ResponseType" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/style.md b/website/docs/services/notifications/types/style.md new file mode 100644 index 0000000000..642356e272 --- /dev/null +++ b/website/docs/services/notifications/types/style.md @@ -0,0 +1,7 @@ +--- +title: "Style" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/visibility.md b/website/docs/services/notifications/types/visibility.md new file mode 100644 index 0000000000..64b3901916 --- /dev/null +++ b/website/docs/services/notifications/types/visibility.md @@ -0,0 +1,7 @@ +--- +title: "Visibility" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/weboptions.md b/website/docs/services/notifications/types/weboptions.md new file mode 100644 index 0000000000..42b9f08c43 --- /dev/null +++ b/website/docs/services/notifications/types/weboptions.md @@ -0,0 +1,7 @@ +--- +title: "WebOptions" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/windowsoptions.md b/website/docs/services/notifications/types/windowsoptions.md new file mode 100644 index 0000000000..2e4eebad7a --- /dev/null +++ b/website/docs/services/notifications/types/windowsoptions.md @@ -0,0 +1,7 @@ +--- +title: "WindowsOptions" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/services/notifications/types/windowsscenario.md b/website/docs/services/notifications/types/windowsscenario.md new file mode 100644 index 0000000000..404bb3503a --- /dev/null +++ b/website/docs/services/notifications/types/windowsscenario.md @@ -0,0 +1,7 @@ +--- +title: "WindowsScenario" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/sidebars.yml b/website/sidebars.yml index 23b1ace48b..cc6c6c286a 100644 --- a/website/sidebars.yml +++ b/website/sidebars.yml @@ -450,6 +450,7 @@ docs: HapticFeedback: services/hapticfeedback.md LocalAuthentication: services/localauthentication/index.md Magnetometer: services/magnetometer.md + Notifications: services/notifications/index.md PermissionHandler: services/permissionhandler/index.md ScreenBrightness: services/screenbrightness.md SemanticsService: services/semanticsservice.md @@ -675,6 +676,41 @@ docs: _index: types/notchshape/index.md AutomaticNotchShape: types/automaticnotchshape.md CircularRectangleNotchShape: types/circularrectanglenotchshape.md + - Notifications: + - services/notifications/types/activenotification.md + - services/notifications/types/androidicon.md + - services/notifications/types/androidoptions.md + - services/notifications/types/androidschedulemode.md + - services/notifications/types/bigpicturestyle.md + - services/notifications/types/bigtextstyle.md + - services/notifications/types/darwinoptions.md + - services/notifications/types/datetimecomponents.md + - services/notifications/types/groupalertbehavior.md + - services/notifications/types/importance.md + - services/notifications/types/inboxstyle.md + - services/notifications/types/interruptionlevel.md + - services/notifications/types/launchdetails.md + - services/notifications/types/linuxoptions.md + - services/notifications/types/linuxurgency.md + - services/notifications/types/message.md + - services/notifications/types/messagingstyle.md + - services/notifications/types/notificationaction.md + - services/notifications/types/notificationactioninput.md + - services/notifications/types/notificationchannel.md + - services/notifications/types/notificationerror.md + - services/notifications/types/notificationerrorcode.md + - services/notifications/types/notificationevent.md + - services/notifications/types/notificationoptions.md + - services/notifications/types/pendingnotification.md + - services/notifications/types/person.md + - services/notifications/types/priority.md + - services/notifications/types/repeatinterval.md + - services/notifications/types/responsetype.md + - services/notifications/types/style.md + - services/notifications/types/visibility.md + - services/notifications/types/weboptions.md + - services/notifications/types/windowsoptions.md + - services/notifications/types/windowsscenario.md - types/observable.md - types/offset.md - OutlinedBorder: