diff --git a/app.json b/app.json index e8b2faf..ef33a6f 100644 --- a/app.json +++ b/app.json @@ -10,7 +10,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "com.beenvoice.app", - "buildNumber": "20", + "buildNumber": "22", "icon": "./assets/beenvoice.icon", "infoPlist": { "ITSAppUsesNonExemptEncryption": false, @@ -68,6 +68,8 @@ } ], "./plugins/withSyncWidgetVersions.js", + "./plugins/withLiveActivityBannerFrame.js", + "./plugins/withStableWidgetsChildIdentity.js", [ "expo-local-authentication", { diff --git a/lib/time-clock-live-activity.ts b/lib/time-clock-live-activity.ts index 23297b2..b2e0e6e 100644 --- a/lib/time-clock-live-activity.ts +++ b/lib/time-clock-live-activity.ts @@ -7,7 +7,11 @@ type RunningEntry = { description: string; startedAt: Date | string; client?: { name: string } | null; - invoice?: { invoicePrefix: string | null; invoiceNumber: string } | null; + invoice?: { + invoicePrefix: string | null; + invoiceNumber: string; + business?: { name: string } | null; + } | null; }; type LiveActivityHandle = { @@ -64,6 +68,7 @@ export function buildTimeClockActivityProps( }), description: resolveClockDescription(running.description), clientName: running.client?.name ?? "", + businessName: invoice?.business?.name ?? "", invoiceLabel: invoice ? `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}` : "", diff --git a/lib/time-clock-live-activity.types.ts b/lib/time-clock-live-activity.types.ts index 008b6a4..ee27da7 100644 --- a/lib/time-clock-live-activity.types.ts +++ b/lib/time-clock-live-activity.types.ts @@ -9,5 +9,6 @@ export type TimeClockActivityProps = { clockTime: string; description: string; clientName: string; + businessName: string; invoiceLabel: string; }; diff --git a/plugins/withLiveActivityBannerFrame.js b/plugins/withLiveActivityBannerFrame.js new file mode 100644 index 0000000..629d8bb --- /dev/null +++ b/plugins/withLiveActivityBannerFrame.js @@ -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 + 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; diff --git a/plugins/withStableWidgetsChildIdentity.js b/plugins/withStableWidgetsChildIdentity.js new file mode 100644 index 0000000..1f9ded2 --- /dev/null +++ b/plugins/withStableWidgetsChildIdentity.js @@ -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(_ 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(props: props).transition(.identity)) + } + return AnyView(EmptyView()) + } catch { + return AnyView(EmptyView()) + } + }() + } + + // MARK: - Function that sets children as DynamicView + + private func updateChildren

(_ 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;