diff --git a/clients/apps/app/app.config.js b/clients/apps/app/app.config.js new file mode 100644 index 0000000000..e08e19d82a --- /dev/null +++ b/clients/apps/app/app.config.js @@ -0,0 +1,117 @@ +const IS_WIDGET_BUILD = process.env.EXPO_WIDGET_BUILD === '1' + +const plugins = [ + 'expo-router', + [ + 'expo-splash-screen', + { + image: './assets/images/splash-icon.png', + imageWidth: 120, + resizeMode: 'contain', + backgroundColor: '#0D0E10', + }, + ], + 'expo-secure-store', + 'expo-font', + 'expo-notifications', + [ + 'expo-asset', + { + assets: ['./assets/images/login-background.jpg'], + }, + ], + 'expo-web-browser', +] + +// Only include Sentry plugin for non-widget builds +// The Sentry plugin fails with @bacons/apple-targets blank template +// because it expects the "Bundle React Native code and images" build phase to exist +if (!IS_WIDGET_BUILD) { + plugins.push([ + '@sentry/react-native/expo', + { + url: 'https://sentry.io/', + project: 'polar-app', + organization: 'polar-sh', + }, + ]) +} + +plugins.push('@bacons/apple-targets') + +module.exports = { + expo: { + name: 'Polar', + slug: 'Polar', + version: '1.1.0', + orientation: 'portrait', + icon: './assets/images/icon.png', + scheme: 'polar', + userInterfaceStyle: 'dark', + newArchEnabled: true, + owner: 'polar-sh', + ios: { + appleTeamId: '55U3YA3QTA', + supportsTablet: false, + bundleIdentifier: 'com.polarsource.Polar', + infoPlist: { + ITSAppUsesNonExemptEncryption: false, + }, + icon: './assets/images/ios-dark.png', + entitlements: { + 'com.apple.developer.applesignin': ['Default'], + 'com.apple.security.application-groups': [ + 'group.com.polarsource.Polar', + ], + }, + associatedDomains: ['applinks:polar.godetour.link'], + }, + android: { + adaptiveIcon: { + foregroundImage: './assets/images/adaptive-icon.png', + backgroundColor: '#0D0E10', + }, + package: 'com.polarsource.Polar', + scheme: 'polar', + googleServicesFile: './google-services.json', + intentFilters: [ + { + action: 'VIEW', + autoVerify: true, + data: [ + { + scheme: 'https', + host: 'polar.godetour.link', + pathPrefix: '/baSjUTJtg8', + }, + ], + category: ['BROWSABLE', 'DEFAULT'], + }, + ], + }, + web: { + bundler: 'metro', + output: 'static', + favicon: './assets/images/favicon.png', + }, + plugins, + experiments: { + typedRoutes: true, + }, + extra: { + router: { + origin: false, + root: './app', + }, + eas: { + projectId: '0c79977b-c070-4416-8878-d8b8febe2e25', + }, + }, + runtimeVersion: { + policy: 'appVersion', + }, + updates: { + url: 'https://u.expo.dev/0c79977b-c070-4416-8878-d8b8febe2e25', + }, + }, +} diff --git a/clients/apps/app/app.json b/clients/apps/app/app.json deleted file mode 100644 index 527f6fadda..0000000000 --- a/clients/apps/app/app.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "expo": { - "name": "Polar", - "slug": "Polar", - "version": "1.1.0", - "orientation": "portrait", - "icon": "./assets/images/icon.png", - "scheme": "polar", - "userInterfaceStyle": "dark", - "newArchEnabled": true, - "owner": "polar-sh", - "ios": { - "supportsTablet": false, - "bundleIdentifier": "com.polarsource.Polar", - "infoPlist": { - "ITSAppUsesNonExemptEncryption": false - }, - "icon": "./assets/images/ios-dark.png", - "entitlements": { - "com.apple.developer.applesignin": ["Default"] - }, - "associatedDomains": ["applinks:polar.godetour.link"] - }, - "android": { - "adaptiveIcon": { - "foregroundImage": "./assets/images/adaptive-icon.png", - "backgroundColor": "#0D0E10" - }, - "package": "com.polarsource.Polar", - "scheme": "polar", - "googleServicesFile": "./google-services.json", - "intentFilters": [ - { - "action": "VIEW", - "autoVerify": true, - "data": [ - { - "scheme": "https", - "host": "polar.godetour.link", - "pathPrefix": "/baSjUTJtg8" - } - ], - "category": ["BROWSABLE", "DEFAULT"] - } - ] - }, - "web": { - "bundler": "metro", - "output": "static", - "favicon": "./assets/images/favicon.png" - }, - "plugins": [ - "expo-router", - [ - "expo-splash-screen", - { - "image": "./assets/images/splash-icon.png", - "imageWidth": 120, - "resizeMode": "contain", - "backgroundColor": "#0D0E10" - } - ], - "expo-secure-store", - "expo-font", - "expo-notifications", - [ - "expo-asset", - { - "assets": ["./assets/images/login-background.jpg"] - } - ], - "expo-web-browser", - [ - "@sentry/react-native/expo", - { - "url": "https://sentry.io/", - "project": "polar-app", - "organization": "polar-sh" - } - ] - ], - "experiments": { - "typedRoutes": true - }, - "extra": { - "router": { - "origin": false, - "root": "./app" - }, - "eas": { - "projectId": "0c79977b-c070-4416-8878-d8b8febe2e25" - } - }, - "runtimeVersion": { - "policy": "appVersion" - }, - "updates": { - "url": "https://u.expo.dev/0c79977b-c070-4416-8878-d8b8febe2e25" - } - } -} diff --git a/clients/apps/app/app/_layout.tsx b/clients/apps/app/app/_layout.tsx index 5762c98b79..15d966d0ac 100644 --- a/clients/apps/app/app/_layout.tsx +++ b/clients/apps/app/app/_layout.tsx @@ -1,17 +1,20 @@ import { Box } from '@/components/Shared/Box' import theme from '@/design-system/theme' import { SessionProvider } from '@/providers/SessionProvider' +import { ExtensionStorage } from '@bacons/apple-targets' import { useReactNavigationDevTools } from '@dev-plugins/react-navigation' import { InstrumentSerif_400Regular } from '@expo-google-fonts/instrument-serif/400Regular' import { InstrumentSerif_400Regular_Italic } from '@expo-google-fonts/instrument-serif/400Regular_Italic' import { useFonts } from '@expo-google-fonts/instrument-serif/useFonts' import NetInfo from '@react-native-community/netinfo' import * as Sentry from '@sentry/react-native' + import { ThemeProvider } from '@shopify/restyle' import { onlineManager } from '@tanstack/react-query' import { Slot, useNavigationContainerRef } from 'expo-router' import * as SplashScreen from 'expo-splash-screen' -import React, { useCallback } from 'react' +import React, { useCallback, useEffect } from 'react' +import { AppState } from 'react-native' import { GestureHandlerRootView } from 'react-native-gesture-handler' import { SafeAreaProvider } from 'react-native-safe-area-context' @@ -62,6 +65,14 @@ export default Sentry.wrap(function RootLayout() { InstrumentSerif_400Regular_Italic, }) + useEffect(() => { + AppState.addEventListener('change', (state) => { + if (state === 'background') { + ExtensionStorage.reloadWidget() + } + }) + }, []) + const onLayoutRootView = useCallback(() => { if (fontsLoaded) { // This tells the splash screen to hide immediately! If we call this after diff --git a/clients/apps/app/hooks/auth.ts b/clients/apps/app/hooks/auth.ts index 373f8d0e01..f1c0430333 100644 --- a/clients/apps/app/hooks/auth.ts +++ b/clients/apps/app/hooks/auth.ts @@ -1,6 +1,7 @@ import { useOAuthConfig } from '@/hooks/oauth' import { useNotifications } from '@/providers/NotificationsProvider' import { useSession } from '@/providers/SessionProvider' +import { ExtensionStorage } from '@bacons/apple-targets' import AsyncStorage from '@react-native-async-storage/async-storage' import { useQueryClient } from '@tanstack/react-query' import { revokeAsync } from 'expo-auth-session' @@ -13,6 +14,8 @@ import { useGetNotificationRecipient, } from './polar/notifications' +const widgetStorage = new ExtensionStorage('group.com.polarsource.Polar') + export const useLogout = () => { const { session, setSession } = useSession() const { expoPushToken } = useNotifications() @@ -44,6 +47,11 @@ export const useLogout = () => { WebBrowser.coolDownAsync().catch(() => {}) queryClient.clear() await AsyncStorage.clear() + + widgetStorage.set('widget_api_token', '') + widgetStorage.set('widget_organization_id', '') + widgetStorage.set('widget_organization_name', '') + setSession(null) router.replace('/') } catch (error) { diff --git a/clients/apps/app/package.json b/clients/apps/app/package.json index f63073bebb..cefc0e50cf 100644 --- a/clients/apps/app/package.json +++ b/clients/apps/app/package.json @@ -17,12 +17,14 @@ "lint": "pnpm format:check && expo lint", "typecheck": "tsc --noEmit", "push:test": "node tooling/push-notifications/send-push.js", - "postinstall": "pnpm --filter @polar-sh/client build" + "postinstall": "pnpm --filter @polar-sh/client build && patch-package", + "prewidget": "EXPO_NO_GIT_STATUS=1 EXPO_WIDGET_BUILD=1 npx expo prebuild --template ./node_modules/@bacons/apple-targets/prebuild-blank.tgz --clean" }, "jest": { "preset": "jest-expo" }, "dependencies": { + "@bacons/apple-targets": "^3.0.6", "@dev-plugins/react-navigation": "^0.3.1", "@dev-plugins/react-query": "^0.1.0", "@expo-google-fonts/instrument-serif": "^0.4.0", @@ -67,6 +69,7 @@ "expo-updates": "~29.0.12", "expo-web-browser": "~15.0.9", "nativewind": "^4.1.23", + "patch-package": "^8.0.1", "react": "19.1.0", "react-dom": "19.1.0", "react-error-boundary": "^6.0.0", diff --git a/clients/apps/app/providers/OrganizationProvider.tsx b/clients/apps/app/providers/OrganizationProvider.tsx index 0032438711..b13f2f4e51 100644 --- a/clients/apps/app/providers/OrganizationProvider.tsx +++ b/clients/apps/app/providers/OrganizationProvider.tsx @@ -1,6 +1,7 @@ import { Box } from '@/components/Shared/Box' import { useOrganizations } from '@/hooks/polar/organizations' import { useStorageState } from '@/hooks/storage' +import { ExtensionStorage } from '@bacons/apple-targets' import { schemas } from '@polar-sh/client' import AsyncStorage from '@react-native-async-storage/async-storage' import { Redirect, usePathname } from 'expo-router' @@ -8,6 +9,8 @@ import { createContext, PropsWithChildren, useEffect, useMemo } from 'react' import { ActivityIndicator } from 'react-native' import { useSession } from './SessionProvider' +const storage = new ExtensionStorage('group.com.polarsource.Polar') + export interface OrganizationContextValue { isLoading: boolean organization: schemas['Organization'] | undefined @@ -66,6 +69,13 @@ export function PolarOrganizationProvider({ children }: PropsWithChildren) { ) }, [organizationData, organizationId]) + useEffect(() => { + if (organization) { + storage.set('widget_organization_id', organization.id) + storage.set('widget_organization_name', organization.name) + } + }, [organization]) + const isLoading = isStorageLoading || isLoadingOrganizations const organizations = organizationData?.items ?? [] diff --git a/clients/apps/app/providers/SessionProvider.tsx b/clients/apps/app/providers/SessionProvider.tsx index af3ad14cfa..7a8e65bbdb 100644 --- a/clients/apps/app/providers/SessionProvider.tsx +++ b/clients/apps/app/providers/SessionProvider.tsx @@ -1,5 +1,13 @@ import { useStorageState } from '@/hooks/storage' -import { createContext, useContext, type PropsWithChildren } from 'react' +import { ExtensionStorage } from '@bacons/apple-targets' +import { + createContext, + useContext, + useEffect, + type PropsWithChildren, +} from 'react' + +const storage = new ExtensionStorage('group.com.polarsource.Polar') const AuthContext = createContext<{ setSession: (session: string | null) => void @@ -26,6 +34,12 @@ export function useSession() { export function SessionProvider({ children }: PropsWithChildren) { const [[isLoading, session], setSession] = useStorageState('session') + useEffect(() => { + if (session) { + storage.set('widget_api_token', session) + } + }, [session]) + return ( + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + \ No newline at end of file diff --git a/clients/apps/app/targets/widget/expo-target.config.js b/clients/apps/app/targets/widget/expo-target.config.js new file mode 100644 index 0000000000..b2cf113348 --- /dev/null +++ b/clients/apps/app/targets/widget/expo-target.config.js @@ -0,0 +1,12 @@ +/** @type {import('@bacons/apple-targets/app.plugin').ConfigFunction} */ +module.exports = (config) => ({ + type: 'widget', + icon: 'https://github.com/expo.png', + entitlements: { + 'com.apple.security.application-groups': + config.ios.entitlements['com.apple.security.application-groups'], + }, + colors: { + accent: '#FF7B54', + }, +}) diff --git a/clients/apps/app/targets/widget/generated.entitlements b/clients/apps/app/targets/widget/generated.entitlements new file mode 100644 index 0000000000..53906768bb --- /dev/null +++ b/clients/apps/app/targets/widget/generated.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.polarsource.Polar + + + \ No newline at end of file diff --git a/clients/apps/app/targets/widget/index.swift b/clients/apps/app/targets/widget/index.swift new file mode 100644 index 0000000000..1b0c946acc --- /dev/null +++ b/clients/apps/app/targets/widget/index.swift @@ -0,0 +1,10 @@ +import WidgetKit +import SwiftUI + +@main +struct exportWidgets: WidgetBundle { + var body: some Widget { + widget() + LockScreenWidget() + } +} diff --git a/clients/apps/app/targets/widget/widgets.swift b/clients/apps/app/targets/widget/widgets.swift new file mode 100644 index 0000000000..f1b343f64a --- /dev/null +++ b/clients/apps/app/targets/widget/widgets.swift @@ -0,0 +1,883 @@ +import WidgetKit +import SwiftUI +import Charts + +struct Provider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> SimpleEntry { + let placeholderData = generatePlaceholderData(days: 30) + let combinedMetrics = CombinedMetrics( + revenueValue: 425, + revenueChartData: placeholderData, + ordersValue: 12, + ordersChartData: generatePlaceholderData(days: 30, scale: 0.5), + averageOrderValue: 35.42, + averageOrderValueChartData: generatePlaceholderData(days: 30, scale: 0.3) + ) + return SimpleEntry(date: Date(), configuration: ConfigurationAppIntent(), metricValue: 425, metricValueDouble: nil, organizationName: "Acme Inc", chartData: placeholderData, lastUpdated: Date(), isError: false, isUnauthorized: false, combinedMetrics: combinedMetrics) + } + + func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry { + let defaults = UserDefaults(suiteName: "group.com.polarsource.Polar") + let orgName = defaults?.string(forKey: "widget_organization_name") + let days = configuration.timeFrame.days + + if let (metricValue, metricValueDouble, chartData, _) = await fetchMetrics(days: days, metricType: configuration.metricType) { + let combinedMetrics = await fetchAllMetrics(days: days) + return SimpleEntry(date: Date(), configuration: configuration, metricValue: metricValue, metricValueDouble: metricValueDouble, organizationName: orgName, chartData: chartData, lastUpdated: Date(), isError: false, isUnauthorized: false, combinedMetrics: combinedMetrics) + } + + let apiToken = defaults?.string(forKey: "widget_api_token") + let organizationId = defaults?.string(forKey: "widget_organization_id") + let isUnauthorized = await checkIfUnauthorized(apiToken: apiToken, organizationId: organizationId) + + let placeholderData = generatePlaceholderData(days: days) + return SimpleEntry(date: Date(), configuration: configuration, metricValue: 425, metricValueDouble: nil, organizationName: orgName, chartData: placeholderData, lastUpdated: Date(), isError: true, isUnauthorized: isUnauthorized, combinedMetrics: nil) + } + + func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline { + let currentDate = Date() + let defaults = UserDefaults(suiteName: "group.com.polarsource.Polar") + let orgName = defaults?.string(forKey: "widget_organization_name") + let days = configuration.timeFrame.days + + let result = await fetchMetrics(days: days, metricType: configuration.metricType) + let combinedMetrics = await fetchAllMetrics(days: days) + + let entry: SimpleEntry + if let (metricValue, metricValueDouble, chartData, _) = result { + entry = SimpleEntry(date: currentDate, configuration: configuration, metricValue: metricValue, metricValueDouble: metricValueDouble, organizationName: orgName, chartData: chartData, lastUpdated: currentDate, isError: false, isUnauthorized: false, combinedMetrics: combinedMetrics) + } else { + let apiToken = defaults?.string(forKey: "widget_api_token") + let organizationId = defaults?.string(forKey: "widget_organization_id") + let isUnauthorized = await checkIfUnauthorized(apiToken: apiToken, organizationId: organizationId) + + entry = SimpleEntry(date: currentDate, configuration: configuration, metricValue: 182, metricValueDouble: nil, organizationName: orgName, chartData: generatePlaceholderData(days: days), lastUpdated: currentDate, isError: true, isUnauthorized: isUnauthorized, combinedMetrics: nil) + } + + let nextUpdate = Calendar.current.date(byAdding: .minute, value: 5, to: currentDate)! + + return Timeline(entries: [entry], policy: .after(nextUpdate)) + } + + func generatePlaceholderData(days: Int, scale: Double = 1.0) -> [RevenueData] { + return (1...days).map { day in + let dayDouble = Double(day) + let baseGrowth = dayDouble * 8.0 * scale + let wave1 = sin(dayDouble * 0.3) * 40.0 * scale + let wave2 = cos(dayDouble * 0.15) * 25.0 * scale + let amount = max(10.0, baseGrowth + wave1 + wave2) + return RevenueData(day: day, amount: amount) + } + } + + private func calculateAverageOrderValue(revenue: Int, orders: Int) -> Double { + guard orders > 0 else { return 0 } + return Double(revenue) / Double(orders) / 100.0 + } + + private func checkIfUnauthorized(apiToken: String?, organizationId: String?) async -> Bool { + guard let apiToken = apiToken, + let organizationId = organizationId else { + return false + } + + let endDate = Date() + guard let startDate = Calendar.current.date(byAdding: .day, value: -7, to: endDate) else { + return false + } + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + + let startDateStr = dateFormatter.string(from: startDate) + let endDateStr = dateFormatter.string(from: endDate) + + var components = URLComponents(string: "https://api.polar.sh/v1/metrics/")! + components.queryItems = [ + URLQueryItem(name: "organization_id", value: organizationId), + URLQueryItem(name: "start_date", value: startDateStr), + URLQueryItem(name: "end_date", value: endDateStr), + URLQueryItem(name: "interval", value: "day"), + URLQueryItem(name: "timezone", value: TimeZone.current.identifier) + ] + + guard let url = components.url else { + return false + } + + var request = URLRequest(url: url) + request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") + + do { + let (_, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + return httpResponse.statusCode == 401 + } + + return false + } catch { + return false + } + } + + private func fetchMetrics(days: Int, metricType: MetricType) async -> (Int, Double?, [RevenueData], Int?)? { + let defaults = UserDefaults(suiteName: "group.com.polarsource.Polar") + guard let apiToken = defaults?.string(forKey: "widget_api_token"), + let organizationId = defaults?.string(forKey: "widget_organization_id") else { + return nil + } + + let endDate = Date() + guard let startDate = Calendar.current.date(byAdding: .day, value: -days, to: endDate) else { + return nil + } + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + + let startDateStr = dateFormatter.string(from: startDate) + let endDateStr = dateFormatter.string(from: endDate) + + var components = URLComponents(string: "https://api.polar.sh/v1/metrics/")! + components.queryItems = [ + URLQueryItem(name: "organization_id", value: organizationId), + URLQueryItem(name: "start_date", value: startDateStr), + URLQueryItem(name: "end_date", value: endDateStr), + URLQueryItem(name: "interval", value: "day"), + URLQueryItem(name: "timezone", value: TimeZone.current.identifier) + ] + + guard let url = components.url else { + return nil + } + + var request = URLRequest(url: url) + request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + var statusCode: Int? + if let httpResponse = response as? HTTPURLResponse { + statusCode = httpResponse.statusCode + + if httpResponse.statusCode == 401 { + return nil + } + } + + let decodedResponse = try JSONDecoder().decode(MetricsResponse.self, from: data) + + let metricValue: Int + let metricValueDouble: Double? + switch metricType { + case .revenue: + let revenueCents = decodedResponse.totals.revenue ?? 0 + metricValue = Int(Double(revenueCents) / 100.0) + metricValueDouble = nil + case .orders: + metricValue = decodedResponse.totals.orders ?? 0 + metricValueDouble = nil + case .averageOrderValue: + let aov = calculateAverageOrderValue( + revenue: decodedResponse.totals.revenue ?? 0, + orders: decodedResponse.totals.orders ?? 0 + ) + metricValue = Int(aov) + metricValueDouble = aov + } + + var cumulativeValue: Double = 0 + let chartData = decodedResponse.periods.enumerated().map { index, period -> RevenueData in + let periodValue: Double + switch metricType { + case .revenue: + periodValue = Double(period.revenue ?? 0) / 100.0 + case .orders: + periodValue = Double(period.orders ?? 0) + case .averageOrderValue: + let periodRevenue = period.revenue ?? 0 + let periodOrders = period.orders ?? 0 + periodValue = calculateAverageOrderValue(revenue: periodRevenue, orders: periodOrders) + } + cumulativeValue += periodValue + + return RevenueData(day: index + 1, amount: cumulativeValue) + } + + return (metricValue, metricValueDouble, chartData, statusCode) + + } catch { + return nil + } + } + + private func fetchAllMetrics(days: Int) async -> CombinedMetrics? { + let defaults = UserDefaults(suiteName: "group.com.polarsource.Polar") + guard let apiToken = defaults?.string(forKey: "widget_api_token"), + let organizationId = defaults?.string(forKey: "widget_organization_id") else { + return nil + } + + let endDate = Date() + guard let startDate = Calendar.current.date(byAdding: .day, value: -days, to: endDate) else { + return nil + } + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + + let startDateStr = dateFormatter.string(from: startDate) + let endDateStr = dateFormatter.string(from: endDate) + + var components = URLComponents(string: "https://api.polar.sh/v1/metrics/")! + components.queryItems = [ + URLQueryItem(name: "organization_id", value: organizationId), + URLQueryItem(name: "start_date", value: startDateStr), + URLQueryItem(name: "end_date", value: endDateStr), + URLQueryItem(name: "interval", value: "day"), + URLQueryItem(name: "timezone", value: TimeZone.current.identifier) + ] + + guard let url = components.url else { + return nil + } + + var request = URLRequest(url: url) + request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") + + do { + let (data, _) = try await URLSession.shared.data(for: request) + + let decodedResponse = try JSONDecoder().decode(MetricsResponse.self, from: data) + + let revenueValue = Int(Double(decodedResponse.totals.revenue ?? 0) / 100.0) + let ordersValue = decodedResponse.totals.orders ?? 0 + let averageOrderValue = calculateAverageOrderValue( + revenue: decodedResponse.totals.revenue ?? 0, + orders: decodedResponse.totals.orders ?? 0 + ) + + var cumulativeRevenue: Double = 0 + let revenueChartData = decodedResponse.periods.enumerated().map { index, period -> RevenueData in + let periodValue = Double(period.revenue ?? 0) / 100.0 + cumulativeRevenue += periodValue + return RevenueData(day: index + 1, amount: cumulativeRevenue) + } + + var cumulativeOrders: Double = 0 + let ordersChartData = decodedResponse.periods.enumerated().map { index, period -> RevenueData in + let periodValue = Double(period.orders ?? 0) + cumulativeOrders += periodValue + return RevenueData(day: index + 1, amount: cumulativeOrders) + } + + var cumulativeAOV: Double = 0 + let aovChartData = decodedResponse.periods.enumerated().map { index, period -> RevenueData in + let periodRevenue = period.revenue ?? 0 + let periodOrders = period.orders ?? 0 + let periodAOV = calculateAverageOrderValue(revenue: periodRevenue, orders: periodOrders) + cumulativeAOV += periodAOV + return RevenueData(day: index + 1, amount: cumulativeAOV) + } + + return CombinedMetrics( + revenueValue: revenueValue, + revenueChartData: revenueChartData, + ordersValue: ordersValue, + ordersChartData: ordersChartData, + averageOrderValue: averageOrderValue, + averageOrderValueChartData: aovChartData + ) + + } catch { + return nil + } + } +} + +struct MetricsResponse: Codable { + let totals: MetricsTotals + let periods: [MetricsPeriod] +} + +struct MetricsTotals: Codable { + let revenue: Int? + let orders: Int? +} + +struct MetricsPeriod: Codable { + let revenue: Int? + let orders: Int? +} + +struct CombinedMetrics { + let revenueValue: Int + let revenueChartData: [RevenueData] + let ordersValue: Int + let ordersChartData: [RevenueData] + let averageOrderValue: Double + let averageOrderValueChartData: [RevenueData] +} + +struct SimpleEntry: TimelineEntry { + let date: Date + let configuration: ConfigurationAppIntent + let metricValue: Int + let metricValueDouble: Double? + let organizationName: String? + let chartData: [RevenueData] + let lastUpdated: Date + let isError: Bool + let isUnauthorized: Bool + let combinedMetrics: CombinedMetrics? +} + +struct RevenueData: Identifiable { + let id = UUID() + let day: Int + let amount: Double +} + +func formatCompactValue(_ value: Int) -> String { + let absValue = abs(value) + + if absValue >= 1_000_000 { + let millions = Double(absValue) / 1_000_000.0 + if millions >= 10 { + return "$\(Int(millions))M" + } else { + return String(format: "$%.1fM", millions) + } + } else if absValue >= 1_000 { + let thousands = Double(absValue) / 1_000.0 + if thousands >= 100 { + return "$\(Int(thousands))k" + } else if thousands >= 10 { + return String(format: "$%.0fk", thousands) + } else { + return String(format: "$%.1fk", thousands) + } + } else { + return "$\(absValue)" + } +} + +func formatAverageOrderValue(_ value: Double) -> String { + if value >= 1000 { + return String(format: "$%.1fk", value / 1000.0) + } else if value >= 100 { + return String(format: "$%.0f", value) + } else { + return String(format: "$%.2f", value) + } +} + +struct widgetEntryView : View { + var entry: Provider.Entry + @Environment(\.widgetFamily) var family + @Environment(\.colorScheme) var colorScheme + + var body: some View { + if family == .systemLarge && entry.combinedMetrics != nil { + largeWidgetView + } else { + standardWidgetView + } + } + + private var largeWidgetView: some View { + let combinedMetrics = entry.combinedMetrics! + let timeFrameText = entry.configuration.timeFrame.rawValue + + let primaryTextColor: Color = colorScheme == .dark ? .white : .black + let secondaryTextColor: Color = colorScheme == .dark ? .white.opacity(0.6) : .black.opacity(0.6) + let logoImageName = colorScheme == .dark ? "PolarLogoWhite" : "PolarLogoBlack" + + return ZStack { + if !entry.isError { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(logoImageName) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + + Text("Metrics | \(timeFrameText)") + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(primaryTextColor) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 24) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Revenue") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(primaryTextColor) + + Spacer() + + Text(formatCompactValue(combinedMetrics.revenueValue)) + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(primaryTextColor) + } + .padding(.horizontal, 16) + + Chart(combinedMetrics.revenueChartData) { data in + LineMark( + x: .value("Day", data.day), + y: .value("Revenue", data.amount) + ) + .interpolationMethod(.monotone) + .foregroundStyle( + LinearGradient( + colors: [Color(hex: "005FFF"), Color(hex: "005FFF").opacity(0.7)], + startPoint: .leading, + endPoint: .trailing + ) + ) + .lineStyle(StrokeStyle(lineWidth: 3)) + + AreaMark( + x: .value("Day", data.day), + y: .value("Revenue", data.amount) + ) + .interpolationMethod(.monotone) + .foregroundStyle( + LinearGradient( + colors: [Color(hex: "005FFF").opacity(0.3), Color(hex: "005FFF").opacity(0.05)], + startPoint: .top, + endPoint: .bottom + ) + ) + } + .chartXScale(domain: 1...combinedMetrics.revenueChartData.count) + .chartYScale(domain: 0...(combinedMetrics.revenueChartData.map { $0.amount }.max() ?? 240) * 1.2) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .frame(height: 30) + .padding(.horizontal, 16) + } + + Divider() + .padding(.horizontal, 16) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Orders") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(primaryTextColor) + + Spacer() + + Text("\(combinedMetrics.ordersValue)") + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(primaryTextColor) + } + .padding(.horizontal, 16) + + Chart(combinedMetrics.ordersChartData) { data in + LineMark( + x: .value("Day", data.day), + y: .value("Orders", data.amount) + ) + .interpolationMethod(.monotone) + .foregroundStyle( + LinearGradient( + colors: [Color(hex: "005FFF"), Color(hex: "005FFF").opacity(0.7)], + startPoint: .leading, + endPoint: .trailing + ) + ) + .lineStyle(StrokeStyle(lineWidth: 3)) + + AreaMark( + x: .value("Day", data.day), + y: .value("Orders", data.amount) + ) + .interpolationMethod(.monotone) + .foregroundStyle( + LinearGradient( + colors: [Color(hex: "005FFF").opacity(0.3), Color(hex: "005FFF").opacity(0.05)], + startPoint: .top, + endPoint: .bottom + ) + ) + } + .chartXScale(domain: 1...combinedMetrics.ordersChartData.count) + .chartYScale(domain: 0...(combinedMetrics.ordersChartData.map { $0.amount }.max() ?? 240) * 1.2) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .frame(height: 30) + .padding(.horizontal, 16) + } + + Divider() + .padding(.horizontal, 16) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Avg Order Value") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(primaryTextColor) + + Spacer() + + Text(formatAverageOrderValue(combinedMetrics.averageOrderValue)) + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(primaryTextColor) + } + .padding(.horizontal, 16) + + Chart(combinedMetrics.averageOrderValueChartData) { data in + LineMark( + x: .value("Day", data.day), + y: .value("AOV", data.amount) + ) + .interpolationMethod(.monotone) + .foregroundStyle( + LinearGradient( + colors: [Color(hex: "005FFF"), Color(hex: "005FFF").opacity(0.7)], + startPoint: .leading, + endPoint: .trailing + ) + ) + .lineStyle(StrokeStyle(lineWidth: 3)) + + AreaMark( + x: .value("Day", data.day), + y: .value("AOV", data.amount) + ) + .interpolationMethod(.monotone) + .foregroundStyle( + LinearGradient( + colors: [Color(hex: "005FFF").opacity(0.3), Color(hex: "005FFF").opacity(0.05)], + startPoint: .top, + endPoint: .bottom + ) + ) + } + .chartXScale(domain: 1...combinedMetrics.averageOrderValueChartData.count) + .chartYScale(domain: 0...(combinedMetrics.averageOrderValueChartData.map { $0.amount }.max() ?? 240) * 1.2) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .frame(height: 30) + .padding(.horizontal, 16) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + if entry.isError { + Text(entry.isUnauthorized ? "Log in to see your data" : "Error fetching data") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(primaryTextColor) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + } + } + .unredacted() + } + + private var standardWidgetView: some View { + let maxValue = entry.chartData.map { $0.amount }.max() ?? 240 + let yAxisMax = maxValue * 1.2 + let timeFrameText = entry.configuration.timeFrame.rawValue + let metricType = entry.configuration.metricType + let metricLabel = metricType.rawValue + + let formattedValue: String + switch metricType { + case .revenue: + formattedValue = formatCompactValue(entry.metricValue) + case .orders: + formattedValue = "\(entry.metricValue)" + case .averageOrderValue: + let aovValue = entry.metricValueDouble ?? Double(entry.metricValue) + formattedValue = formatAverageOrderValue(aovValue) + } + + let primaryTextColor: Color = colorScheme == .dark ? .white : .black + let secondaryTextColor: Color = colorScheme == .dark ? .white.opacity(0.6) : .black.opacity(0.6) + let logoImageName = colorScheme == .dark ? "PolarLogoWhite" : "PolarLogoBlack" + + let chartColor = "005FFF" + + return ZStack { + if !entry.isError { + VStack(alignment: .leading, spacing: family == .systemSmall ? 4 : 2) { + if family == .systemSmall { + let shortTimeFrame = timeFrameText.replacingOccurrences(of: " days", with: "d") + + let shouldShowLabel = formattedValue.count <= 3 + + HStack(spacing: 4) { + Image(logoImageName) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + + if shouldShowLabel { + Text("\(metricLabel)") + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(primaryTextColor) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + + Spacer(minLength: 2) + + Text(formattedValue) + .font(.headline) + .fontWeight(.bold) + .foregroundStyle(primaryTextColor) + .lineLimit(1) + } + .padding(.horizontal, 6) + } else { + HStack(spacing: 10) { + Image(logoImageName) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + + Text("\(metricLabel) | \(timeFrameText)") + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(primaryTextColor) + + Spacer() + + Text(formattedValue) + .font(.title) + .fontWeight(.bold) + .foregroundStyle(primaryTextColor) + } + .padding(.horizontal, family == .systemSmall ? 6 : 8) + } + + Chart(entry.chartData) { data in + LineMark( + x: .value("Day", data.day), + y: .value("Revenue", data.amount) + ) + .interpolationMethod(.monotone) + .foregroundStyle( + LinearGradient( + colors: [Color(hex: chartColor), Color(hex: chartColor).opacity(0.7)], + startPoint: .leading, + endPoint: .trailing + ) + ) + .lineStyle(StrokeStyle(lineWidth: family == .systemSmall ? 2.5 : 3)) + + AreaMark( + x: .value("Day", data.day), + y: .value("Revenue", data.amount) + ) + .interpolationMethod(.monotone) + .foregroundStyle( + LinearGradient( + colors: [Color(hex: chartColor).opacity(0.3), Color(hex: chartColor).opacity(0.05)], + startPoint: .top, + endPoint: .bottom + ) + ) + } + .chartXScale(domain: 1...entry.chartData.count) + .chartYScale(domain: 0...yAxisMax) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .frame(maxHeight: .infinity) + .padding(.horizontal, family == .systemSmall ? 6 : 8) + } + .padding(.vertical, family == .systemSmall ? 0 : 10) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + if entry.isError { + Text(entry.isUnauthorized ? "Log in to see your analytics" : "Error fetching analytics") + .font(family == .systemSmall ? .caption : .subheadline) + .fontWeight(.semibold) + .foregroundStyle(primaryTextColor) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + } + } + .unredacted() + } +} + +struct widget: Widget { + let kind: String = "widget" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in + widgetEntryView(entry: entry) + .containerBackground(for: .widget) { + AdaptiveWidgetBackground() + } + } + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} + +struct AdaptiveWidgetBackground: View { + @Environment(\.colorScheme) var colorScheme + + var body: some View { + if colorScheme == .dark { + LinearGradient( + colors: [Color(red: 0.1, green: 0.1, blue: 0.15), Color(red: 0.05, green: 0.05, blue: 0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } else { + LinearGradient( + colors: [Color(red: 0.95, green: 0.95, blue: 0.97), Color(red: 0.90, green: 0.90, blue: 0.92)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + } +} + +struct LockScreenWidget: Widget { + let kind: String = "LockScreenWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in + LockScreenWidgetView(entry: entry) + } + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) + .configurationDisplayName("Polar Lock Screen") + .description("Quick glance at your metrics.") + } +} + +struct LockScreenWidgetView: View { + var entry: Provider.Entry + @Environment(\.widgetFamily) var family + + var body: some View { + switch family { + case .accessoryCircular: + circularView + case .accessoryRectangular: + rectangularView + case .accessoryInline: + inlineView + default: + EmptyView() + } + } + + private var circularView: some View { + let metricType = entry.configuration.metricType + let formattedValue = metricType == .revenue ? formatCompactValue(entry.metricValue) : "\(entry.metricValue)" + let iconName = metricType == .revenue ? "chart.line.uptrend.xyaxis" : "chart.line.uptrend.xyaxis" + + return ZStack { + AccessoryWidgetBackground() + VStack(spacing: 1) { + Image(systemName: iconName) + .font(.callout) + Text(formattedValue) + .font(.caption2) + .fontWeight(.bold) + } + } + } + + private var rectangularView: some View { + let metricType = entry.configuration.metricType + let formattedValue = metricType == .revenue ? formatCompactValue(entry.metricValue) : "\(entry.metricValue)" + let timeFrameText = entry.configuration.timeFrame.rawValue + + return HStack(spacing: 8) { + Image(systemName: "chart.line.uptrend.xyaxis") + .font(.system(size: 32)) + .frame(maxHeight: .infinity) + + VStack(alignment: .leading, spacing: 0) { + Text(formattedValue) + .font(.headline) + .fontWeight(.bold) + Text("Past \(timeFrameText)") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var inlineView: some View { + let metricType = entry.configuration.metricType + let formattedValue = metricType == .revenue ? formatCompactValue(entry.metricValue) : "\(entry.metricValue)" + + return Text("\(metricType.rawValue): \(formattedValue)") + } +} + +#Preview(as: .systemSmall) { + widget() +} timeline: { + let placeholderData = (1...30).map { i in RevenueData(day: i, amount: Double(i) * 10.0) } + let ordersData = (1...30).map { i in RevenueData(day: i, amount: Double(i) * 0.5) } + let aovData = (1...30).map { i in RevenueData(day: i, amount: Double(i) * 0.3) } + let combinedMetrics = CombinedMetrics( + revenueValue: 425, + revenueChartData: placeholderData, + ordersValue: 12, + ordersChartData: ordersData, + averageOrderValue: 35.42, + averageOrderValueChartData: aovData + ) + let config = ConfigurationAppIntent() + SimpleEntry(date: .now, configuration: config, metricValue: 425, metricValueDouble: nil, organizationName: "Acme Inc", chartData: placeholderData, lastUpdated: Date(), isError: false, isUnauthorized: false, combinedMetrics: combinedMetrics) +} + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} diff --git a/clients/pnpm-lock.yaml b/clients/pnpm-lock.yaml index 6f04c73306..5803a536bb 100644 --- a/clients/pnpm-lock.yaml +++ b/clients/pnpm-lock.yaml @@ -37,6 +37,9 @@ importers: apps/app: dependencies: + '@bacons/apple-targets': + specifier: ^3.0.6 + version: 3.0.6 '@dev-plugins/react-navigation': specifier: ^0.3.1 version: 0.3.1(@react-navigation/core@7.13.0(react@19.1.0))(expo@54.0.27)(react@19.1.0) @@ -169,6 +172,9 @@ importers: nativewind: specifier: ^4.1.23 version: 4.2.1(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.3)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.3)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.3)(react@19.1.0))(react@19.1.0))(react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.3)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.3)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.18(yaml@2.8.1)) + patch-package: + specifier: ^8.0.1 + version: 8.0.1 react: specifier: 19.1.0 version: 19.1.0 @@ -1428,6 +1434,12 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@bacons/apple-targets@3.0.6': + resolution: {integrity: sha512-WsKkhDG1CEq1upX+sWqdeAPu7fdQUUzrXd6spJxykz2iuzmAwGlLGx2j59LlWH8/igwRpyi3mW+PYbXH+usyzg==} + + '@bacons/xcode@1.0.0-alpha.27': + resolution: {integrity: sha512-WlemTiwpwwVH8IMvg4VBsyxGUuFWUfzeWKzxhU+dRxd8MHvnX7F9PlWCf7T0ZXmtjbl39iEgQB+/Tf3wuGJbyg==} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -2078,6 +2090,9 @@ packages: '@expo/package-manager@1.9.9': resolution: {integrity: sha512-Nv5THOwXzPprMJwbnXU01iXSrCp3vJqly9M4EJ2GkKko9Ifer2ucpg7x6OUsE09/lw+npaoUnHMXwkw7gcKxlg==} + '@expo/plist@0.0.18': + resolution: {integrity: sha512-+48gRqUiz65R21CZ/IXa7RNBXgAI/uPSdvJqoN9x1hfL44DNbUoWHgHiEXTx7XelcATpDwNTz6sHLfy0iNqf+w==} + '@expo/plist@0.4.8': resolution: {integrity: sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==} @@ -3528,6 +3543,9 @@ packages: '@react-native/normalize-colors@0.74.89': resolution: {integrity: sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==} + '@react-native/normalize-colors@0.79.7': + resolution: {integrity: sha512-RrvewhdanEWhlyrHNWGXGZCc6MY0JGpNgRzA8y6OomDz0JmlnlIsbBHbNpPnIrt9Jh2KaV10KTscD1Ry8xU9gQ==} + '@react-native/normalize-colors@0.81.5': resolution: {integrity: sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==} @@ -4766,6 +4784,11 @@ packages: '@webgpu/types@0.1.21': resolution: {integrity: sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==} + '@xmldom/xmldom@0.7.13': + resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==} + engines: {node: '>=10.0.0'} + deprecated: this version is no longer supported, please update to at least 0.8.* + '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} @@ -4779,6 +4802,9 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@yarnpkg/lockfile@1.1.0': + resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} + abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -6630,6 +6656,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + find-yarn-workspace-root@2.0.0: + resolution: {integrity: sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} @@ -6715,6 +6744,10 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -7618,6 +7651,10 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stable-stringify@1.3.0: + resolution: {integrity: sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==} + engines: {node: '>= 0.4'} + json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -7630,6 +7667,12 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + jsonify@0.0.1: + resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -7643,6 +7686,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + klaw-sync@6.0.0: + resolution: {integrity: sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -8773,6 +8819,11 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + patch-package@8.0.1: + resolution: {integrity: sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==} + engines: {node: '>=14', npm: '>5'} + hasBin: true + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -9840,6 +9891,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@2.0.0: + resolution: {integrity: sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==} + engines: {node: '>=6'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -10220,6 +10275,10 @@ packages: resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} hasBin: true + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + engines: {node: '>=14.14'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -10509,6 +10568,10 @@ packages: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -10586,6 +10649,10 @@ packages: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} hasBin: true + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -10930,6 +10997,10 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xmlbuilder@14.0.0: + resolution: {integrity: sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg==} + engines: {node: '>=8.0'} + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -11711,6 +11782,23 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bacons/apple-targets@3.0.6': + dependencies: + '@bacons/xcode': 1.0.0-alpha.27 + '@react-native/normalize-colors': 0.79.7 + debug: 4.4.3(supports-color@10.2.2) + glob: 10.5.0 + transitivePeerDependencies: + - supports-color + + '@bacons/xcode@1.0.0-alpha.27': + dependencies: + '@expo/plist': 0.0.18 + debug: 4.4.3(supports-color@10.2.2) + uuid: 8.3.2 + transitivePeerDependencies: + - supports-color + '@bcoe/v8-coverage@0.2.3': {} '@cfworker/json-schema@4.1.1': {} @@ -12501,6 +12589,12 @@ snapshots: ora: 3.4.0 resolve-workspace-root: 2.0.0 + '@expo/plist@0.0.18': + dependencies: + '@xmldom/xmldom': 0.7.13 + base64-js: 1.5.1 + xmlbuilder: 14.0.0 + '@expo/plist@0.4.8': dependencies: '@xmldom/xmldom': 0.8.11 @@ -14509,6 +14603,8 @@ snapshots: '@react-native/normalize-colors@0.74.89': {} + '@react-native/normalize-colors@0.79.7': {} + '@react-native/normalize-colors@0.81.5': {} '@react-native/virtualized-lists@0.81.5(@types/react@19.2.3)(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.3)(react@19.1.0))(react@19.1.0)': @@ -15914,6 +16010,8 @@ snapshots: '@webgpu/types@0.1.21': {} + '@xmldom/xmldom@0.7.13': {} + '@xmldom/xmldom@0.8.11': {} '@xobotyi/scrollbar-width@1.9.5': {} @@ -15922,6 +16020,8 @@ snapshots: '@xtuc/long@4.2.2': {} + '@yarnpkg/lockfile@1.1.0': {} + abab@2.0.6: {} abort-controller@3.0.0: @@ -18181,6 +18281,10 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + find-yarn-workspace-root@2.0.0: + dependencies: + micromatch: 4.0.8 + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 @@ -18249,6 +18353,12 @@ snapshots: fresh@2.0.0: {} + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -19495,6 +19605,14 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stable-stringify@1.3.0: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + isarray: 2.0.5 + jsonify: 0.0.1 + object-keys: 1.1.1 + json5@1.0.2: dependencies: minimist: 1.2.8 @@ -19505,6 +19623,14 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonify@0.0.1: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -19527,6 +19653,10 @@ snapshots: dependencies: json-buffer: 3.0.1 + klaw-sync@6.0.0: + dependencies: + graceful-fs: 4.2.11 + kleur@3.0.3: {} ky@1.14.1: {} @@ -21070,6 +21200,23 @@ snapshots: parseurl@1.3.3: {} + patch-package@8.0.1: + dependencies: + '@yarnpkg/lockfile': 1.1.0 + chalk: 4.1.2 + ci-info: 3.9.0 + cross-spawn: 7.0.6 + find-yarn-workspace-root: 2.0.0 + fs-extra: 10.1.0 + json-stable-stringify: 1.3.0 + klaw-sync: 6.0.0 + minimist: 1.2.8 + open: 7.4.2 + semver: 7.7.3 + slash: 2.0.0 + tmp: 0.2.5 + yaml: 2.8.1 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -22363,6 +22510,8 @@ snapshots: sisteransi@1.0.5: {} + slash@2.0.0: {} + slash@3.0.0: {} slash@5.1.0: {} @@ -22742,6 +22891,8 @@ snapshots: dependencies: tldts-core: 7.0.19 + tmp@0.2.5: {} + tmpl@1.0.5: {} to-regex-range@5.0.1: @@ -23034,6 +23185,8 @@ snapshots: universalify@0.2.0: {} + universalify@2.0.1: {} + unpipe@1.0.0: {} unplugin@1.0.1: @@ -23144,6 +23297,8 @@ snapshots: uuid@7.0.3: {} + uuid@8.3.2: {} + uuid@9.0.1: {} v8-compile-cache-lib@3.0.1: {} @@ -23537,6 +23692,8 @@ snapshots: xmlbuilder@11.0.1: {} + xmlbuilder@14.0.0: {} + xmlbuilder@15.1.1: {} xmlchars@2.2.0: {}