Packages
mob
0.9.22
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.1
0.9.0
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.39
0.7.38
0.7.37
0.7.36
0.7.35
0.7.34
0.7.33
0.7.32
0.7.31
0.7.30
0.7.29
0.7.28
0.7.27
0.7.26
0.7.25
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.26
0.6.25
0.6.24
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.2
0.6.1
0.6.0
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.11
0.5.10
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.0
BEAM-on-device mobile framework for Elixir
Current section
Files
Jump to
Current section
Files
guides/device_capabilities.md
# Device Capabilities
All device APIs in Mob follow a consistent pattern: call the function from a callback (returning the socket unchanged), then handle the result in `handle_info/2`. APIs never block the screen process.
Since 0.7.0, several capabilities ship as first-party **plugins** rather than in core: `MobCamera` (`mob_camera`), `MobPhotos` (`mob_photos`), `MobLocation` (`mob_location`), `MobBiometric` (`mob_biometric`), `MobScanner` (`mob_scanner`), and `MobNotify` (`mob_notify`). Activating one is the same two steps for each — add the dep, list it in `mob.exs`:
```elixir
# mix.exs
{:mob_camera, "~> 0.1"}
# mob.exs
config :mob, :plugins, [:mob_camera]
```
See the [Plugins guide](plugins.md). Everything else on this page (haptics, clipboard, share, files, audio, motion, storage, web view, alerts) is core.
## Permissions
Some capabilities require an OS permission before they can be used. Request permissions via `Mob.Permissions.request/2`. The result arrives asynchronously:
```elixir
def mount(_params, _session, socket) do
socket = Mob.Permissions.request(socket, :camera)
{:ok, socket}
end
def handle_info({:permission, :camera, :granted}, socket) do
{:noreply, Mob.Socket.assign(socket, :camera_ready, true)}
end
def handle_info({:permission, :camera, :denied}, socket) do
{:noreply, Mob.Socket.assign(socket, :camera_ready, false)}
end
```
**Capabilities that require permission:** `:camera`, `:microphone`, `:photo_library`, `:location`, `:notifications`
**No permission needed:** haptics, clipboard, share sheet, file picker.
> **`Mob.Permissions.request/2` is only half the picture.** Each
> permission-gated capability also needs an `Info.plist` usage
> description (iOS) and `AndroidManifest.xml` `uses-permission` entry
> (Android). The default `mix mob.new` template covers camera +
> microphone on iOS and most capabilities on Android, but leaves
> location, photo library, etc. for you to add explicitly. See
> [permissions](permissions.html) for the per-capability table, the
> iOS-specific gotchas, and a diagnostic checklist for "the dialog
> never appears".
## Haptic feedback
`Mob.Haptic.trigger/2` fires synchronously (no `handle_info` needed) and returns the socket:
```elixir
def handle_info({:tap, :purchase}, socket) do
socket = Mob.Haptic.trigger(socket, :success)
{:noreply, socket}
end
```
Feedback types: `:light`, `:medium`, `:heavy`, `:success`, `:error`, `:warning`
iOS uses `UIImpactFeedbackGenerator` / `UINotificationFeedbackGenerator`. Android uses `View.performHapticFeedback`.
## Clipboard
`Mob.Clipboard.get/1` dispatches to the main thread synchronously (same model as `safe_area/0`); no `handle_info` needed. `put/2` is fire-and-forget.
```elixir
# Write to clipboard
def handle_info({:tap, :copy}, socket) do
socket = Mob.Clipboard.put(socket, socket.assigns.code)
{:noreply, socket}
end
# Read from clipboard — result returned synchronously
def handle_info({:tap, :paste}, socket) do
case Mob.Clipboard.get(socket) do
{:clipboard, :ok, text} -> {:noreply, Mob.Socket.assign(socket, :pasted_text, text)}
{:clipboard, :empty} -> {:noreply, socket}
end
end
```
## Share sheet
Opens the platform's native share sheet (iOS: `UIActivityViewController`, Android: `ACTION_SEND` via `Intent.createChooser`). Fire-and-forget — no response arrives in the BEAM.
```elixir
def handle_info({:tap, :share}, socket) do
socket = Mob.Share.text(socket, "Check out this app! https://example.com")
{:noreply, socket}
end
```
Plain text only. URLs go inside the text; iOS auto-detects and offers URL-appropriate share targets. If you need multi-part sharing (title/url/image separately), file a ticket describing the use case.
## Camera
`MobCamera` ships in the `mob_camera` plugin (which also registers the `:camera` permission) — add the dep + activate in `mob.exs`. Requires `:camera` permission (and `:microphone` for video).
```elixir
# Capture a photo
socket = MobCamera.capture_photo(socket)
socket = MobCamera.capture_photo(socket, quality: :medium)
# Record a video
socket = MobCamera.capture_video(socket)
socket = MobCamera.capture_video(socket, max_duration: 30)
# Results:
def handle_info({:camera, :photo, %{path: path, width: w, height: h}}, socket) do
{:noreply, Mob.Socket.assign(socket, :photo_path, path)}
end
def handle_info({:camera, :video, %{path: path, duration: seconds}}, socket) do
{:noreply, Mob.Socket.assign(socket, :video_path, path)}
end
def handle_info({:camera, :cancelled}, socket) do
{:noreply, socket}
end
```
`path` is a local temp file. Copy it to a permanent location before the next capture.
## Photos
Browse and pick from the photo library. `MobPhotos` ships in the `mob_photos` plugin. Requires `:photo_library` permission.
```elixir
socket = MobPhotos.pick(socket)
socket = MobPhotos.pick(socket, max: 5) # pick up to 5
def handle_info({:photos, :picked, photos}, socket) do
# photos is a list of %{path: path, width: w, height: h} maps
{:noreply, Mob.Socket.assign(socket, :photos, photos)}
end
def handle_info({:photos, :cancelled}, socket) do
{:noreply, socket}
end
```
## Files
Open the system file picker:
```elixir
socket = Mob.Files.pick(socket)
socket = Mob.Files.pick(socket, types: ["public.pdf", "public.text"]) # iOS UTI strings
socket = Mob.Files.pick(socket, types: ["application/pdf", "text/plain"]) # Android MIME types
def handle_info({:files, :picked, files}, socket) do
# files is a list of %{path: path, name: name, size: bytes} maps
{:noreply, Mob.Socket.assign(socket, :files, files)}
end
```
> **Platform note:** `types` uses iOS UTI strings on iOS (`"public.pdf"`) and MIME type strings on Android (`"application/pdf"`). To support both platforms with the same call, pass both forms — the platform ignores strings it doesn't recognise. See [Platform-specific props](components.md#platform-specific-props) for a cleaner pattern.
## Camera preview
Display a live camera feed inline (no OS permission dialog for preview). The `<CameraPreview>` render-tree node lives in core, but the preview session API is `MobCamera` (`mob_camera` plugin):
```elixir
def mount(_params, _session, socket) do
socket = MobCamera.start_preview(socket, facing: :back)
{:ok, socket}
end
def render(assigns) do
~MOB"""
<Column>
<CameraPreview facing={:back} weight={1} />
<Button text="Flip" on_tap={{self(), :flip}} />
</Column>
"""
end
def terminate(_reason, socket) do
MobCamera.stop_preview(socket)
:ok
end
```
The `:camera_preview` component requires an active preview session — call `start_preview/2` before mounting and `stop_preview/1` in `terminate/2`.
## Audio recording
Requires `:microphone` permission.
```elixir
socket = Mob.Audio.start_recording(socket)
socket = Mob.Audio.start_recording(socket, format: :aac, quality: :medium)
socket = Mob.Audio.stop_recording(socket)
def handle_info({:audio, :recorded, %{path: path, duration: seconds}}, socket) do
{:noreply, Mob.Socket.assign(socket, :recording, path)}
end
def handle_info({:audio, :error, reason}, socket) do
{:noreply, Mob.Socket.assign(socket, :error, reason)}
end
```
Recording formats: `:aac` (default), `:wav`. Quality: `:low`, `:medium` (default), `:high`.
## Audio playback
No permission needed. Plays local files or remote URLs.
```elixir
socket = Mob.Audio.play(socket, "/path/to/clip.m4a")
socket = Mob.Audio.play(socket, path, loop: true, volume: 0.8)
socket = Mob.Audio.stop_playback(socket)
socket = Mob.Audio.set_volume(socket, 0.5) # adjust without stopping
def handle_info({:audio, :playback_finished, %{path: path}}, socket) do
{:noreply, socket}
end
def handle_info({:audio, :playback_error, %{reason: reason}}, socket) do
{:noreply, Mob.Socket.assign(socket, :error, reason)}
end
```
iOS uses `AVAudioPlayer` / `AVPlayer`. Android uses `MediaPlayer`.
## Text-to-speech
No permission needed. `Mob.Speech` reads text aloud with the system voice, or
one the user picked from `Mob.Speech.voices/0`.
```elixir
# List the installed voices (blocks; Android waits for the engine the first
# time, so load it asynchronously)
def mount(_params, _session, socket) do
{:ok, Mob.Socket.start_async(socket, :voices, fn -> Mob.Speech.voices() end)}
end
def handle_async(:voices, {:ok, {:ok, voices}}, socket),
do: {:noreply, Mob.Socket.assign(socket, :voices, voices)}
# voices: [%{id: "com.apple.voice.compact.en-US.Samantha", name: "Samantha",
# language: "en-US", quality: :normal, network: false}, ...]
socket = Mob.Speech.speak(socket, reply, voice: socket.assigns.voice_id)
socket = Mob.Speech.speak(socket, "Bonjour", voice: "fr-FR") # a language works too
socket = Mob.Speech.stop_speaking(socket)
# One message per utterance, to the process that called speak/3; stop_speaking/1
# cancels every unfinished one (Mob.Speech lists the one exception)
def handle_info({:speech, :done}, socket), do: {:noreply, Mob.Socket.assign(socket, :reading, false)}
def handle_info({:speech, :cancelled}, socket), do: {:noreply, socket}
def handle_info({:speech, :failed}, socket), do: {:noreply, socket} # Android only
```
`:voice` matches a voice id first; otherwise it's read as a language tag, and
a value that is neither speaks with the system default voice. Voice ids are
per device, so a saved id may be missing on another phone. Options apply to
one utterance only; left out, rate and pitch follow the user's text-to-speech
settings. iOS uses `AVSpeechSynthesizer`; Android uses `TextToSpeech`, and
needs a `MobBridge.kt` from mob_new 0.6.14 or newer for voice ids, `voices/0`
and the events (see `Mob.Speech`).
## Location
`MobLocation` ships in the `mob_location` plugin. Requires `:location` permission.
```elixir
# Single fix
socket = MobLocation.get_once(socket)
# Continuous updates
socket = MobLocation.start(socket)
socket = MobLocation.start(socket, accuracy: :high) # :high | :balanced | :low
socket = MobLocation.stop(socket)
def handle_info({:location, %{lat: lat, lon: lon, accuracy: acc, altitude: alt}}, socket) do
{:noreply, Mob.Socket.assign(socket, :location, %{lat: lat, lon: lon})}
end
def handle_info({:location, :error, reason}, socket) do
{:noreply, Mob.Socket.assign(socket, :location_error, reason)}
end
```
iOS uses `CLLocationManager`. Android uses `FusedLocationProviderClient`.
## Motion (accelerometer / gyroscope)
```elixir
socket = Mob.Motion.start(socket)
socket = Mob.Motion.start(socket, interval_ms: 100)
socket = Mob.Motion.stop(socket)
def handle_info({:motion, %{ax: ax, ay: ay, az: az, gx: gx, gy: gy, gz: gz}}, socket) do
{:noreply, Mob.Socket.assign(socket, :motion, %{ax: ax, ay: ay, az: az})}
end
```
## Biometric authentication
`MobBiometric` ships in the `mob_biometric` plugin.
```elixir
socket = MobBiometric.authenticate(socket, reason: "Confirm your identity")
def handle_info({:biometric, :success}, socket) do
{:noreply, Mob.Socket.assign(socket, :authenticated, true)}
end
def handle_info({:biometric, :failure, reason}, socket) do
{:noreply, socket}
end
```
iOS uses Face ID / Touch ID. Android uses `BiometricPrompt`.
## QR / barcode scanner
`MobScanner` ships in the `mob_scanner` plugin. Activate `mob_camera` alongside it — `mob_camera` owns the `:camera` permission the scanner needs (`config :mob, :plugins, [:mob_camera, :mob_scanner]`).
```elixir
socket = MobScanner.scan(socket)
def handle_info({:scan, :result, %{type: type, value: value}}, socket) do
# type: :qr | :ean | :upc | etc.
{:noreply, Mob.Socket.assign(socket, :scanned, value)}
end
def handle_info({:scan, :cancelled}, socket) do
{:noreply, socket}
end
```
## Notifications
`MobNotify` ships in the `mob_notify` plugin — see [its docs](https://hexdocs.pm/mob_notify) for the full API. Delivery (the `{:notification, ...}` and `{:push_token, ...}` messages below) is unchanged core behavior.
Requires `:notifications` permission.
### Local notifications
```elixir
# Schedule
MobNotify.schedule(socket,
id: "reminder_1",
title: "Time to check in",
body: "Open the app to see today's updates",
at: ~U[2026-04-16 09:00:00Z], # or delay_seconds: 60
data: %{screen: "reminders"}
)
# Cancel
MobNotify.cancel(socket, "reminder_1")
# Receive in handle_info. :foreground = arrived while the app was open,
# :tap = opened by the user (from foreground, background, or a cold launch).
# Full shape: Mob.Notification.
def handle_info({:notification, %{presentation: :tap, id: id, data: data}}, socket) do
{:noreply, socket}
end
```
### Push notifications
See the [Push Notifications guide](push_notifications.md) for the full walkthrough — server setup, credential configuration, token handling, delivery lifecycle, and appearance options.
Quick reference:
```elixir
# After :notifications permission is granted:
{:noreply, MobNotify.register_push(socket)}
# Receive the device token — store it server-side with the platform:
def handle_info({:push_token, platform, token}, socket) do
MyApp.PushTokens.upsert(socket.assigns.user_id, token, platform)
{:noreply, socket}
end
# Receive push notifications (foreground arrival, background tap, or killed → tapped):
def handle_info({:notification, %{source: :push} = notif}, socket) do
# notif.presentation is :foreground or :tap; notif.data holds your custom keys
{:noreply, socket}
end
```
To send from your server, add [`mob_push`](https://hexdocs.pm/mob_push) to your server dependencies.
## Deep links
An app can be opened by a URL with its own scheme: a link tapped in another
app, a QR code read by the camera or a scanner app. Declare the schemes in
`mob.exs`; the native build (mob_dev 0.7.12 or later) adds an intent filter to
the Android main activity and a `CFBundleURLTypes` entry to the iOS bundle.
A change needs a native rebuild (`mix mob.deploy --native`).
```elixir
# mob.exs
config :mob_dev, url_schemes: ["myapp"]
```
On Android, `MainActivity` must also be `android:launchMode="singleTask"` in
`android/app/src/main/AndroidManifest.xml`; the build refuses `url_schemes`
without it. With the template's `singleTop`, a link opened from another app's
task (a QR scanner, a browser that doesn't ask for a new task) starts a
second `MainActivity` in that task, and two activities then drive one BEAM's
UI; `singleTask` hands the link to the existing one's `onNewIntent`. The cost,
and the reason it isn't the default: reopening the app from its launcher icon,
or a link reaching the running app, then finishes any activity stacked above
`MainActivity` (a file picker, a share target, an in-app scanner), whose
result comes back cancelled.
Each URL arrives as `{:link, link}`, at the screen showing (full shape:
`Mob.Link`). `source` is `:launch` when the link arrived before the app had
finished starting: it opened the app, or came in while the app was starting,
and it waited until the root screen had mounted (it arrives once).
`source` is `:running` when the app was already running.
```elixir
def handle_info({:link, %{url: url}}, socket) do
case URI.parse(url) do
%URI{scheme: "myapp", host: "thread", query: query} when is_binary(query) ->
%{"id" => id} = URI.decode_query(query)
{:noreply, Mob.Socket.push_screen(socket, MyApp.ThreadScreen, %{id: id})}
_ ->
{:noreply, socket}
end
end
```
Any app on the device can open a link with anything in it, so treat it as
untrusted input. To handle links in one place rather than in whichever screen
is showing, register a process (it can be registered before the root screen
starts, so it also gets the link that launched the app):
```elixir
# In the init/1 of a GenServer your app starts from on_start/0:
Mob.Link.register(self())
```
Try it without a QR code:
```bash
adb shell am start -a android.intent.action.VIEW -d 'myapp://thread?id=42'
xcrun simctl openurl booted 'myapp://thread?id=42'
```
### Apps generated before mob_new 0.6.4
The native files that forward the URL are app-owned, so an older app adds the
calls itself. Android, in `MainActivity.kt`:
```kotlin
// onCreate, inside the same guard as the notification tap, so re-creation
// from saved state and relaunch from Recents don't replay the link:
if (savedInstanceState == null &&
(intent.flags and android.content.Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0
) {
deliverNotificationTap(intent)
deliverLink(intent)
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
deliverNotificationTap(intent)
deliverLink(intent)
}
private fun deliverLink(intent: android.content.Intent?) {
if (intent?.action != android.content.Intent.ACTION_VIEW) return
val uri = intent.data ?: return
val scheme = uri.scheme
if ("content".equals(scheme, ignoreCase = true) || "file".equals(scheme, ignoreCase = true)) return
MobBridge.nativeDeliverLink(uri.toString())
}
```
`MobBridge.kt` declares `@JvmStatic external fun nativeDeliverLink(url: String)`
beside `nativeDeliverNotification`, and `beam_jni.c` forwards it:
```c
JNIEXPORT void JNICALL
Java_com_example_myapp_MobBridge_nativeDeliverLink(JNIEnv* env, jclass cls, jstring url) {
const char* cu = (*env)->GetStringUTFChars(env, url, NULL);
mob_deliver_link(cu);
(*env)->ReleaseStringUTFChars(env, url, cu);
}
```
Remove an intent filter you declared by hand for a scheme that's now in
`url_schemes`, and make `MainActivity` `singleTask` (above).
iOS, in `AppDelegate.m`'s `SceneDelegate` (a scene-based app never gets
`application:openURL:options:`):
```objc
// at the end of scene:willConnectToSession:options:
for (UIOpenURLContext *ctx in connectionOptions.URLContexts) {
if (!ctx.URL.isFileURL) mob_deliver_link(ctx.URL.absoluteString.UTF8String);
}
- (void)scene:(UIScene*)scene openURLContexts:(NSSet<UIOpenURLContext*>*)URLContexts {
for (UIOpenURLContext *ctx in URLContexts) {
if (!ctx.URL.isFileURL) mob_deliver_link(ctx.URL.absoluteString.UTF8String);
}
}
```
## Storage
App-local file storage using named locations instead of raw paths. No permission needed.
```elixir
# Resolve a location to its absolute path
path = Mob.Storage.dir(:documents) # persists, backed up to iCloud on iOS, user-visible there
path = Mob.Storage.dir(:cache) # persists until OS needs space, not backed up
path = Mob.Storage.dir(:temp) # ephemeral, may be purged any time
path = Mob.Storage.dir(:app_support) # persists, hidden from user, backed up on iOS unless excluded
# File operations
{:ok, files} = Mob.Storage.list(:documents) # returns full paths
{:ok, meta} = Mob.Storage.stat("/path/to/file") # %{name, path, size, modified_at}
{:ok, path} = Mob.Storage.write("/path/file.txt", "contents")
{:ok, data} = Mob.Storage.read("/path/file.txt")
{:ok, dest} = Mob.Storage.copy("/path/src.txt", :documents) # keeps basename
{:ok, dest} = Mob.Storage.move("/path/src.txt", "/path/dest.txt")
:ok = Mob.Storage.delete("/path/file.txt")
ext = Mob.Storage.extension("/tmp/clip.mp4") # => ".mp4"
```
All operations that can fail return `{:ok, value} | {:error, posix}`. `dir/1` raises on an unknown location atom.
### Keeping downloaded data out of backups
Apple rejects apps that back up data they can download again (Apple's iOS
Data Storage Guidelines). `:documents` and
`:app_support` are both backed up to iCloud on iOS, so put re-downloadable
data (models, content packs, offline media) in its own directory under
`:app_support` and exclude that directory:
```elixir
dir = Path.join(Mob.Storage.dir(:app_support), "packs")
File.mkdir_p!(dir)
:ok = Mob.Storage.exclude_from_backup(dir) # a directory's flag covers its contents
# {:error, :unsupported} on an iOS native build older than mob 0.9.21:
# rebuild with `mix mob.deploy --native`.
Mob.Storage.excluded_from_backup?(dir) # => true on iOS
```
On iOS this sets `NSURLIsExcludedFromBackupKey`. The path must exist
(`{:error, :enoent}` otherwise, on every platform); the
flag on a directory also covers files created in it later, and files inside
it still report `false` from `excluded_from_backup?/1` because the flag lives
on the directory. Call it at every launch: a restore or a move to a new
device can drop the attribute.
On Android both calls do nothing (`:ok`, `false`); Android has no per-file
flag. Generated apps set `android:allowBackup="false"`, which keeps app data
out of cloud backups (and, up to Android 11, device-to-device transfers). To
keep a directory out of a backup when your app turns backup on, or out of an
Android 12+ device-to-device transfer (which `allowBackup="false"` doesn't
stop), exclude it in the backup rules (`android:fullBackupContent` up to
Android 11, `android:dataExtractionRules` from Android 12). Off device both
calls also return `:ok` / `false`.
For saving to the native media library (Camera Roll, Downloads), see `Mob.Storage.Apple` and `Mob.Storage.Android`.
## WebView
Embed a native web view and communicate with it over a JS bridge. No permission needed.
```elixir
def render(assigns) do
~MOB"""
<WebView url="https://example.com" allow={["https://example.com"]} show_url={true} weight={1} />
"""
end
# Send a message to Elixir from JS:
# window.mob.send({ event: "clicked", id: 42 })
def handle_info({:webview, :message, %{"event" => "clicked", "id" => id}}, socket) do
{:noreply, socket}
end
# A navigation attempt was blocked by the allow: whitelist
def handle_info({:webview, :blocked, url}, socket) do
{:noreply, socket}
end
```
Push a message from Elixir into the page (calls `window.mob.onMessage` handlers):
```elixir
socket = Mob.WebView.post_message(socket, %{type: "update", value: 42})
```
Evaluate arbitrary JavaScript and receive the result:
```elixir
socket = Mob.WebView.eval_js(socket, "document.title")
# Result arrives as:
def handle_info({:webview, :eval_result, result}, socket) do
{:noreply, socket}
end
```
Props: `:url` (required), `:allow` (list of URL prefixes — blocks others), `:show_url` (native URL bar), `:title` (static label overriding `:show_url`), `:width`, `:height`.
> **Platform note:** WebView is supported on both iOS and Android.
## Alerts and toasts
`Mob.Alert` shows native dialogs and status messages. No permission needed.
### Alert dialog
Centered modal for confirmations and errors (iOS: `UIAlertController(.alert)`, Android: `AlertDialog`).
```elixir
def handle_info({:tap, :delete}, socket) do
Mob.Alert.alert(socket,
title: "Delete item?",
message: "This cannot be undone.",
buttons: [
[label: "Delete", style: :destructive, action: :confirmed_delete],
[label: "Cancel", style: :cancel]
]
)
{:noreply, socket}
end
def handle_info({:alert, :confirmed_delete}, socket) do
{:noreply, do_delete(socket)}
end
def handle_info({:alert, :dismiss}, socket) do
{:noreply, socket}
end
```
Dismissing without tapping a button (e.g. Android back gesture) sends `{:alert, :dismiss}`.
### Action sheet
Bottom-anchored list for choosing between actions (iOS: `UIAlertController(.actionSheet)`, Android: list dialog).
```elixir
Mob.Alert.action_sheet(socket,
title: "Share photo",
buttons: [
[label: "Save to Photos", action: :save],
[label: "Copy link", action: :copy],
[label: "Cancel", style: :cancel]
]
)
def handle_info({:alert, :save}, socket), do: {:noreply, save_photo(socket)}
def handle_info({:alert, :copy}, socket), do: {:noreply, copy_link(socket)}
def handle_info({:alert, :dismiss}, socket), do: {:noreply, socket}
```
### Toast
Ephemeral status message with no callback.
```elixir
Mob.Alert.toast(socket, "Saved!")
Mob.Alert.toast(socket, "File uploaded", duration: :long)
```
Duration: `:short` (default, ~2 s) or `:long` (~4 s). iOS renders a floating label overlay; Android uses `Toast`.
### Button options
| Key | Values | Default |
|-----|--------|---------|
| `:label` | string | `""` |
| `:style` | `:default`, `:cancel`, `:destructive` | `:default` |
| `:action` | atom — delivered as `{:alert, atom}` to `handle_info/2` | `:dismiss` |