fix(native): fix widget image management and add tests

- Fix `copyBundledAsset` to accept a URI instead of a named bundle asset, resolving via `resolveAssetURL` with support for remote downloads
- Read app group identifier from Info.plist (`ExpoWidgetsAppGroupIdentifier`) instead of a hardcoded string
- Add `pruneWidgetImages(maxAgeSeconds)` to age-based cache cleanup, skipping the widget icon file
- Add `normalizedFileName` helper to sanitize cache keys and a `log` helper with consistent prefix
- Extract `getWidgetIconAsset()` into `widget-assets.ts` so the asset require is testable in isolation
- Add Vitest config for the native app and unit tests covering widget data-fetch, image download, and prune logic
This commit is contained in:
2026-03-24 11:13:09 -04:00
parent 1bf12d3884
commit 34b9ae3620
12 changed files with 522 additions and 102 deletions
+10 -5
View File
@@ -12,11 +12,12 @@ on:
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
# Skip for bot PRs and fork PRs due to OIDC token limitations in pull_request_target
# Fork PRs fail because the actor in OIDC context is the fork contributor (read-only)
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.user.type != 'Bot' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
@@ -41,3 +42,7 @@ jobs:
prompt: "/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
track_progress: true
claude_args: |
--model 'claude-opus-4-6'
--allowedTools 'mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)'
+1 -1
View File
@@ -46,4 +46,4 @@ jobs:
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
claude_args: "--model claude-opus-4-6"
@@ -9,12 +9,17 @@ export async function downloadWidgetImage(url: string, key: string): Promise<str
return getModule().downloadWidgetImage(url, key);
}
export async function copyBundledAsset(assetName: string, key: string): Promise<string | null> {
export async function copyBundledAsset(assetUri: string, key: string): Promise<string | null> {
if (Platform.OS !== "ios") return null;
return getModule().copyBundledAsset(assetName, key);
return getModule().copyBundledAsset(assetUri, key);
}
export async function clearWidgetImages(): Promise<void> {
if (Platform.OS !== "ios") return;
return getModule().clearWidgetImages();
}
export async function pruneWidgetImages(maxAgeSeconds: number): Promise<void> {
if (Platform.OS !== "ios") return;
return getModule().pruneWidgetImages(maxAgeSeconds);
}
@@ -1,8 +1,11 @@
import ExpoModulesCore
import Foundation
import UIKit
private let groupIdentifier = "group.com.jakejarvis.sofa"
private let imageDirectory = "widget_images"
private let widgetIconKey = "sofa_icon.png"
private let infoPlistAppGroupKey = "ExpoWidgetsAppGroupIdentifier"
private let logPrefix = "[SofaWidgetsSupport]"
public class SofaWidgetsSupportModule: Module {
public func definition() -> ModuleDefinition {
@@ -10,20 +13,20 @@ public class SofaWidgetsSupportModule: Module {
AsyncFunction("downloadWidgetImage") { (url: String, key: String) -> String? in
guard let imageUrl = URL(string: url) else {
self.log("Invalid widget image URL: \(url)")
return nil
}
guard let directoryUrl = self.ensureImageDirectory() else {
guard let destinationUrl = self.destinationURL(for: key) else {
return nil
}
let destinationUrl = directoryUrl.appendingPathComponent(key)
let (data, response) = try await URLSession.shared.data(from: imageUrl)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
self.log("Failed to download widget image: \(url)")
return nil
}
@@ -43,46 +46,97 @@ public class SofaWidgetsSupportModule: Module {
return destinationUrl.absoluteString
}
AsyncFunction("copyBundledAsset") { (assetName: String, key: String) -> String? in
guard let directoryUrl = self.ensureImageDirectory() else {
AsyncFunction("copyBundledAsset") { (assetUri: String, key: String) -> String? in
guard let destinationUrl = self.destinationURL(for: key) else {
return nil
}
let destinationUrl = directoryUrl.appendingPathComponent(key)
// Skip if already copied
if FileManager.default.fileExists(atPath: destinationUrl.path) {
return destinationUrl.absoluteString
}
guard let image = UIImage(named: assetName) else {
guard let sourceUrl = self.resolveAssetURL(assetUri) else {
self.log("Unable to resolve widget asset URI: \(assetUri)")
return nil
}
guard let pngData = image.pngData() else {
let data: Data
if sourceUrl.isFileURL {
data = try Data(contentsOf: sourceUrl)
} else {
let (downloadedData, response) = try await URLSession.shared.data(from: sourceUrl)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
self.log("Failed to download widget asset: \(assetUri)")
return nil
}
data = downloadedData
}
try pngData.write(to: destinationUrl, options: .atomic)
try data.write(to: destinationUrl, options: .atomic)
return destinationUrl.absoluteString
}
AsyncFunction("clearWidgetImages") {
guard let containerUrl = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: groupIdentifier
) else {
guard let directoryUrl = self.imageDirectoryURL() else {
return
}
let directoryUrl = containerUrl.appendingPathComponent(imageDirectory)
try? FileManager.default.removeItem(at: directoryUrl)
}
AsyncFunction("pruneWidgetImages") { (maxAgeSeconds: Double) in
guard maxAgeSeconds > 0 else {
return
}
guard let directoryUrl = self.imageDirectoryURL() else {
return
}
let fileManager = FileManager.default
let cutoffDate = Date().addingTimeInterval(-maxAgeSeconds)
let resourceKeys: Set<URLResourceKey> = [.contentModificationDateKey]
let fileUrls = try fileManager.contentsOfDirectory(
at: directoryUrl,
includingPropertiesForKeys: Array(resourceKeys),
options: [.skipsHiddenFiles]
)
for fileUrl in fileUrls {
if fileUrl.lastPathComponent == widgetIconKey {
continue
}
let modifiedAt = try fileUrl.resourceValues(forKeys: resourceKeys).contentModificationDate
?? .distantPast
if modifiedAt < cutoffDate {
try? fileManager.removeItem(at: fileUrl)
}
}
}
}
private func appGroupIdentifier() -> String? {
guard let identifier = Bundle.main.object(
forInfoDictionaryKey: infoPlistAppGroupKey
) as? String, !identifier.isEmpty else {
log("Missing \(infoPlistAppGroupKey) in Info.plist")
return nil
}
return identifier
}
private func imageDirectoryURL() -> URL? {
guard let groupIdentifier = appGroupIdentifier() else {
return nil
}
private func ensureImageDirectory() -> URL? {
guard let containerUrl = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: groupIdentifier
) else {
log("Unable to access app group container: \(groupIdentifier)")
return nil
}
@@ -94,6 +148,40 @@ public class SofaWidgetsSupportModule: Module {
return directoryUrl
}
private func destinationURL(for key: String) -> URL? {
guard let directoryUrl = imageDirectoryURL() else {
return nil
}
return directoryUrl.appendingPathComponent(normalizedFileName(key))
}
private func resolveAssetURL(_ assetUri: String) -> URL? {
if assetUri.isEmpty {
return nil
}
if let url = URL(string: assetUri), url.scheme != nil {
return url
}
return URL(fileURLWithPath: assetUri)
}
private func normalizedFileName(_ key: String) -> String {
let fallback = UUID().uuidString
let source = key.isEmpty ? fallback : key
let allowedCharacters = CharacterSet.alphanumerics.union(
CharacterSet(charactersIn: "._-")
)
let normalized = source.components(separatedBy: allowedCharacters.inverted).joined(separator: "_")
return normalized.isEmpty ? fallback : normalized
}
private func log(_ message: String) {
print("\(logPrefix) \(message)")
}
private func resizeImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
let size = image.size
let scale = min(maxDimension / size.width, maxDimension / size.height, 1.0)
@@ -1,3 +1,10 @@
import { requireNativeModule } from "expo";
export default requireNativeModule("SofaWidgetsSupport");
type SofaWidgetsSupportModuleType = {
downloadWidgetImage(url: string, key: string): Promise<string | null>;
copyBundledAsset(assetUri: string, key: string): Promise<string | null>;
clearWidgetImages(): Promise<void>;
pruneWidgetImages(maxAgeSeconds: number): Promise<void>;
};
export default requireNativeModule<SofaWidgetsSupportModuleType>("SofaWidgetsSupport");
+2 -1
View File
@@ -12,7 +12,8 @@
"lint": "oxlint",
"format": "oxfmt --config ../../.oxfmtrc.json",
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
"check-types": "tsc --noEmit"
"check-types": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@better-auth/expo": "catalog:",
+3
View File
@@ -0,0 +1,3 @@
export function getWidgetIconAsset() {
return require("../../assets/widget/sofa-icon.png");
}
+212
View File
@@ -0,0 +1,212 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const CACHED_WIDGET_IMAGE_RE = /^file:\/\/\/group\/cw_/;
const platform = { OS: "ios" };
const resolveAssetSource = vi.fn(() => ({ uri: "file:///tmp/sofa-icon.png" }));
const continueWatching = vi.fn();
const upcoming = vi.fn();
const downloadWidgetImage = vi.fn(async (_url: string, key: string) => `file:///group/${key}`);
const copyBundledAsset = vi.fn(async (_assetUri: string, key: string) => `file:///group/${key}`);
const pruneWidgetImages = vi.fn(async () => undefined);
const getWidgetIconAsset = vi.fn(() => "widget-icon-asset");
const continueWatchingWidget = {
updateSnapshot: vi.fn(),
updateTimeline: vi.fn(),
};
const upcomingWidget = {
updateSnapshot: vi.fn(),
updateTimeline: vi.fn(),
};
vi.mock("react-native", () => ({
Image: {
resolveAssetSource,
},
Platform: platform,
}));
vi.mock("@/lib/widget-assets", () => ({
getWidgetIconAsset,
}));
vi.mock("@/lib/orpc", () => ({
client: {
dashboard: {
continueWatching,
upcoming,
},
},
}));
vi.mock("@/lib/server", () => ({
resolveUrl: (path: string | null) => (path ? `https://sofa.test${path}` : null),
}));
vi.mock("../../modules/sofa-widgets-support", () => ({
copyBundledAsset,
downloadWidgetImage,
pruneWidgetImages,
}));
vi.mock("@/widgets/continue-watching", () => ({
default: continueWatchingWidget,
}));
vi.mock("@/widgets/upcoming", () => ({
default: upcomingWidget,
}));
async function loadWidgetsModule() {
vi.resetModules();
return import("./widgets");
}
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "warn").mockImplementation(() => undefined);
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
platform.OS = "ios";
continueWatching.mockResolvedValue({ items: [] });
upcoming.mockResolvedValue({ items: [] });
downloadWidgetImage.mockImplementation(
async (_url: string, key: string) => `file:///group/${key}`,
);
copyBundledAsset.mockImplementation(
async (_assetUri: string, key: string) => `file:///group/${key}`,
);
pruneWidgetImages.mockResolvedValue(undefined);
resolveAssetSource.mockReturnValue({ uri: "file:///tmp/sofa-icon.png" });
getWidgetIconAsset.mockReturnValue("widget-icon-asset");
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe("refreshWidgets", () => {
test("uses a fresh widget image path on each refresh cycle", async () => {
continueWatching.mockResolvedValue({
items: [
{
title: {
id: "title-1",
title: "Severance",
backdropPath: "/backdrop-1.jpg",
},
nextEpisode: {
seasonNumber: 2,
episodeNumber: 3,
stillPath: "/still-1.jpg",
},
watchedEpisodes: 2,
totalEpisodes: 10,
},
],
});
const { refreshWidgets } = await loadWidgetsModule();
await refreshWidgets();
const firstPath =
continueWatchingWidget.updateTimeline.mock.calls[0]?.[0]?.[0]?.props?.imageFilePath;
continueWatchingWidget.updateTimeline.mockClear();
vi.advanceTimersByTime(1000);
await refreshWidgets();
const secondPath =
continueWatchingWidget.updateTimeline.mock.calls[0]?.[0]?.[0]?.props?.imageFilePath;
expect(firstPath).toMatch(CACHED_WIDGET_IMAGE_RE);
expect(secondPath).toMatch(CACHED_WIDGET_IMAGE_RE);
expect(firstPath).not.toBe(secondPath);
expect(copyBundledAsset).toHaveBeenCalledWith("file:///tmp/sofa-icon.png", "sofa_icon.png");
expect(pruneWidgetImages).toHaveBeenCalledWith(21600);
});
test("publishes a timeline even when one widget image download fails", async () => {
continueWatching.mockResolvedValue({
items: [
{
title: {
id: "title-1",
title: "The Pitt",
backdropPath: "/fallback.jpg",
},
nextEpisode: {
seasonNumber: 1,
episodeNumber: 5,
stillPath: "/bad.jpg",
},
watchedEpisodes: 4,
totalEpisodes: 12,
},
{
title: {
id: "title-2",
title: "Andor",
backdropPath: "/backdrop-2.jpg",
},
nextEpisode: {
seasonNumber: 2,
episodeNumber: 1,
stillPath: "/good.jpg",
},
watchedEpisodes: 8,
totalEpisodes: 12,
},
],
});
downloadWidgetImage.mockImplementation(async (url: string, key: string) => {
if (url.includes("/bad.jpg")) {
throw new Error("download failed");
}
return `file:///group/${key}`;
});
const { refreshWidgets } = await loadWidgetsModule();
await refreshWidgets();
const entries = continueWatchingWidget.updateTimeline.mock.calls[0]?.[0];
expect(continueWatchingWidget.updateTimeline).toHaveBeenCalledTimes(1);
expect(entries).toHaveLength(2);
expect(entries[0]?.props?.imageFilePath).toBe("");
expect(entries[1]?.props?.imageFilePath).toMatch(CACHED_WIDGET_IMAGE_RE);
});
test("passes the copied icon path into empty widget states", async () => {
const { refreshWidgets } = await loadWidgetsModule();
await refreshWidgets();
expect(continueWatchingWidget.updateSnapshot).toHaveBeenCalledWith(
expect.objectContaining({
iconFilePath: "file:///group/sofa_icon.png",
titleName: "",
}),
);
expect(upcomingWidget.updateSnapshot).toHaveBeenCalledWith(
expect.objectContaining({
iconFilePath: "file:///group/sofa_icon.png",
titleName: "",
}),
);
});
test("is a no-op outside iOS", async () => {
platform.OS = "android";
const { refreshWidgets } = await loadWidgetsModule();
await refreshWidgets();
expect(continueWatching).not.toHaveBeenCalled();
expect(upcoming).not.toHaveBeenCalled();
expect(downloadWidgetImage).not.toHaveBeenCalled();
expect(copyBundledAsset).not.toHaveBeenCalled();
expect(pruneWidgetImages).not.toHaveBeenCalled();
});
});
+125 -39
View File
@@ -1,20 +1,48 @@
import { Platform } from "react-native";
import { Image, Platform } from "react-native";
import { client } from "@/lib/orpc";
import { resolveUrl } from "@/lib/server";
import { getWidgetIconAsset } from "@/lib/widget-assets";
import type { ContinueWatchingProps } from "@/widgets/continue-watching";
import type { UpcomingProps } from "@/widgets/upcoming";
import { copyBundledAsset, downloadWidgetImage } from "../../modules/sofa-widgets-support";
import {
copyBundledAsset,
downloadWidgetImage,
pruneWidgetImages,
} from "../../modules/sofa-widgets-support";
/**
* Strip null/undefined values from widget props before passing to the native module.
* iOS UserDefaults (used by expo-widgets to store timeline entries) does not support
* NSNull — any null or undefined value in the props dictionary will throw an ObjC
* NSInvalidArgumentException that surfaces as "Exception in HostFunction: <unknown>".
*/
function sanitizeProps<T extends object>(props: { [K in keyof T]: T[K] | null }): T {
const clean = {} as Record<string, unknown>;
for (const [key, value] of Object.entries(props)) {
if (value != null) {
clean[key] = value;
}
}
return clean as T;
}
/** Resolve a potentially-relative image path to an absolute URL and download it. */
async function downloadImage(path: string | null, key: string): Promise<string | null> {
async function downloadImage(path: string | null, key: string): Promise<string> {
const url = resolveUrl(path);
if (!url) return null;
return downloadWidgetImage(url, key);
if (!url) return "";
try {
return (await downloadWidgetImage(url, key)) ?? "";
} catch (error) {
console.warn(`[Widgets] Failed to cache image (${key}):`, error);
return "";
}
}
const TIMELINE_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes (iOS minimum)
const IMAGE_PRUNE_MAX_AGE_SECONDS = 6 * 60 * 60;
const WIDGET_ICON_KEY = "sofa_icon.png";
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
@@ -38,13 +66,51 @@ function formatEpisodeLabel(item: {
return "TV";
}
let iconFilePath: string | null = null;
let refreshSequence = 0;
async function ensureIcon(): Promise<string | null> {
if (!iconFilePath) {
iconFilePath = await copyBundledAsset("sofa-icon", "sofa_icon");
function hashString(value: string): string {
let hash = 0x811c9dc5;
for (let i = 0; i < value.length; i += 1) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}
function nextRefreshToken(prefix: string): string {
refreshSequence += 1;
return `${prefix}_${Date.now().toString(36)}_${refreshSequence.toString(36)}`;
}
function buildImageKey(
prefix: string,
refreshToken: string,
parts: Array<string | number | null | undefined>,
): string {
const identity = parts
.filter((part): part is string | number => part != null && part !== "")
.join("|");
return `${prefix}_${refreshToken}_${hashString(identity)}.jpg`;
}
function resolveWidgetIconUri(): string | null {
const source = Image.resolveAssetSource(getWidgetIconAsset());
return source?.uri ?? null;
}
async function ensureIcon(): Promise<string> {
const iconUri = resolveWidgetIconUri();
if (!iconUri) {
console.warn("[Widgets] Failed to resolve widget icon asset URI");
return "";
}
try {
return (await copyBundledAsset(iconUri, WIDGET_ICON_KEY)) ?? "";
} catch (error) {
console.warn("[Widgets] Failed to cache widget icon:", error);
return "";
}
return iconFilePath;
}
export async function refreshWidgets(): Promise<void> {
@@ -56,57 +122,69 @@ export async function refreshWidgets(): Promise<void> {
import("@/widgets/upcoming"),
]);
await ensureIcon();
const iconFilePath = await ensureIcon();
await Promise.all([
refreshContinueWatching(ContinueWatchingWidget),
refreshUpcoming(UpcomingWidget),
refreshContinueWatching(ContinueWatchingWidget, iconFilePath),
refreshUpcoming(UpcomingWidget, iconFilePath),
]);
try {
await pruneWidgetImages(IMAGE_PRUNE_MAX_AGE_SECONDS);
} catch (error) {
console.warn("[Widgets] Failed to prune cached images:", error);
}
}
async function refreshContinueWatching(
widget: Awaited<typeof import("@/widgets/continue-watching")>["default"],
iconFilePath: string,
): Promise<void> {
try {
const { items } = await client.dashboard.continueWatching();
if (items.length === 0) {
widget.updateSnapshot({
isEmpty: true,
widget.updateSnapshot(
sanitizeProps<ContinueWatchingProps>({
iconFilePath,
titleId: "",
titleName: "",
imageFilePath: null,
imageFilePath: "",
watchedEpisodes: 0,
totalEpisodes: 0,
isMovie: false,
});
}),
);
return;
}
const top5 = items.slice(0, 5);
const refreshToken = nextRefreshToken("cw");
const entries = await Promise.all(
top5.map(async (item, index) => {
const imageKey = `cw_${index}`;
const imagePath = item.nextEpisode?.stillPath ?? item.title.backdropPath;
const imageKey = buildImageKey("cw", refreshToken, [
item.title.id,
item.nextEpisode?.seasonNumber,
item.nextEpisode?.episodeNumber,
imagePath,
index,
]);
const imageFilePath = await downloadImage(imagePath, imageKey);
const props: ContinueWatchingProps = {
return {
date: new Date(Date.now() + index * TIMELINE_INTERVAL_MS),
props: sanitizeProps<ContinueWatchingProps>({
titleId: item.title.id,
titleName: item.title.title,
imageFilePath,
iconFilePath: null,
iconFilePath,
seasonNumber: item.nextEpisode?.seasonNumber,
episodeNumber: item.nextEpisode?.episodeNumber,
watchedEpisodes: item.watchedEpisodes,
totalEpisodes: item.totalEpisodes,
isMovie: !item.nextEpisode,
isEmpty: false,
};
return {
date: new Date(Date.now() + index * TIMELINE_INTERVAL_MS),
props,
}),
};
}),
);
@@ -119,6 +197,7 @@ async function refreshContinueWatching(
async function refreshUpcoming(
widget: Awaited<typeof import("@/widgets/upcoming")>["default"],
iconFilePath: string,
): Promise<void> {
try {
const { items } = await client.dashboard.upcoming({
@@ -127,44 +206,51 @@ async function refreshUpcoming(
});
if (items.length === 0) {
widget.updateSnapshot({
isEmpty: true,
widget.updateSnapshot(
sanitizeProps<UpcomingProps>({
iconFilePath,
titleId: "",
titleName: "",
imageFilePath: null,
imageFilePath: "",
titleType: "tv",
episodeCount: 0,
dateLabel: "",
episodeLabel: "",
});
}),
);
return;
}
const top5 = items.slice(0, 5);
const refreshToken = nextRefreshToken("up");
const entries = await Promise.all(
top5.map(async (item, index) => {
const imageKey = `up_${index}`;
const imageKey = buildImageKey("up", refreshToken, [
item.titleId,
item.date,
item.seasonNumber,
item.episodeNumber,
item.titleType,
item.backdropPath,
index,
]);
const imageFilePath = await downloadImage(item.backdropPath, imageKey);
const props: UpcomingProps = {
return {
date: new Date(Date.now() + index * TIMELINE_INTERVAL_MS),
props: sanitizeProps<UpcomingProps>({
titleId: item.titleId,
titleName: item.titleName,
imageFilePath,
iconFilePath: null,
iconFilePath,
titleType: item.titleType,
seasonNumber: item.seasonNumber,
episodeNumber: item.episodeNumber,
episodeCount: item.episodeCount,
dateLabel: formatShortDate(item.date),
episodeLabel: formatEpisodeLabel(item),
isEmpty: false,
};
return {
date: new Date(Date.now() + index * TIMELINE_INTERVAL_MS),
props,
}),
};
}),
);
@@ -14,20 +14,19 @@ import { createWidget, type WidgetEnvironment } from "expo-widgets";
export type ContinueWatchingProps = {
titleId: string;
titleName: string;
imageFilePath: string | null;
iconFilePath: string | null;
imageFilePath: string;
iconFilePath: string;
seasonNumber?: number;
episodeNumber?: number;
watchedEpisodes: number;
totalEpisodes: number;
isMovie: boolean;
isEmpty: boolean;
};
const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnvironment) => {
"widget";
if (props.isEmpty) {
if (!props.titleName) {
return (
<VStack
modifiers={[
+5 -6
View File
@@ -14,21 +14,20 @@ import { createWidget, type WidgetEnvironment } from "expo-widgets";
export type UpcomingProps = {
titleId: string;
titleName: string;
imageFilePath: string | null;
iconFilePath: string | null;
imageFilePath: string;
iconFilePath: string;
titleType: "movie" | "tv";
seasonNumber?: number | null;
episodeNumber?: number | null;
seasonNumber?: number;
episodeNumber?: number;
episodeCount: number;
dateLabel: string;
episodeLabel: string;
isEmpty: boolean;
};
const UpcomingWidget = (props: UpcomingProps, _env: WidgetEnvironment) => {
"widget";
if (props.isEmpty) {
if (!props.titleName) {
return (
<VStack
modifiers={[
+15
View File
@@ -0,0 +1,15 @@
import path from "node:path";
import { defineProject } from "vitest/config";
export default defineProject({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
test: {
include: ["src/**/*.test.ts"],
environment: "node",
},
});