+1 (415) 843-4662

Home screen widgets and iOS Live Activities for Ionic apps

Widgets are the cheapest real estate on a phone. A delivery app that shows the courier's ETA on the lock screen, a field-service app that puts today's first job on the home screen, a finance app with a balance tile — all of them get opened more often than the same app without a widget. In 2026 the two features clients ask us for by name are iOS Live Activities (lock screen and Dynamic Island) and home screen widgets on both platforms.

Neither one runs in your WebView. Widgets and Live Activities are native processes with their own lifecycle: SwiftUI/WidgetKit on iOS, Glance or AppWidgetProvider on Android. Your Ionic app cannot render them, but it can feed them — and that plumbing is the whole job. This tutorial builds it end to end for a Capacitor app: shared storage, a small Capacitor plugin, a WidgetKit timeline, an Android Glance widget, and a Live Activity that a server can update by push while the app is closed.

Examples are Ionic Angular, but nothing in the TypeScript layer is framework-specific.

The mental model

Ionic app (WebView)
   |  Capacitor plugin call
   v
Native shared store   <-- App Group / SharedPreferences -->   Widget extension
   ^                                                                ^
   |                                                                |
   +---------------- APNs push (Live Activity update) ---------------+

Three rules follow from this diagram, and every widget bug we get called in to fix breaks one of them:

  1. The widget never calls your API on demand. It reads a small snapshot written by the app (or delivered by push). Widget processes get a few seconds of CPU and can be killed mid-flight.
  2. The snapshot is a plain, versioned struct — no JWTs, no HTML, no megabyte JSON blobs. On iOS the App Group container is readable by anything in the group; treat it as semi-public.
  3. Refresh budgets are real. WidgetKit gives you on the order of a few dozen timeline refreshes a day. Design a timeline, not a polling loop.

1. Define the snapshot once, in TypeScript

Start from the contract. One interface, one version field, shared by app, plugin and both native widgets.

// src/app/widget/snapshot.ts
export interface JobSnapshot {
  v: 1;
  title: string;        // "Boiler service — 14 Mill St"
  startsAtIso: string;  // ISO 8601, UTC
  status: 'scheduled' | 'enroute' | 'onsite' | 'done';
  countToday: number;
  updatedAtIso: string;
}

export function encodeSnapshot(s: JobSnapshot): string {
  return JSON.stringify(s);
}

Keep it under a kilobyte or two. If you find yourself adding arrays of items, you are building a second app inside a widget; ship the summary instead.

2. A thin Capacitor plugin

You only need two methods: write the snapshot, and ask the OS to reload the widget. Generate the scaffold with npm init @capacitor/plugin, then define the API:

// packages/widget-bridge/src/definitions.ts
export interface WidgetBridgePlugin {
  setSnapshot(options: { json: string }): Promise<void>;
  reload(): Promise<void>;
  startLiveActivity(options: { json: string }): Promise<{ pushToken?: string }>;
  endLiveActivity(): Promise<void>;
}

iOS side

Create an App Group (group.com.example.app) and enable it on both the app target and the widget extension target — this is the step teams miss, and the symptom is a widget that only ever shows placeholder data.

// ios/Plugin/WidgetBridgePlugin.swift
import Capacitor
import WidgetKit

@objc(WidgetBridgePlugin)
public class WidgetBridgePlugin: CAPPlugin {
    private let suite = UserDefaults(suiteName: "group.com.example.app")

    @objc func setSnapshot(_ call: CAPPluginCall) {
        guard let json = call.getString("json") else {
            return call.reject("json required")
        }
        suite?.set(json, forKey: "jobSnapshot")
        call.resolve()
    }

    @objc func reload(_ call: CAPPluginCall) {
        WidgetCenter.shared.reloadTimelines(ofKind: "JobWidget")
        call.resolve()
    }
}

Android side

SharedPreferences is not shared across processes reliably, but a widget runs in your own app process, so plain prefs plus a broadcast is enough.

@CapacitorPlugin(name = "WidgetBridge")
class WidgetBridgePlugin : Plugin() {
    @PluginMethod
    fun setSnapshot(call: PluginCall) {
        val json = call.getString("json") ?: return call.reject("json required")
        context.getSharedPreferences("widget", Context.MODE_PRIVATE)
            .edit().putString("jobSnapshot", json).apply()
        call.resolve()
    }

    @PluginMethod
    fun reload(call: PluginCall) {
        val mgr = AppWidgetManager.getInstance(context)
        val ids = mgr.getAppWidgetIds(ComponentName(context, JobWidgetReceiver::class.java))
        mgr.notifyAppWidgetViewDataChanged(ids, R.id.widget_root)
        JobWidgetReceiver().onUpdate(context, mgr, ids)
        call.resolve()
    }
}

3. Write the snapshot at the right moments

Do not write on every state change; write when the user-visible summary changes, and always on resume and on pause.

@Injectable({ providedIn: 'root' })
export class WidgetService {
  private last = '';

  async publish(s: JobSnapshot) {
    const json = encodeSnapshot(s);
    if (json === this.last) return;         // avoid burning refreshes
    this.last = json;
    await WidgetBridge.setSnapshot({ json });
    await WidgetBridge.reload();
  }
}
// app.component.ts
App.addListener('appStateChange', ({ isActive }) => {
  if (!isActive) this.widgets.publish(this.jobs.currentSnapshot());
});

Writing on pause matters: that is the moment the user is about to look at the home screen.

4. The WidgetKit timeline

The widget decodes the snapshot and produces a timeline — entries with future dates, so the ETA keeps counting down without any refresh at all.

struct JobEntry: TimelineEntry { let date: Date; let snapshot: JobSnapshot? }

