Building HarmonyOS Interactive Cards: Pomodoro Timer Widget with FormKit
This tutorial walks through building a HarmonyOS interactive desktop widget using FormKit, covering configuration, UI with @LocalStorageProp, FormExtensionAbility lifecycle, event handling via postCardAction, state machine design, and real-world pitfalls like formId extraction and background execution limits.
Why Desktop Cards Are High-Value Entry Points
Mobile apps often get downloaded, used twice, then forgotten in the app drawer. The app icon is a one-time entry point — it only gets users into the app. Users actually want a specific function that takes one second. Desktop cards (service cards) solve this by moving a feature to the home screen: users can start a Pomodoro timer, check today's count, or mark a task done without opening the app. For users it's "one-second usable"; for developers it's continuous, non-intrusive exposure every time the user glances at the home screen.
FormKit Card Types
HarmonyOS FormKit supports three card types:
Static cards : Render once, never change — like an electronic sticky note. Simplest but no interaction.
Dynamic cards : Data can update via formProvider.updateForm; support timed refresh. Most news/weather cards use this.
Interactive cards : Build on dynamic cards, add user interaction — tap buttons, trigger animations. The Pomodoro widget belongs here: tap "Start Focus" to switch state, tap "Reset" to return, today's count increments in real time.
Concept Clarification: Dynamic vs Interactive Cards
Dynamic card ( isDynamic: true ) core is "data updatable". Set isDynamic to true in form_config.json to receive formProvider.updateForm pushes; UI re-renders on data change. This is the foundation.
Interactive card adds interaction on top: card UI sends events via postCardAction to FormExtensionAbility (message events), enabling "tap button → background processing → refresh card" loop. HarmonyOS 6+ adds "break-frame animations" and sensor linkage, but those are extras.
⚠️ Often overlooked: ArkTS cards and JS cards are two different runtimes. This article uses ArkTS cards ( uiSyntax: "arkts" ), with dev experience nearly identical to ArkUI pages, but with a key constraint — card UI must use V1 state management ( @LocalStorageProp ), not @ComponentV2 .
Project Structure & Configuration: Register the Card First
The first hurdle isn't code — it's configuration. The card must be registered in module.json5 so the system recognizes it. Many developers write code but the card never appears in the long-press menu; 90% of the time it's a missing config.
Register FormExtensionAbility in module.json5
Add to extensionAbilities array:
{ "name": "PomodoroFormAbility", "srcEntry": "./ets/widget/PomodoroFormAbility.ets", "type": "form", "exported": true, "metadata": [ { "name": "ohos.extension.form", "resource": "$profile:form_config_pomodoro" } ]}Three critical fields: type: "form" — declares a card-type ExtensionAbility. Writing "service" makes it a service, not a card. srcEntry — points to your FormExtensionAbility implementation file. metadata resource $profile:form_config_pomodoro — resolves to resources/base/profile/form_config_pomodoro.json. Filename must match exactly. exported: true — allows external (desktop/system) invocation; card services must be exported.
Card Config File: form_config_pomodoro.json
Create under entry/src/main/resources/base/profile/:
{ "forms": [ { "name": "PomodoroCard", "displayName": "$string:pomodoro_card_name", "description": "$string:pomodoro_card_desc", "src": "./ets/widget/PomodoroCard.ets", "uiSyntax": "arkts", "window": { "designWidth": 720, "autoDesignWidth": true }, "colorMode": "auto", "isDynamic": true, "isDefault": true, "supportDimensions": ["2*2"], "defaultDimension": "2*2", "updateEnabled": true, "updateDuration": 2 } ]}Field breakdown: name — card name, must match module.json5 metadata and FormInfo.name. displayName / description — user-visible name/description in "Add Card" UI; use $string:xxx for string resources. src — card UI page file path. uiSyntax: "arkts" — ArkTS card ("hml" is legacy JS card). isDynamic: true — enables dynamic card, receives updateForm pushes. isDefault — default card when an ExtensionAbility defines multiple forms. supportDimensions / defaultDimension — supported sizes; demo uses 2×2 only. updateEnabled / updateDuration — system timed refresh switch; updateDuration unit is half-hours, 2 = 1 hour refresh.
String Resources
Add to resources/base/element/string.json:
{ "name": "pomodoro_card_name", "value": "番茄钟专注" },{ "name": "pomodoro_card_desc", "value": "桌面一键开始专注,记录今日完成次数" }Missing these causes errors or blank names when adding the card — a common pitfall.
Card UI: @LocalStorageProp Is the Only Entry Point
Card UI ( PomodoroCard.ets) looks like a normal ArkUI page but has one ironclad difference: data can only enter via @LocalStorageProp from outside; the card cannot maintain business state internally.
Reason: card UI runs in the system's card framework process (FormRenderService), not your app process. Your app pushes data via formProvider.updateForm → system puts it into a LocalStorage → card UI reads via @LocalStorageProp. This is a unidirectional data flow : app → system → card; card cannot modify data back.
Standard Pattern
let storageLocal = new LocalStorage()@Entry(storageLocal)@Componentstruct PomodoroCard { @LocalStorageProp('phase') phase: string = 'idle' @LocalStorageProp('remainText') remainText: string = '25:00' @LocalStorageProp('todayCount') todayCount: number = 0 @LocalStorageProp('statusText') statusText: string = '准备开始' build() { Column() { Text(this.remainText).fontSize(44) // ... } }}Five Key Points
@Entry(storageLocal) must receive a LocalStorage instance. This instance is the container for system-injected data. Without it, @LocalStorageProp gets nothing.
@LocalStorageProp('key') key must exactly match the backend push key, case-sensitive. Backend pushes { remainText: '25:00' } → card must write @LocalStorageProp('remainText'). remaintext or RemainText fails silently.
Card UI uses V1 ( @Component ), not @ComponentV2 . Not a preference — FormKit only recognizes V1's @LocalStorageProp injection. If your project uses V2, the card UI is the sole exception and must use V1.
Card UI does zero derived computation. All "compute display from state" logic moves to backend. In normal ArkUI you'd write
get primaryText() { return this.phase === 'focusing' ? '暂停' : '开始' }, but card process reactive tracking doesn't trace @LocalStorageProp accesses inside getters, so getter reads stale data or doesn't re-evaluate. Fix: compute derived values (button label, action name, theme color) in backend's toCardPayload and push as payload fields.
Don't use Button component; simulate buttons with Text . Button(this.primaryText) in card renders background color and white dots but no text — a rendering defect of parameterized Button constructor in card mechanism. Solution:
Text(this.primaryText).fontSize(13).fontColor('#FFFFFF').backgroundColor(this.themeColor).textAlign(TextAlign.Center).height(32).borderRadius(16).onClick(() => { ... }). This only applies to card UI; normal pages use Button fine.
💡 Why can't card UI maintain its own state? Because it runs in the system's card framework process, not your app process. Your app may be killed or frozen, but the card must keep showing on the desktop. So data is system-managed; card UI only renders what it receives. This is a deliberate design constraint, not a technical limitation.
FormExtensionAbility: The Card's Brain
If card UI is the face, FormExtensionAbility is the brain — manages lifecycle, handles events, decides when to push what data. Demo implements PomodoroFormAbility.ets.
Lifecycle Callbacks
onAddForm(want) — Trigger: User adds card to desktop. Demo usage: Return initial data.
onUpdateForm(formId) — Trigger: System timed refresh ( updateDuration). Demo usage: Re-push current state.
onCastToNormalForm(formId) — Trigger: Static → dynamic card. Demo usage: Empty (demo uses dynamic only).
onFormEvent(formId, message) — Trigger: Card UI sends message event. Demo usage: Core : handle button taps.
onRemoveForm(formId) — Trigger: User removes card. Demo usage: Clean up state. onFormEvent is the interactive card key — button taps arrive here. Covered in next section.
Pitfall 1: Extracting formId in onAddForm
onAddFormfires on card add; parameter is want. You must extract formId and return initial data:
onAddForm(want: Want): formBindingData.FormBindingData { const formIdRaw: Object | undefined = want.parameters ? want.parameters[formInfo.FormParam.IDENTITY_KEY] : undefined const formId: string = formIdRaw !== undefined && formIdRaw !== null ? `${formIdRaw}` : '' const data: PomodoroCardData = getState(formId) const payload: Record<string, Object> = toCardPayload(data) return formBindingData.createFormBindingData(payload)}Real pitfall : the key name for formId. Many hardcode 'ohos.extra.param.key.form_id' but official constant is formInfo.FormParam.IDENTITY_KEY (value ohos.extra.param.key.form_identity). Using wrong key yields empty formId; all subsequent updateForm calls silently fail — card shows initial data forever, buttons do nothing, no error logs. Fix: always use the official constant.
Correct Data Return
onAddFormreturns a FormBindingData object via formBindingData.createFormBindingData(payload) — NOT formProvider.createFormBindingData (which doesn't exist). formBindingData creates data objects; formProvider pushes them ( formProvider.updateForm). Two modules, distinct roles.
Payload keys must match card UI's @LocalStorageProp('key') exactly. Centralize in data layer's toCardPayload to avoid mismatch:
export function toCardPayload(d: PomodoroCardData): Record<string, Object> { const payload: Record<string, Object> = { phase: d.phase, remainSeconds: d.remainSeconds, remainText: formatRemain(d.remainSeconds), todayCount: d.todayCount, statusText: d.statusText, // ... } return payload}State Cannot Live in Ability Instance
FormExtensionAbilityis a stateless, short-lived object . System creates a new instance per event ( onFormEvent, onUpdateForm), destroys it after. Storing state in instance fields loses it on next event.
Solution: module-level variable. Demo uses Map<string, PomodoroCardData> keyed by formId:
const stateStore: Map<string, PomodoroCardData> = new Map<string, PomodoroCardData>()function getState(formId: string): PomodoroCardData { const exist: PomodoroCardData | undefined = stateStore.get(formId) if (exist !== undefined) { return exist } const fresh: PomodoroCardData = defaultPomodoroData() stateStore.set(formId, fresh) return fresh}Module-level variable lives while ExtensionAbility process lives. If system reclaims card process, state is lost; next onAddForm (user re-adds card) re-initializes. Sufficient for lightweight Pomodoro; for persistence, add @ohos.data.preferences.
Click Interaction: postCardAction's Three Event Types
Card UI ready, data flows in, but buttons do nothing — events not wired. This section makes the card "alive".
ArkTS cards cannot use normal .onClick(() => {...}) for business logic (card process can't run your logic). Must use postCardAction to send event to backend FormExtensionAbility, which processes and calls updateForm to refresh card — a card → backend → card round-trip.
Three Event Types Comparison
// Event 1: router — jump to main app Ability (launches main app UI)postCardAction(this, { action: 'router', abilityName: 'EntryAbility', params: { from: 'widget' }})// Event 2: message — launch FormExtensionAbility, no UI jump, process then refresh cardpostCardAction(this, { action: 'message', params: { action: 'start' }})// Event 3: call — launch main app Ability background method, no UIpostCardAction(this, { action: 'call', abilityName: 'EntryAbility', params: { method: 'updateCardInfo', formId: this.formId }})router — Launches UI: ✅ Launches main app. Handler: Main app Ability. Use case: "Tap card to open detail page".
message — Launches UI: ❌ No launch. Handler: FormExtensionAbility.onFormEvent. Use case: "Interact on card, don't leave desktop" .
call — Launches UI: ❌ No launch. Handler: Main app Ability background method. Use case: Need main app complex logic, no UI.
Demo choice : button taps (start/pause/reset) use message — user taps, sees card state change instantly, no app launch. That's the desktop card value. Blank-area tap uses router to jump to main app for full stats.
Card-Side Event Firing
Button(this.primaryText) .onClick(() => { postCardAction(this, { action: 'message', params: { action: this.primaryAction } // 'start' / 'pause' / 'resume' }) }) this.primaryActionderives from current phase: idle → 'start', focusing → 'pause', paused → 'resume'. One button, multiple semantics, clean UI.
Backend Event Handling: onFormEvent
Event arrives at FormExtensionAbility.onFormEvent(formId, message). message is a serialized string containing card-side params. Parse it, advance state machine, then updateForm push back:
onFormEvent(formId: string, message: string): void { const action: string = parseCardAction(message) const current: PomodoroCardData = getState(formId) let next: PomodoroCardData = current if (action === 'start' || action === 'resume') { next = startFocus(current) } else if (action === 'pause') { next = pause(current) } else if (action === 'reset') { next = reset(current) } stateStore.set(formId, next) pushToCard(formId, next)} messageis a string (official API signature confirmed). But its JSON structure varies across sources — some say full object {"action":"message","params":{"action":"start"}}, others say only params {"action":"start"}. Official docs give no exact format example.
Facing this uncertainty, safest is defensive parsing — make parser compatible with multiple formats. Demo's parseCardAction:
export function parseCardAction(message: string): string { if (message === null || message === undefined || message.length === 0) { return 'unknown' } try { const obj: Record<string, Object> = JSON.parse(message) as Record<string, Object> // Prefer params.action — our actual business action const paramsObj: Object | undefined = obj['params'] if (paramsObj !== null && paramsObj !== undefined) { const inner: Record<string, Object> = paramsObj as Record<string, Object> const innerAction: Object | undefined = inner['action'] if (typeof innerAction === 'string' && innerAction.length > 0) { return innerAction } } // Fallback to top-level action, but exclude 'message' (event type, not business action) const topAction: Object | undefined = obj['action'] if (typeof topAction === 'string' && topAction !== 'message' && topAction.length > 0) { return topAction } return 'unknown' } catch (e) { return message // Not valid JSON, maybe plain action string 'start' }}Covers three verified formats: {"action":"message","params":{"action":"start"}} → extracts params.action = 'start' ✅ {"action":"start"} → extracts top-level action = 'start' ✅ 'start' (plain string) → catch returns original = 'start' ✅
Critical detail: fallback excludes 'message' itself because postCardAction event type is 'message'; if params lacks business action, top-level action becomes 'message', which must not be treated as business action.
This layered property access + type checks is typical in ArkTS because its type system forbids treating Object as arbitrary type for property access. Verbose but type-safe. Defensive compatibility for uncertain input formats is more reliable than betting on one format.
State Machine: Extract Pomodoro Logic into Pure Functions
Core link (UI ↔ Ability ↔ data) works. But Pomodoro state transitions (idle → focusing → paused → break → focusing…) are complex; stuffing them into onFormEvent makes maintenance hard. Extract into pure functions for testability and reuse.
Why Pure Functions
Hard to test : logic coupled in Ability, can't unit-test in isolation.
Hard to reuse : main app page also manipulates same Pomodoro state; duplicating logic causes inconsistency.
Hard to reason : transitions hidden in event handler, full picture invisible.
Pure functions become "input old state + action → output new state" black box. Can draw state diagram, unit-test every branch, both main app and card Ability call same logic. Demo's PomodoroData.ets is this pure data layer.
State Definition
export type PomodoroPhase = 'idle' | 'focusing' | 'paused' | 'break' | 'done'export interface PomodoroCardData { phase: PomodoroPhase remainSeconds: number focusTotalSeconds: number breakTotalSeconds: number todayCount: number statusText: string}Five phases form a loop: idle (ready) → focusing (working) ↔ paused (paused) → break (rest) → focusing (next round). Each maps to distinct card colors and labels.
Immutable Updates: Explicit Copy
Follows ArkTS iron law — immutable updates : always return new object, never mutate original. But ArkTS forbids object spread { ...state }, so must write explicit copyXxx functions:
export function copyPomodoroData(d: PomodoroCardData): PomodoroCardData { const next: PomodoroCardData = { phase: d.phase, remainSeconds: d.remainSeconds, focusTotalSeconds: d.focusTotalSeconds, breakTotalSeconds: d.breakTotalSeconds, todayCount: d.todayCount, statusText: d.statusText } return next}State Transition Functions
Based on copy function, transitions are clear. Example startFocus:
export function startFocus(d: PomodoroCardData): PomodoroCardData { const next: PomodoroCardData = copyPomodoroData(d) if (next.phase === 'idle' || next.phase === 'done') { next.remainSeconds = next.focusTotalSeconds } next.phase = 'focusing' next.statusText = phaseToStatusText('focusing') return next}Each transition function ( startFocus, pause, reset, tick) follows same pattern: copy → modify per action → return. All pure, no ArkUI deps, fully coverable by ohosTest.
💡 This "pure data layer + Ability/component layer calls" layering is the project's design style. Benefit: logic layer testable without UI; UI layer only "render whatever state received". State-heavy scenarios like Pomodoro especially benefit.
Countdown Implementation & Card Background Limits (Technical Depth Core)
Interactive Pomodoro card runs. But to make it actually "count down" — decrement number every second — you hit FormKit's most critical limit: card background runtime is strictly limited .
What Are Card Background Limits
Card UI process is managed by system card framework, not your app process. For battery, system strictly limits card background execution:
Card UI process cannot run long-lived setInterval timers — frozen or reclaimed after a while. FormExtensionAbility is also short-lived; cannot stay resident in background for timing.
Thus you cannot use setInterval to update card countdown every second like a normal app . Even if written, system stops the timer in minutes; card time freezes.
System's "official" refresh is form_config.json 's updateDuration — but minimum cycle is 30 minutes ( updateDuration: 1), far too slow for minute-level Pomodoro.
Three Implementation Paths
Path A: Event-Driven Refresh (Demo Choice)
No real-time second-level countdown. Card shows "current phase + set duration" (e.g., "Focusing 25:00").
Refresh only on user button tap (start/pause/reset).
Fully complies with card background limits; runs completely on simulator.
Sacrifices "second-level countdown" visual but keeps Pomodoro core value (focus timing, state switching, completion stats).
Path B: Main App Foreground Real-Time Push
User taps "Start Focus" in main app; main app runs setInterval for countdown, pushes every second via formProvider.updateForm.
When main app backgrounds, countdown falls back to system timed refresh (30 min).
Foreground: second-level countdown; background: countdown stops.
Path C: Hybrid A + B
Foreground real-time push (B), background fallback to updateDuration (A).
Best UX, most complex logic.
I chose Path A because it aligns with card design philosophy : desktop cards are "lightweight, one-second usable", not "full timer". A card that starts focus with one tap and logs completions already delivers core Pomodoro value. True second-precision needs? User opens main app.
This reflects a product judgment: card is not an app replica, but a minimal front-end of the app's core function . Cramming everything into card violates its lightweight philosophy.
Card ↔ Main App Data Sync: Engineering Trade-offs
Card interaction loop (UI ↔ FormExtensionAbility) complete. Final piece: main app. User may want full stats in main app, adjust focus duration, then sync back to card. This section clarifies real engineering hurdles of "main app → card" push.
Core Hurdle: Main App Can't Get formId
To call updateForm proactively, you need formId. But formId is only available in FormExtensionAbility.onAddForm(want) from want.parameters (see section 5). Main app process (UIAbility) and card service process (FormExtensionAbility) are two independent processes — module-level variables don't share; main app can't directly read Ability's formId.
Can formProvider.getFormsInfo() query it? No. Real pitfall hit during dev: getFormsInfo() returns FormInfo — card configuration metadata (name, supported dimensions, dynamic flag, etc.), fields: bundleName, moduleName, abilityName, name, type, supportDimensions — no field for card instance ID . Initially thought FormInfo had id field; compiler error: Property 'id' does not exist on type 'FormInfo'.
Official API check reveals: card instance ID ( formId) lives in RunningFormInfo, retrieved via formProvider.getRunningFormInfos(). But this API involves runtime card info, stricter permissions and version requirements.
Correct Approach: Persist formId in onAddForm
Reliable way for main app to get formId: persist it when Ability receives it, main app reads from storage:
User adds card → FormExtensionAbility.onAddForm(want) gets formId → Write formId to preferences (@ohos.data.preferences) → Main app page aboutToAppear reads formId from preferences → Main app uses read formId to call formProvider.updateFormInvolves cross-process preferences read/write, not complex but many APIs (get/put/flush), plus handle "card not added yet, main app reads no formId" edge case.
Main App Page Responsibilities
Main app page ( PomodoroMainPage.ets) uses @ComponentV2 (no card UI V1 restriction), provides three functions:
State preview : show current state, today's count, total focus minutes (locally maintained, may lag card state).
Duration settings : +/- buttons adjust focus/break duration, update local state, log "takes effect on next card refresh".
Card config detection : use getFormsInfo() to detect if card config installed (not instance), prompt user to add card if missing.
Main app and card share same pure data layer ( PomodoroData.ets), so main app duration changes, start focus, etc. run identical state machine functions as card Ability. Guarantees consistent state transitions regardless of entry point — only difference is when data syncs to card, dictated by system refresh timing.
Pitfall Summary
Real pitfalls encountered, most likely to trip you:
Pitfall 1: Wrong formId key name. Used want.parameters['ohos.extra.param.key.form_id'] (ends form_id), but official is formInfo.FormParam.IDENTITY_KEY (ends form_identity). Wrong key → empty formId → all updateForm silently fail → card stuck on initial data, buttons dead, no errors. Fix: use official constant, never hardcode string.
Pitfall 2: Using formProvider.createFormBindingData . Method doesn't exist. Create data with formBindingData.createFormBindingData, push with formProvider.updateForm — two modules. Mixing compiles (type inference fallback) but crashes at runtime.
Pitfall 3: @LocalStorageProp key case mismatch. Backend pushes { remainText: '25:00' }, card writes @LocalStorageProp('RemainText') or @LocalStorageProp('remaintext') — both get no data, card shows defaults. Keys must match payload keys exactly, case-sensitive . Centralize payload construction in toCardPayload to eliminate drift.
Pitfall 4: Card UI used @ComponentV2 . FormKit data injection only recognizes V1 @LocalStorageProp; V2 card UI gets zero data. Card UI must use V1 ( @Entry(storageLocal) + @Component + @LocalStorageProp). If project uses V2, card UI is the sole exception.
Pitfall 5: Card UI used getter for derived computation, button renders background only, no text. Real pitfall. Card UI wrote
get primaryText() { return this.phase === 'focusing' ? '暂停' : '开始' }reading @LocalStorageProp('phase') — works in normal ArkUI, but card process reactive tracking doesn't trace @LocalStorageProp dependencies inside getters, so getter reads stale data or doesn't re-evaluate. Button renders blue background but empty text. Fix: card UI zero derivation; all "compute display from state" (button label, color, action name) computed in backend toCardPayload, pushed as payload fields, card directly receives via @LocalStorageProp. This contradicts normal ArkUI habits — most hidden card pitfall.
Pitfall 6: Card used Button(text) parameterized constructor, text never renders. Even with correct pushed primaryText (value "开始专注"), Button(this.primaryText) in card process does not render text — shows background color and white dots only. Not a data issue; defect of parameterized Button constructor in card rendering. Fix: all clickable "buttons" in card use Text + backgroundColor + borderRadius + onClick, never Button component. Only affects card UI; normal pages use Button fine.
Summary
From "why desktop cards are high-value entry" we built a complete Pomodoro interactive card with FormKit, covering the full card dev pipeline:
1. Config Layer : module.json5 registers ExtensionAbility + form_config.json defines card properties + string resources. Get "registration" right first for system recognition.
2. UI Layer : Card UI uses V1 @LocalStorageProp to receive data — FormKit hard requirement.
3. Service Layer : FormExtensionAbility manages lifecycle, stores state in module-level Map (not instance fields).
4. Interaction Layer : postCardAction(message) implements card → backend → card event loop, making card "alive".
5. Data Layer : State machine as pure functions, immutable updates (explicit copy, no spread), unit-testable, reusable.
6. Sync Layer (Engineering Trade-off) : Main app proactive push needs formId; formId must be persisted in onAddForm to preferences for cross-process sharing.
Two key takeaways from this architecture:
First, card development essence is "state sync in constrained environment". Card process ≠ your app process; constraints exist (background time, state injection, V1 only). Understanding why constraints exist lets you grasp API design rationale instead of rote memorization.
Second, "event-driven refresh" is an honest design trade-off. Facing card background limits, I didn't force second-level real-time countdown (Paths B/C), but chose the approach most aligned with card philosophy — lightweight, one-second usable, core function front-loaded. Knowing what not to do shows more engineering maturity than cramming every feature.
Biggest gain from building interactive cards isn't "learning FormKit APIs" but experiencing a constrained-environment design mindset: within system constraints, deliver core value to users in the simplest way. Desktop cards, wearables, automotive, IoT — all share this thinking. That mindset is the real lesson behind FormKit practice.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
51CTO HarmonyOS Developer Community
The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
