Add offline caching and refine Live Activity

This commit is contained in:
2026-08-14 16:26:43 -04:00
parent fa3b9bf6a0
commit 31ae51cf9e
12 changed files with 392 additions and 21 deletions
+75 -14
View File
@@ -8,6 +8,7 @@ import {
minimumScaleFactor,
monospacedDigit,
padding,
truncationMode,
widgetAccentedRenderingMode,
} from "@expo/ui/swift-ui/modifiers";
import { createLiveActivity, type LiveActivityEnvironment } from "expo-widgets";
@@ -30,20 +31,24 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
layoutPriority(2),
frame({ width: 100, alignment: "center" }),
];
// A live timer Text reserves a large ideal width (for the H:MM:SS format at
// the Dynamic Island's font), so with no width cap it inflates the compact
// pill to full width. Pin it to a snug fixed width sized for the common
// MM:SS case; minimumScaleFactor lets the occasional H:MM:SS scale down to
// fit instead of widening the pill. This keeps the pill tight with no gap.
const compactTimerMods = [
font({ design: "monospaced", weight: "semibold", size: 11 }),
monospacedDigit(),
foregroundStyle(island),
lineLimit(1),
minimumScaleFactor(0.75),
frame({ minWidth: 38, alignment: "center" }),
layoutPriority(2),
minimumScaleFactor(0.6),
frame({ width: 40, alignment: "center" }),
];
const clientMods = [
font({ weight: "bold", size: 17 }),
foregroundStyle({ type: "hierarchical", style: "primary" }),
lineLimit(1),
minimumScaleFactor(0.75),
truncationMode("tail"),
];
// Avoid greedy layout primitives here: the live timerInterval Text can
// collapse when paired with Spacer/maxWidth. Fixed centered boxes keep the
@@ -60,8 +65,26 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
frame({ maxWidth: Infinity, alignment: "center" }),
];
const bannerTimer = <Text modifiers={timerMods}>{props.elapsedShort}</Text>;
const compactTimer = <Text modifiers={compactTimerMods}>{props.elapsedShort}</Text>;
const startedAt = new Date(props.startedAtMs);
// Bounded to 24h so SwiftUI reserves width for the H:MM:SS format only.
// (An open-ended `.timer`/`.date` style reserves width for a huge duration,
// which blows the compact Dynamic Island pill out to full width.)
const timerRange = {
lower: startedAt,
upper: new Date(props.startedAtMs + 24 * 60 * 60 * 1000),
};
// Banner (Notification Center / Lock Screen): timerInterval inside a fixed
// 100pt box, which absorbs the reserved width.
const bannerTimer = (
<Text modifiers={timerMods} timerInterval={timerRange} countsDown={false} />
);
// Dynamic Island (compact): same bounded timerInterval, sized intrinsically —
// no fixed/minWidth frame or layoutPriority, so the pill hugs the reserved
// H:MM:SS width instead of being padded out or stretched.
const compactTimer = (
<Text modifiers={compactTimerMods} timerInterval={timerRange} countsDown={false} />
);
const logoLarge = (
<Image
assetName="SplashScreenLogo"
@@ -82,6 +105,48 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
modifiers={[frame({ width: 14, height: 14 }), widgetAccentedRenderingMode("fullColor")]}
/>
);
// Apple Watch / CarPlay (`bannerSmall`). The strip is only ~150-180pt wide,
// so it cannot use the iPhone banner's fixed 100/140/100 columns (those
// overflow and push the timer off-screen, leaving just its first digit).
// Instead: fixed-size logo, a flexible client name that truncates to fill
// the leftover space, and a compact fixed-width timer that stays fully
// visible (fixed width — not maxWidth — so the live timer text doesn't
// collapse).
const watchRowMods = [
padding({ horizontal: 10, vertical: 6 }),
frame({ maxWidth: Infinity, alignment: "center" }),
];
const watchTitleMods = [
font({ weight: "semibold", size: 14 }),
foregroundStyle({ type: "hierarchical", style: "primary" }),
lineLimit(1),
truncationMode("tail"),
frame({ maxWidth: Infinity, alignment: "leading" }),
];
const watchTimerMods = [
font({ design: "monospaced", weight: "bold", size: 14 }),
monospacedDigit(),
foregroundStyle({ type: "hierarchical", style: "primary" }),
lineLimit(1),
minimumScaleFactor(0.6),
frame({ width: 58, alignment: "trailing" }),
];
const watchLogo = (
<Image
assetName="SplashScreenLogo"
systemName="dollarsign.circle.fill"
size={16}
modifiers={[
foregroundStyle({ type: "hierarchical", style: "primary" }),
frame({ width: 16, height: 16 }),
widgetAccentedRenderingMode("fullColor"),
]}
/>
);
const watchTimer = (
<Text modifiers={watchTimerMods} timerInterval={timerRange} countsDown={false} />
);
return {
banner: (
<HStack alignment="center" spacing={8} modifiers={bannerRowMods}>
@@ -95,14 +160,10 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
</HStack>
),
bannerSmall: (
<HStack alignment="center" spacing={8} modifiers={bannerRowMods}>
<VStack alignment="center" modifiers={sideZoneMods}>
{logoLarge}
</VStack>
<VStack alignment="center" spacing={1} modifiers={titleRowMods}>
<Text modifiers={clientMods}>{title}</Text>
</VStack>
{bannerTimer}
<HStack alignment="center" spacing={6} modifiers={watchRowMods}>
{watchLogo}
<Text modifiers={watchTitleMods}>{title}</Text>
{watchTimer}
</HStack>
),
compactLeading: logoSmall,