struct JobProvider: TimelineProvider {
    func placeholder(in _: Context) -> JobEntry { JobEntry(date: .now, snapshot: nil) }

    func getSnapshot(in _: Context, completion: @escaping (JobEntry) -> Void) {
        completion(JobEntry(date: .now, snapshot: load()))
    }

    func getTimeline(in _: Context, completion: @escaping (Timeline<JobEntry>) -> Void) {
        let snap = load()
        var entries = [JobEntry(date: .now, snapshot: snap)]
        // one extra entry at the job start time, then ask to be refreshed
        if let s = snap, let start = ISO8601DateFormatter().date(from: s.startsAtIso),
           start > .now {
            entries.append(JobEntry(date: start, snapshot: snap))
        }
        completion(Timeline(entries: entries, policy: .after(.now.addingTimeInterval(1800))))
    }

    private func load() -> JobSnapshot? {
        guard let json = UserDefaults(suiteName: "group.com.example.app")?
                .string(forKey: "jobSnapshot"),
              let data = json.data(using: .utf8) else { return nil }
        return try? JSONDecoder().decode(JobSnapshot.self, from: data)
    }
}

Use Text(date, style: .relative) and Text(date, style: .timer) in the view for live-looking countdowns that cost you nothing. And add a widgetURL(URL(string: "example://job/\(id)")) so tapping the widget deep-links into the app — which only works if you have already wired Universal Links and App Links properly.

5. Android: Glance in ~30 lines

class JobWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        val json = context.getSharedPreferences("widget", Context.MODE_PRIVATE)
            .getString("jobSnapshot", null)
        val snap = json?.let { Json.decodeFromString<JobSnapshot>(it) }
        provideContent {
            Column(modifier = GlanceModifier.padding(12.dp)) {
                Text(snap?.title ?: "No jobs today")
                Text("${snap?.countToday ?: 0} jobs")
            }
        }
    }
}

class JobWidgetReceiver : GlanceAppWidgetReceiver() {
    override val glanceAppWidget = JobWidget()
}

Register the receiver in AndroidManifest.xml with an android.appwidget.provider meta-data pointing at an XML descriptor that sets targetCellWidth/targetCellHeight and a previewLayout — Android 12+ shows that preview in the widget picker, and a missing one looks broken.

6. Live Activities: the part that needs a server

A Live Activity is started by the app, in the foreground, and then updated either locally or — the useful case — by push, so the lock screen stays correct while your app is suspended.

@objc func startLiveActivity(_ call: CAPPluginCall) {
    guard let json = call.getString("json"), let data = json.data(using: .utf8),
          let snap = try? JSONDecoder().decode(JobSnapshot.self, from: data)
    else { return call.reject("bad snapshot") }

    do {
        let activity = try Activity<JobAttributes>.request(
            attributes: JobAttributes(title: snap.title),
            content: .init(state: .init(status: snap.status,
                                        startsAt: ISO8601DateFormatter().date(from: snap.startsAtIso) ?? .now),
                           staleDate: .now.addingTimeInterval(3600)),
            pushType: .token
        )
        Task {
            for await token in activity.pushTokenUpdates {
                let hex = token.map { String(format: "%02x", $0) }.joined()
                self.notifyListeners("liveActivityToken", data: ["token": hex])
            }
        }
        call.resolve()
    } catch { call.reject(error.localizedDescription) }
}

Checklist for the iOS target: NSSupportsLiveActivities = true in Info.plist, a Widget Extension containing the ActivityConfiguration, and ActivityAuthorizationInfo().areActivitiesEnabled checked before you request — users can switch Live Activities off per app.

In the app, forward the push token to your backend:

WidgetBridge.addListener('liveActivityToken', ({ token }) =>
  this.api.post('/live-activities', { token, jobId: this.jobId }),
);

The server then pushes updates to APNs with apns-push-type: liveactivity, topic <bundle-id>.push-type.liveactivity, and a payload whose content-state matches your ContentState struct exactly:

{
  "aps": {
    "timestamp": 1767225600,
    "event": "update",
    "content-state": { "status": "enroute", "startsAt": 1767229200 },
    "alert": { "title": "Courier on the way", "body": "ETA 14:20" }
  }
}

Send "event": "end" with a dismissal-date when the job finishes. Activities that are never ended sit on the lock screen for hours and generate support tickets.

Android's closest equivalent is an ongoing notification with a progress bar (and Notification.CallStyle/ProgressStyle variants on Android 16). Plan for two implementations behind one TypeScript API rather than pretending the platforms match.

7. Testing and the traps

  • Widget shows placeholder forever → App Group not enabled on the extension target, or a Codable mismatch. Add a debugDescription fallback string to the snapshot while developing.
  • Stale data after login/logout → clear the snapshot on sign-out. A widget that still shows the previous user's data is a privacy incident, not a bug.
  • Nothing updates in the background → that is correct behaviour. If the data must be live while the app is closed, it has to arrive by push.
  • Timeline refreshes exhausted → drop the refresh policy to .after(30 min) and rely on Text(style: .timer) for anything that just counts.
  • On device, test with the app force-quit, in Low Power Mode, and with the phone locked. Those three states are where widget code fails.
  • Snapshot the widget in your CI screenshot suite; SwiftUI previews render widget families without a device.

Where the effort actually goes

The native code above is a day or two of work. The real cost is deciding what single number or line of text belongs on the lock screen, and making sure your domain model can produce it cheaply and correctly at any moment — including offline. Teams that get widgets right usually already have a clean local state layer feeding the UI; teams that don't end up adding a widget-shaped API endpoint and a race condition.

If you want this built and reviewed properly on your Ionic app — the App Group setup, the plugin, the APNs Live Activity pipeline and the store review notes that come with it — get in touch. We do this on Ionic and Capacitor codebases every week.