Wire live activity widget patches
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.beenvoice.app",
|
"bundleIdentifier": "com.beenvoice.app",
|
||||||
"buildNumber": "20",
|
"buildNumber": "22",
|
||||||
"icon": "./assets/beenvoice.icon",
|
"icon": "./assets/beenvoice.icon",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"ITSAppUsesNonExemptEncryption": false,
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
@@ -68,6 +68,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./plugins/withSyncWidgetVersions.js",
|
"./plugins/withSyncWidgetVersions.js",
|
||||||
|
"./plugins/withLiveActivityBannerFrame.js",
|
||||||
|
"./plugins/withStableWidgetsChildIdentity.js",
|
||||||
[
|
[
|
||||||
"expo-local-authentication",
|
"expo-local-authentication",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ type RunningEntry = {
|
|||||||
description: string;
|
description: string;
|
||||||
startedAt: Date | string;
|
startedAt: Date | string;
|
||||||
client?: { name: string } | null;
|
client?: { name: string } | null;
|
||||||
invoice?: { invoicePrefix: string | null; invoiceNumber: string } | null;
|
invoice?: {
|
||||||
|
invoicePrefix: string | null;
|
||||||
|
invoiceNumber: string;
|
||||||
|
business?: { name: string } | null;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LiveActivityHandle = {
|
type LiveActivityHandle = {
|
||||||
@@ -64,6 +68,7 @@ export function buildTimeClockActivityProps(
|
|||||||
}),
|
}),
|
||||||
description: resolveClockDescription(running.description),
|
description: resolveClockDescription(running.description),
|
||||||
clientName: running.client?.name ?? "",
|
clientName: running.client?.name ?? "",
|
||||||
|
businessName: invoice?.business?.name ?? "",
|
||||||
invoiceLabel: invoice
|
invoiceLabel: invoice
|
||||||
? `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`
|
? `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`
|
||||||
: "",
|
: "",
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ export type TimeClockActivityProps = {
|
|||||||
clockTime: string;
|
clockTime: string;
|
||||||
description: string;
|
description: string;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
|
businessName: string;
|
||||||
invoiceLabel: string;
|
invoiceLabel: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// @ts-check
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { withDangerousMod } = require("@expo/config-plugins");
|
||||||
|
|
||||||
|
const widgetsPath = path.join("node_modules", "expo-widgets", "ios", "Widgets");
|
||||||
|
const liveActivityBannerSource = `import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
import ActivityKit
|
||||||
|
|
||||||
|
@available(iOS 18.0, *)
|
||||||
|
struct LiveActivityBanner: View {
|
||||||
|
@Environment(\\.activityFamily) var activityFamily
|
||||||
|
var context: ActivityViewContext<LiveActivityAttributes>
|
||||||
|
var nodes: [String: Any]?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let nodes, let node = nodes["banner"] as? [String: Any] {
|
||||||
|
WidgetsDynamicView(name: context.activityID, kind: .liveActivity, node: node)
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function patchWidgetLiveActivitySwift(source) {
|
||||||
|
const target = ` if #available(iOS 18.0, *) {
|
||||||
|
LiveActivityBanner(context: context, nodes: nodes)
|
||||||
|
} else if let node = nodes["banner"] as? [String: Any] {
|
||||||
|
WidgetsDynamicView(name: context.activityID, kind: .liveActivity, node: node)
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}`;
|
||||||
|
const framedTarget = ` if #available(iOS 18.0, *) {
|
||||||
|
LiveActivityBanner(context: context, nodes: nodes)
|
||||||
|
} else if let node = nodes["banner"] as? [String: Any] {
|
||||||
|
WidgetsDynamicView(name: context.activityID, kind: .liveActivity, node: node)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}`;
|
||||||
|
const hardcodedBanner = ` if #available(iOS 18.0, *) {
|
||||||
|
LiveActivityBanner(context: context, nodes: nodes)
|
||||||
|
} else {
|
||||||
|
TimeClockNativeActivityBanner(context: context)
|
||||||
|
}`;
|
||||||
|
|
||||||
|
if (source.includes(target)) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.replace(framedTarget, target).replace(hardcodedBanner, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {import('@expo/config-plugins').ConfigPlugin} */
|
||||||
|
function withLiveActivityBannerFrame(config) {
|
||||||
|
return withDangerousMod(config, [
|
||||||
|
"ios",
|
||||||
|
async (config) => {
|
||||||
|
const sourceDir = path.join(config.modRequest.projectRoot, widgetsPath);
|
||||||
|
const bannerPath = path.join(sourceDir, "LiveActivityBanner.swift");
|
||||||
|
const activityPath = path.join(sourceDir, "WidgetLiveActivity.swift");
|
||||||
|
|
||||||
|
if (!fs.existsSync(bannerPath) || !fs.existsSync(activityPath)) {
|
||||||
|
throw new Error(`Could not find expo-widgets live activity sources in ${sourceDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(bannerPath, liveActivityBannerSource);
|
||||||
|
|
||||||
|
const activitySource = fs.readFileSync(activityPath, "utf8");
|
||||||
|
const patchedActivitySource = patchWidgetLiveActivitySwift(activitySource);
|
||||||
|
if (patchedActivitySource !== activitySource) {
|
||||||
|
fs.writeFileSync(activityPath, patchedActivitySource);
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withLiveActivityBannerFrame;
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// @ts-check
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { withDangerousMod } = require("@expo/config-plugins");
|
||||||
|
|
||||||
|
const dynamicViewPath = path.join(
|
||||||
|
"node_modules",
|
||||||
|
"expo-widgets",
|
||||||
|
"ios",
|
||||||
|
"Widgets",
|
||||||
|
"DynamicView.swift",
|
||||||
|
);
|
||||||
|
|
||||||
|
// expo-widgets rebuilds every WidgetsDynamicView child with a brand-new random UUID
|
||||||
|
// on every single render (see the original "Hack to satisfy ExpoSwiftUI.AnyChild"
|
||||||
|
// comment), so the `ForEach(props.children, id: \.id)` that HStack/VStack use to
|
||||||
|
// render their children sees a totally different identity set on each re-render.
|
||||||
|
// SwiftUI then treats every child as removed-and-reinserted instead of updated in
|
||||||
|
// place, which can make children silently fail to render whenever the parent's
|
||||||
|
// layout is recomputed (e.g. after a `.frame(maxWidth:)` change). This patch gives
|
||||||
|
// each child a stable identity derived from its structural position, cached across
|
||||||
|
// renders, so ForEach can correctly diff instead of thrashing.
|
||||||
|
const patchedSource = `import SwiftUI
|
||||||
|
import ExpoModulesCore
|
||||||
|
import ExpoUI
|
||||||
|
|
||||||
|
// TODO(@jakex7): Hack to satisfy ExpoSwiftUI.AnyChild with random UUID value
|
||||||
|
class NodeIdentityWrapper {
|
||||||
|
let id: UUID
|
||||||
|
init(id: UUID) {
|
||||||
|
self.id = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuses the same NodeIdentityWrapper for a given structural key across re-renders,
|
||||||
|
// so ForEach(id: \\.id) sees a stable identity instead of a fresh UUID every render.
|
||||||
|
private final class NodeIdentityCache {
|
||||||
|
static let shared = NodeIdentityCache()
|
||||||
|
private var wrappers: [String: NodeIdentityWrapper] = [:]
|
||||||
|
|
||||||
|
func wrapper(for key: String) -> NodeIdentityWrapper {
|
||||||
|
if let existing = wrappers[key] {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
let created = NodeIdentityWrapper(id: UUID())
|
||||||
|
wrappers[key] = created
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ObjectIdentifier: @retroactive Encodable {
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(String(describing: self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct WidgetsDynamicView: View, ExpoSwiftUI.AnyChild {
|
||||||
|
let node: [String: Any]
|
||||||
|
let name: String
|
||||||
|
let kind: WidgetsKind
|
||||||
|
let entryIndex: Int?
|
||||||
|
let environmentString: String?
|
||||||
|
let childKey: String?
|
||||||
|
|
||||||
|
private var uuid: NodeIdentityWrapper {
|
||||||
|
guard let childKey else {
|
||||||
|
return NodeIdentityWrapper(id: UUID())
|
||||||
|
}
|
||||||
|
return NodeIdentityCache.shared.wrapper(for: childKey)
|
||||||
|
}
|
||||||
|
public var id: ObjectIdentifier {
|
||||||
|
ObjectIdentifier(uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(name: String, kind: WidgetsKind, node: [String: Any]) {
|
||||||
|
self.name = name
|
||||||
|
self.kind = kind
|
||||||
|
self.node = node
|
||||||
|
self.entryIndex = nil
|
||||||
|
self.environmentString = nil
|
||||||
|
self.childKey = node["type"] as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(name: String, kind: WidgetsKind, node: [String: Any], entryIndex: Int?, environmentString: String?) {
|
||||||
|
self.name = name
|
||||||
|
self.kind = kind
|
||||||
|
self.node = node
|
||||||
|
self.entryIndex = entryIndex
|
||||||
|
self.environmentString = environmentString
|
||||||
|
self.childKey = node["type"] as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(name: String, kind: WidgetsKind, node: [String: Any], entryIndex: Int?, environmentString: String?, childKey: String) {
|
||||||
|
self.name = name
|
||||||
|
self.kind = kind
|
||||||
|
self.node = node
|
||||||
|
self.entryIndex = entryIndex
|
||||||
|
self.environmentString = environmentString
|
||||||
|
self.childKey = childKey
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
public var body: some View {
|
||||||
|
switch node["type"] as? String {
|
||||||
|
case "TextView":
|
||||||
|
render(TextView.self, TextViewProps.self, updateProps: updateChildren)
|
||||||
|
case "HStackView":
|
||||||
|
render(HStackView.self, HStackViewProps.self, updateProps: updateChildren)
|
||||||
|
case "VStackView":
|
||||||
|
render(VStackView.self, VStackViewProps.self, updateProps: updateChildren)
|
||||||
|
case "ZStackView":
|
||||||
|
render(ZStackView.self, ZStackViewProps.self, updateProps: updateChildren)
|
||||||
|
case "RectangleView":
|
||||||
|
render(RectangleView.self, RectangleViewProps.self)
|
||||||
|
case "RoundedRectangleView":
|
||||||
|
render(RoundedRectangleView.self, RoundedRectangleViewProps.self)
|
||||||
|
case "CapsuleView":
|
||||||
|
render(CapsuleView.self, CapsuleViewProps.self)
|
||||||
|
case "CircleView":
|
||||||
|
render(CircleView.self, CircleViewProps.self)
|
||||||
|
case "ImageView":
|
||||||
|
render(ImageView.self, ImageViewProps.self)
|
||||||
|
case "AccessoryWidgetBackgroundView":
|
||||||
|
render(AccessoryWidgetBackgroundView.self, AccessoryWidgetBackgroundProps.self)
|
||||||
|
case "DividerView":
|
||||||
|
render(DividerView.self, DividerProps.self)
|
||||||
|
case "EllipseView":
|
||||||
|
render(EllipseView.self, EllipseViewProps.self)
|
||||||
|
case "LabelView":
|
||||||
|
render(LabelView.self, LabelViewProps.self)
|
||||||
|
case "ProgressView":
|
||||||
|
render(ProgressView.self, ProgressViewProps.self)
|
||||||
|
case "SpacerView":
|
||||||
|
render(SpacerView.self, SpacerViewProps.self)
|
||||||
|
case "UnevenRoundedRectangleView":
|
||||||
|
render(UnevenRoundedRectangleView.self, UnevenRoundedRectangleViewProps.self)
|
||||||
|
case "GaugeView":
|
||||||
|
render(GaugeView.self, GaugeProps.self)
|
||||||
|
case "ChartView":
|
||||||
|
render(ChartView.self, ChartProps.self)
|
||||||
|
case "Button":
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
switch kind {
|
||||||
|
case .widget:
|
||||||
|
render(WidgetButtonView.self, ButtonProps.self) { buttonProps in
|
||||||
|
try updateChildren(buttonProps)
|
||||||
|
buttonProps.source = name
|
||||||
|
buttonProps.entryIndex = entryIndex
|
||||||
|
buttonProps.environmentString = environmentString
|
||||||
|
}
|
||||||
|
case .liveActivity:
|
||||||
|
render(LiveActivityButtonView.self, ButtonProps.self) { buttonProps in
|
||||||
|
try updateChildren(buttonProps)
|
||||||
|
buttonProps.source = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
render(ExpoUI.Button.self, ExpoUI.ButtonProps.self, updateProps: updateChildren)
|
||||||
|
}
|
||||||
|
case "react.fragment":
|
||||||
|
render(FragmentView.self, FragmentProps.self, updateProps: updateChildren)
|
||||||
|
case "LinkView":
|
||||||
|
render(LinkView.self, LinkViewProps.self, updateProps: updateChildren)
|
||||||
|
#if DEBUG
|
||||||
|
case "RedBoxView":
|
||||||
|
render(RedBoxView.self, RedBoxViewProps.self) { redBoxProps in
|
||||||
|
redBoxProps.source = name
|
||||||
|
redBoxProps.kind = kind
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
ZStack {
|
||||||
|
Color.red.opacity(0.5)
|
||||||
|
Text("Unable to get the view for: \\(node["type"] as? String ?? "undefined")")
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
default:
|
||||||
|
EmptyView()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Render Method
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func render<P, V>(_ viewType: V.Type, _ propsType: P.Type, updateProps: ((_ initialProps: P) throws -> Void)? = nil) -> some View
|
||||||
|
where P: UIBaseViewProps, V: ExpoSwiftUI.View, V.Props == P {
|
||||||
|
// immediately invoked closure {}() here because we can't use 'do-catch' inside @ViewBuilder
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
if let rawProps = node["props"] as? [String: Any] {
|
||||||
|
let props = try propsType.init(rawProps: rawProps, context: WidgetsContext.shared.context)
|
||||||
|
try updateProps?(props)
|
||||||
|
return AnyView(UIBaseView<P, V>(props: props).transition(.identity))
|
||||||
|
}
|
||||||
|
return AnyView(EmptyView())
|
||||||
|
} catch {
|
||||||
|
return AnyView(EmptyView())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Function that sets children as DynamicView
|
||||||
|
|
||||||
|
private func updateChildren<P>(_ initialProps: P) throws
|
||||||
|
where P: UIBaseViewProps {
|
||||||
|
let baseKey = childKey ?? (node["type"] as? String ?? "root")
|
||||||
|
if let props = node["props"] as? [String: Any] {
|
||||||
|
if let children = props["children"] as? [Any] {
|
||||||
|
let validChildren = children.compactMap { $0 as? [String: Any] }
|
||||||
|
initialProps.children = validChildren.enumerated().map { index, childNode in
|
||||||
|
let childType = childNode["type"] as? String ?? "unknown"
|
||||||
|
return WidgetsDynamicView(
|
||||||
|
name: name,
|
||||||
|
kind: kind,
|
||||||
|
node: childNode,
|
||||||
|
entryIndex: entryIndex,
|
||||||
|
environmentString: environmentString,
|
||||||
|
childKey: "\\(baseKey).\\(index).\\(childType)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if let child = props["children"] as? [String: Any] {
|
||||||
|
let childType = child["type"] as? String ?? "unknown"
|
||||||
|
initialProps.children = [WidgetsDynamicView(
|
||||||
|
name: name,
|
||||||
|
kind: kind,
|
||||||
|
node: child,
|
||||||
|
entryIndex: entryIndex,
|
||||||
|
environmentString: environmentString,
|
||||||
|
childKey: "\\(baseKey).0.\\(childType)"
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** @type {import('@expo/config-plugins').ConfigPlugin} */
|
||||||
|
function withStableWidgetsChildIdentity(config) {
|
||||||
|
return withDangerousMod(config, [
|
||||||
|
"ios",
|
||||||
|
async (config) => {
|
||||||
|
const filePath = path.join(config.modRequest.projectRoot, dynamicViewPath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error(`Could not find expo-widgets DynamicView.swift at ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, patchedSource);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withStableWidgetsChildIdentity;
|
||||||
Reference in New Issue
Block a user