Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'
git-subtree-dir: apps/mobile git-subtree-mainline:86f8987dffgit-subtree-split:5fa30f365f
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import UIKit
|
||||
|
||||
enum BeenVoiceIntentHelpers {
|
||||
@MainActor
|
||||
static func openDeepLink(_ url: URL) {
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import AppIntents
|
||||
|
||||
struct BeenVoiceShortcuts: AppShortcutsProvider {
|
||||
@AppShortcutsBuilder
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
intent: ClockInIntent(),
|
||||
phrases: [
|
||||
"Clock in with \(.applicationName)",
|
||||
"Start timer in \(.applicationName)",
|
||||
"Start tracking time in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Clock In",
|
||||
systemImageName: "play.circle.fill"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: ClockOutIntent(),
|
||||
phrases: [
|
||||
"Clock out in \(.applicationName)",
|
||||
"Stop timer in \(.applicationName)",
|
||||
"Stop tracking time in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Clock Out",
|
||||
systemImageName: "stop.circle.fill"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: OpenTimerIntent(),
|
||||
phrases: [
|
||||
"Open time clock in \(.applicationName)",
|
||||
"Open timer in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Time Clock",
|
||||
systemImageName: "timer"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import AppIntents
|
||||
|
||||
struct ClockInIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Clock In"
|
||||
static var description = IntentDescription("Start the beenvoice time clock with your last client.")
|
||||
static var openAppWhenRun: Bool = true
|
||||
static var isDiscoverable: Bool = true
|
||||
|
||||
@Parameter(title: "Title")
|
||||
var title: String?
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
var components = URLComponents()
|
||||
components.scheme = "beenvoice"
|
||||
components.host = "shortcuts"
|
||||
components.path = "/clock-in"
|
||||
|
||||
if let title, !title.isEmpty {
|
||||
components.queryItems = [URLQueryItem(name: "title", value: title)]
|
||||
}
|
||||
|
||||
guard let url = components.url else {
|
||||
return .result()
|
||||
}
|
||||
|
||||
BeenVoiceIntentHelpers.openDeepLink(url)
|
||||
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import AppIntents
|
||||
|
||||
struct ClockOutIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Clock Out"
|
||||
static var description = IntentDescription("Stop the running beenvoice timer and save your time.")
|
||||
static var openAppWhenRun: Bool = true
|
||||
static var isDiscoverable: Bool = true
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard let url = URL(string: "beenvoice://shortcuts/clock-out") else {
|
||||
return .result()
|
||||
}
|
||||
|
||||
BeenVoiceIntentHelpers.openDeepLink(url)
|
||||
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import AppIntents
|
||||
|
||||
struct OpenTimerIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Open Time Clock"
|
||||
static var description = IntentDescription("Open the beenvoice time clock.")
|
||||
static var openAppWhenRun: Bool = true
|
||||
static var isDiscoverable: Bool = true
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard let url = URL(string: "beenvoice://timer") else {
|
||||
return .result()
|
||||
}
|
||||
|
||||
BeenVoiceIntentHelpers.openDeepLink(url)
|
||||
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// @ts-check
|
||||
const {
|
||||
withDangerousMod,
|
||||
withXcodeProject,
|
||||
IOSConfig,
|
||||
} = require("@expo/config-plugins");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const SWIFT_FILES = [
|
||||
"BeenVoiceIntentHelpers.swift",
|
||||
"ClockInIntent.swift",
|
||||
"ClockOutIntent.swift",
|
||||
"OpenTimerIntent.swift",
|
||||
"BeenVoiceShortcuts.swift",
|
||||
];
|
||||
|
||||
const SHORTCUT_REGISTRATION = `Task {
|
||||
await BeenVoiceShortcuts.updateAppShortcutParameters()
|
||||
}`;
|
||||
|
||||
/** @type {import('@expo/config-plugins').ConfigPlugin} */
|
||||
function withAppIntents(config) {
|
||||
const appIntentsSource = path.join(
|
||||
config._internal?.projectRoot ?? process.cwd(),
|
||||
"plugins",
|
||||
"app-intents",
|
||||
);
|
||||
|
||||
config = withDangerousMod(config, [
|
||||
"ios",
|
||||
async (config) => {
|
||||
const projectRoot = config.modRequest.projectRoot;
|
||||
const platformRoot = config.modRequest.platformProjectRoot;
|
||||
const projectName =
|
||||
config.modRequest.projectName ??
|
||||
IOSConfig.XcodeUtils.getProjectName(projectRoot);
|
||||
const targetDir = path.join(platformRoot, projectName);
|
||||
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
for (const file of SWIFT_FILES) {
|
||||
fs.copyFileSync(path.join(appIntentsSource, file), path.join(targetDir, file));
|
||||
}
|
||||
|
||||
const appDelegatePath = path.join(targetDir, "AppDelegate.swift");
|
||||
if (fs.existsSync(appDelegatePath)) {
|
||||
let appDelegate = fs.readFileSync(appDelegatePath, "utf8");
|
||||
|
||||
if (!appDelegate.includes("import AppIntents")) {
|
||||
appDelegate = appDelegate.replace(
|
||||
"internal import Expo",
|
||||
"import AppIntents\ninternal import Expo",
|
||||
);
|
||||
}
|
||||
|
||||
if (!appDelegate.includes("BeenVoiceShortcuts.updateAppShortcutParameters")) {
|
||||
appDelegate = appDelegate.replace(
|
||||
"return super.application(application, didFinishLaunchingWithOptions: launchOptions)",
|
||||
`${SHORTCUT_REGISTRATION}
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)`,
|
||||
);
|
||||
} else {
|
||||
appDelegate = appDelegate.replace(
|
||||
/if #available\(iOS 1[68]\.0, \*\) \{\s*Task \{\s*await BeenVoiceShortcuts\.updateAppShortcutParameters\(\)\s*\}\s*\}/g,
|
||||
SHORTCUT_REGISTRATION,
|
||||
);
|
||||
}
|
||||
|
||||
if (!appDelegate.includes("applicationDidBecomeActive")) {
|
||||
appDelegate = appDelegate.replace(
|
||||
" // Linking API",
|
||||
` public override func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
${SHORTCUT_REGISTRATION}
|
||||
super.applicationDidBecomeActive(application)
|
||||
}
|
||||
|
||||
// Linking API`,
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(appDelegatePath, appDelegate);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
return withXcodeProject(config, (config) => {
|
||||
const project = config.modResults;
|
||||
const projectRoot = config.modRequest.projectRoot;
|
||||
const platformRoot = config.modRequest.platformProjectRoot;
|
||||
const projectName =
|
||||
config.modRequest.projectName ??
|
||||
IOSConfig.XcodeUtils.getProjectName(projectRoot);
|
||||
|
||||
for (const file of SWIFT_FILES) {
|
||||
const filepath = `${projectName}/${file}`;
|
||||
const absolutePath = path.join(platformRoot, filepath);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) continue;
|
||||
|
||||
const fileRef = project.pbxFileReferenceSection();
|
||||
const alreadyLinked = Object.values(fileRef).some(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
"path" in entry &&
|
||||
(entry.path === file ||
|
||||
entry.path === `${projectName}/${file}` ||
|
||||
String(entry.path).endsWith(`/${file}`)),
|
||||
);
|
||||
|
||||
if (!alreadyLinked) {
|
||||
IOSConfig.XcodeUtils.addBuildSourceFileToGroup({
|
||||
filepath,
|
||||
groupName: projectName,
|
||||
project,
|
||||
verbose: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const configurations = project.pbxXCBuildConfigurationSection();
|
||||
for (const key of Object.keys(configurations)) {
|
||||
const buildConfig = configurations[key];
|
||||
if (
|
||||
typeof buildConfig !== "object" ||
|
||||
!buildConfig.buildSettings ||
|
||||
buildConfig.buildSettings.PRODUCT_BUNDLE_IDENTIFIER !== "com.beenvoice.app"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
buildConfig.buildSettings.EXTRACT_APP_INTENTS_METADATA = "YES";
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withAppIntents;
|
||||
@@ -0,0 +1,64 @@
|
||||
// @ts-check
|
||||
const { withXcodeProject } = require("@expo/config-plugins");
|
||||
|
||||
const RELEASE_SIGN_KEY = '"CODE_SIGN_IDENTITY[sdk=iphoneos*]"';
|
||||
const MAIN_BUNDLE_ID = "com.beenvoice.app";
|
||||
const WIDGET_BUNDLE_ID = "com.beenvoice.app.ExpoWidgetsTarget";
|
||||
|
||||
/**
|
||||
* Keep App Store archives on distribution signing. Automatic signing can select
|
||||
* an Apple Development identity for the widget target when archiving from the CLI,
|
||||
* so both release targets use their explicit App Store profiles instead.
|
||||
*/
|
||||
function configureReleaseSigning(project) {
|
||||
const configurations = project.pbxXCBuildConfigurationSection();
|
||||
|
||||
for (const key of Object.keys(configurations)) {
|
||||
const buildConfig = configurations[key];
|
||||
if (
|
||||
!buildConfig ||
|
||||
typeof buildConfig !== "object" ||
|
||||
!buildConfig.buildSettings
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (buildConfig.name !== "Release") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bundleId = String(
|
||||
buildConfig.buildSettings.PRODUCT_BUNDLE_IDENTIFIER ?? "",
|
||||
).replaceAll('"', "");
|
||||
if (bundleId !== MAIN_BUNDLE_ID && bundleId !== WIDGET_BUNDLE_ID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const profileName =
|
||||
bundleId === WIDGET_BUNDLE_ID
|
||||
? (process.env.IOS_WIDGET_APPSTORE_PROFILE_NAME ?? WIDGET_BUNDLE_ID)
|
||||
: (process.env.IOS_MAIN_APPSTORE_PROFILE_NAME ?? MAIN_BUNDLE_ID);
|
||||
|
||||
buildConfig.buildSettings[RELEASE_SIGN_KEY] = '"Apple Distribution"';
|
||||
buildConfig.buildSettings.CODE_SIGN_STYLE = "Manual";
|
||||
buildConfig.buildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${profileName}"`;
|
||||
|
||||
if (process.env.APPLE_TEAM_ID) {
|
||||
buildConfig.buildSettings.DEVELOPMENT_TEAM = process.env.APPLE_TEAM_ID;
|
||||
}
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
/** @type {import('@expo/config-plugins').ConfigPlugin} */
|
||||
function withAppStoreSigning(config) {
|
||||
return withXcodeProject(config, (config) => {
|
||||
configureReleaseSigning(config.modResults);
|
||||
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withAppStoreSigning;
|
||||
module.exports.configureReleaseSigning = configureReleaseSigning;
|
||||
@@ -0,0 +1,104 @@
|
||||
// @ts-check
|
||||
const { createRunOncePlugin, withDangerousMod } = require("@expo/config-plugins");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const pkg = require("expo-mlkit-ocr/package.json");
|
||||
|
||||
const DISABLE_LINE = "ENV['EXPO_MLKIT_OCR_DISABLE_MLKIT'] = '1'";
|
||||
const MARKER = "expo-mlkit-ocr: iOS Simulator MLKit handling";
|
||||
|
||||
function resolveIosEngine(props = {}) {
|
||||
const raw = props.iosEngine ?? "auto";
|
||||
return raw === "auto" || raw === "mlkit" || raw === "vision" ? raw : "auto";
|
||||
}
|
||||
|
||||
function stripMarkedBlock(podfile) {
|
||||
const lines = podfile.split(/\r?\n/);
|
||||
const next = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (!line.includes(MARKER)) {
|
||||
next.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const markerIndent = (line.match(/^\s*/) ?? [""])[0].length;
|
||||
while (i + 1 < lines.length) {
|
||||
i += 1;
|
||||
const candidate = lines[i];
|
||||
const candidateIndent = (candidate.match(/^\s*/) ?? [""])[0].length;
|
||||
if (candidate.trim() === "end" && candidateIndent <= markerIndent) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next.join("\n");
|
||||
}
|
||||
|
||||
function stripOrphanedMlkitArchPatch(podfile) {
|
||||
return podfile
|
||||
.replace(
|
||||
/\n\s*end\s*\n\s*\n\s*installer\.aggregate_targets\.each do \|aggregate_target\|[\s\S]*?aggregate_target\.user_project\.save\s*\n\s*end\s*\n/g,
|
||||
"\n",
|
||||
)
|
||||
.replace(
|
||||
/\n\s*if ENV\['EXPO_MLKIT_OCR_DISABLE_MLKIT'\] == '1'\s*\n\s*installer\.pods_project\.targets\.each do \|target\|[\s\S]*?\n\s*end\s*\n/g,
|
||||
"\n",
|
||||
);
|
||||
}
|
||||
|
||||
function insertDisableLine(podfile) {
|
||||
const lines = podfile
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.trim() !== DISABLE_LINE);
|
||||
|
||||
let insertAt = 0;
|
||||
while (insertAt < lines.length && lines[insertAt].trim().startsWith("#")) {
|
||||
insertAt += 1;
|
||||
}
|
||||
|
||||
lines.splice(insertAt, 0, DISABLE_LINE);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** @type {import('@expo/config-plugins').ConfigPlugin<{ iosEngine?: "auto" | "mlkit" | "vision", disableMlkitOnSimulator?: boolean }>} */
|
||||
function withExpoMlkitOcrEnv(config, props = {}) {
|
||||
return withDangerousMod(config, [
|
||||
"ios",
|
||||
async (config) => {
|
||||
const podfilePath = path.join(config.modRequest.platformProjectRoot, "Podfile");
|
||||
if (!fs.existsSync(podfilePath)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const iosEngine = resolveIosEngine(props);
|
||||
const shouldDisableMlkit =
|
||||
iosEngine !== "mlkit" || props.disableMlkitOnSimulator === true;
|
||||
|
||||
let podfile = fs.readFileSync(podfilePath, "utf8");
|
||||
podfile = stripMarkedBlock(podfile);
|
||||
podfile = stripOrphanedMlkitArchPatch(podfile);
|
||||
|
||||
if (shouldDisableMlkit) {
|
||||
podfile = insertDisableLine(podfile);
|
||||
} else {
|
||||
podfile = podfile
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.trim() !== DISABLE_LINE)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
fs.writeFileSync(podfilePath, podfile.endsWith("\n") ? podfile : `${podfile}\n`);
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
module.exports = createRunOncePlugin(
|
||||
withExpoMlkitOcrEnv,
|
||||
"beenvoice-expo-mlkit-ocr-env",
|
||||
pkg.version,
|
||||
);
|
||||
@@ -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;
|
||||
@@ -0,0 +1,34 @@
|
||||
// @ts-check
|
||||
const { withXcodeProject } = require("@expo/config-plugins");
|
||||
|
||||
/**
|
||||
* expo-widgets seeds ExpoWidgetsTarget with hardcoded MARKETING_VERSION/CURRENT_PROJECT_VERSION
|
||||
* and does not update them on later prebuilds. App Store Connect warns when the extension
|
||||
* does not match the containing app.
|
||||
*/
|
||||
/** @type {import('@expo/config-plugins').ConfigPlugin} */
|
||||
function withSyncWidgetVersions(config) {
|
||||
return withXcodeProject(config, (config) => {
|
||||
const marketingVersion = config.version ?? "1.0.0";
|
||||
const buildNumber = config.ios?.buildNumber ?? "1";
|
||||
const project = config.modResults;
|
||||
const configurations = project.pbxXCBuildConfigurationSection();
|
||||
|
||||
for (const key of Object.keys(configurations)) {
|
||||
const buildConfig = configurations[key];
|
||||
if (!buildConfig?.buildSettings) continue;
|
||||
|
||||
const bundleId = buildConfig.buildSettings.PRODUCT_BUNDLE_IDENTIFIER;
|
||||
if (!bundleId || !String(bundleId).includes("ExpoWidgetsTarget")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
buildConfig.buildSettings.MARKETING_VERSION = marketingVersion;
|
||||
buildConfig.buildSettings.CURRENT_PROJECT_VERSION = buildNumber;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withSyncWidgetVersions;
|
||||
Reference in New Issue
Block a user