WidgetData

The data the main app shares with its home-screen and lock-screen widgets. The app writes a JSON snapshot into an App Group container; the widget extension reads it.

  • Model: Shared/Sources/MinutaShared/Models/WidgetData.swift
  • Writer: Minuta/Sources/Services/WidgetDataService.swift
  • Widget extension: Minuta/Widgets/ (MinutaWidgets.swift, WidgetIntents.swift)

Build status

The widget extension builds and embeds on iOS only (platformFilter: iOS on the target dependency in Minuta/project.yml); Mac Catalyst builds ship without it. The App Group group.tools.minuta.apps is registered under the TestFlight publishing team, not the personal free team the project is signed with, so the committed entitlements files stay empty and the group entitlement is applied only to TestFlight builds through Minuta/TestFlight/*.entitlements (see Publishing to TestFlight). In personal-team builds containerURL(forSecurityApplicationGroupIdentifier:) returns nil and widgets show their placeholder.

The model

WidgetData is a Codable, Sendable snapshot — a denormalized, widget-friendly view rather than the live records:

public struct WidgetData: Codable, Sendable {
    public var runningTimers: [WidgetTimer]
    public var todayRecordsCount: Int
    public var todayTotalDuration: TimeInterval
    public var recentTags: [WidgetTag]     // up to 4, most-recently-used
    public var lastUpdated: Date
}

WidgetTimer and WidgetTag are trimmed projections of TimeRecord and Tag carrying only what the widget renders (id, start time, tag name/color, comment). WidgetTimer.currentDuration computes elapsed time from startTime so the widget can tick without re-reading storage.

Shared constants live in WidgetDataConstants:

public static let filename = "widget-data.json"
public static let appGroupIdentifier = "group.tools.minuta.apps"
public static let maxRecentTags = 4

App Group sharing

The main app and the widget extension cannot read each other’s sandboxed containers directly. They share through an App Group container resolved by identifier:

FileManager.default
    .containerURL(forSecurityApplicationGroupIdentifier: WidgetDataConstants.appGroupIdentifier)?
    .appendingPathComponent(WidgetDataConstants.filename)

This is independent of the user-chosen Automerge storage folder — widgets never read the .automerge files; they only read the snapshot the app publishes.

Writing the snapshot

WidgetDataService is an actor. The app calls updateWidgetData(runningTimers:todayRecords:tags:recentTagIds:) when relevant state changes. It builds WidgetTimers from the running records, sums today’s completed durations, takes up to maxRecentTags recent tags, encodes to ISO-8601 JSON, writes atomically to the App Group file, and asks WidgetKit to refresh:

let jsonData = try encoder.encode(data)
try jsonData.write(to: url, options: .atomic)
WidgetCenter.shared.reloadAllTimelines()

Reading in the widget

WidgetDataReader (in MinutaWidgets.swift) decodes the same file, returning an empty WidgetData() if the container or file is missing. MinutaTimelineProvider feeds it into the timeline, scheduling the next refresh every 60 seconds when a timer is running and every 15 minutes otherwise.

Widget families

The bundle ships two widgets:

  • MinutaHomeWidget (home screen): systemSmall, systemMedium, systemLarge — running timer with stop button, today summary, and quick-start tag buttons.
  • MinutaLockScreenWidget (lock screen, iOS): accessoryCircular, accessoryRectangular, accessoryInline — compact running-timer or today’s-total displays.

Interactive intents

Widget buttons are wired to App Intents in WidgetIntents.swift (StartTimerFromWidgetIntent, StopTimerFromWidgetIntent, StopAllTimersFromWidgetIntent). Because the widget process cannot mutate the app’s storage, each intent writes a PendingTimerRequest to pending-timer.json in the App Group container. The main app processes that request the next time it becomes active.

  • TimeRecord — the source records that WidgetTimer projects from.