From 05d50e2bf6624606dc0d910541066f370dfb2cbd Mon Sep 17 00:00:00 2001 From: sebastianekstrom Date: Thu, 11 Dec 2025 21:33:11 +0100 Subject: [PATCH] Add iOS widget --- clients/apps/app/app.config.js | 117 +++ clients/apps/app/app.json | 101 -- clients/apps/app/app/_layout.tsx | 13 +- clients/apps/app/hooks/auth.ts | 8 + clients/apps/app/package.json | 5 +- .../app/providers/OrganizationProvider.tsx | 10 + .../apps/app/providers/SessionProvider.tsx | 16 +- .../apps/app/targets/widget/AppIntent.swift | 47 + .../widget/Assets.xcassets/Contents.json | 6 + .../PolarLogoBlack.imageset/Contents.json | 21 + .../PolarLogoBlack.png | Bin 0 -> 4668 bytes .../PolarLogoWhite.imageset/Contents.json | 21 + .../PolarLogoWhite.png | Bin 0 -> 7199 bytes .../accent.colorset/Contents.json | 20 + clients/apps/app/targets/widget/Info.plist | 11 + .../app/targets/widget/expo-target.config.js | 12 + .../app/targets/widget/generated.entitlements | 10 + clients/apps/app/targets/widget/index.swift | 10 + clients/apps/app/targets/widget/widgets.swift | 883 ++++++++++++++++++ clients/pnpm-lock.yaml | 157 ++++ 20 files changed, 1364 insertions(+), 104 deletions(-) create mode 100644 clients/apps/app/app.config.js delete mode 100644 clients/apps/app/app.json create mode 100644 clients/apps/app/targets/widget/AppIntent.swift create mode 100644 clients/apps/app/targets/widget/Assets.xcassets/Contents.json create mode 100644 clients/apps/app/targets/widget/Assets.xcassets/PolarLogoBlack.imageset/Contents.json create mode 100644 clients/apps/app/targets/widget/Assets.xcassets/PolarLogoBlack.imageset/PolarLogoBlack.png create mode 100644 clients/apps/app/targets/widget/Assets.xcassets/PolarLogoWhite.imageset/Contents.json create mode 100644 clients/apps/app/targets/widget/Assets.xcassets/PolarLogoWhite.imageset/PolarLogoWhite.png create mode 100644 clients/apps/app/targets/widget/Assets.xcassets/accent.colorset/Contents.json create mode 100644 clients/apps/app/targets/widget/Info.plist create mode 100644 clients/apps/app/targets/widget/expo-target.config.js create mode 100644 clients/apps/app/targets/widget/generated.entitlements create mode 100644 clients/apps/app/targets/widget/index.swift create mode 100644 clients/apps/app/targets/widget/widgets.swift 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 ( QoqAaWr z>POVZ{~>^GK_Kjuh10We7b?WvSf{W&3lC@E;wE6VR9DUMEw|$3hT1) zKo*XQaC%1pJKR10dzUQyDhqF;N~Cr)k0EjRYZgZ4xOK-i&K!2GNB;NGsEq5vfL1ag z41&jNSs0(=+5_7dV?suZ%fd2LI=umD;)J1@j0tf&KgWHWV2m*0uq?cR3Sm=>ko3h_ zxC|9*sTsuIV5AAoAIS%l*!c^`YA3xmuR zJyDskQv{XHJ&ww{gK%(^mKxH|%ffD1*dYr;vv5Wh{)2LY5C-O*o#QY%k6R00o4Ej` zmDnY%FPtZwuncn&Dt*DiL{;olh01SPRws?d%OHiIEtb1|Lr#(~vJE8DZMAEIfoFBfFuzl_U9#gRY)&1iQ5XL{FSg5jWkkb27Tf0 zn!IeoCVC+#J)&Nx1SxJB^@uukI%)|nzx(BRu8q>}L;Zlb9FKBC7Q?Wh&%)4ku(gav z%4$^mPa3@%v|U#a3BMfaiH*NBXG5tbLc>v%eufKCML13$fckf4G0HhMuSV@KNv50) zeb{1sk7l6{YUx0xJQS3#8;~fYpc6D^MnBXNQ5X~Ek$Rp8>c2Foo1OkU>JKE!aVV!D zOQN#EoYk;tKud6R&UmJB)jDbeL3Y44R z`=RI!_$F*Exg*c_OB9FY5vYT<3o^6dFZ%d|yJ4R6P@uqI~S@*U^DKN=Dl2mK3nke@6L3xEZ=;g+3{k8>ZwLiRNwi zaPCSCTPG>yh~Fu&hpp*(zR>&79{u(}NiAWf6C4DkLaJr#(qJQ+-$%Ty(i!=^3Ppl6 z6D~knL#{E(_IJvx4)Tc~EvCm$(E-7u+39JAH>^xWQ9FptStv@xYZ0Q)qq#5;X@*R9 z%CB(B?ULt_!ycMY_e4TVNaQ1(Myz>&K*-eyhV4 z8z5J`b;kswuvL}~{sTJ1%mTr@ZH+3>VDo_t8ea{(1UlY3DzOz&`XvLf?qu%_b>(X7G0i zwA5xqW%&0eQG_Sa8X%6#5_#U`q`m2+?HK&cem|<@Vf;B-;oriS5+Y@pXfHz_uC9m9 z;2BE&UqtI*D*V2m?3A75lpCq!j}MQNPV*%(B=9&Cr5}0RJXG%JwMS`U2<(m`v&e|= zLx+QuxTnxMn4U-<V-O&pu80%R8bGVMwlO}g1A zGMVRiwT=JzXdOJ9NFV5=c?{V#`k%D8)5yy`p>9ItkH7{p=@!`9b1rm7-fh%%Fmzao z)Ux=DQP4w4<%d3${~SxLLaB(rG~Z2>?JHt zr1x>syxq<1mAEBFUY>~AJPaKQ8*YLj@H9MCvB~hww;r<+=yRf#E&9F-VGb38u8}pGKZ(3YnA1K=n5L*SxYOOv>lza z#T|^_q#^sl1`a*e_<6h$Pie9`aF`L_6&SL}Nn7rud5gx^jChMaBgl|puz{=RjYgU^ z)Xjzs7DHNXWf43L@zzM5pUct&%itMIhU@|xtX1e9P8sh^?*2x6E7u?b-aNL%NgLv% zS&X!q;wb4$RXFm2d;(MX>u+B-dCUvf>EVK6| z*sz-szsb31+1Q?`x0jPJFP<&mEbxX+OE|sZYMPT~83OCOJB@jkkL7A1bFGef z4m!`9+AOd}3a?MzlHp#bPK%Li$PjC+vsBo-^6L$AU7aD81JRocj&ahgB?D=`y-{W* z6BihX7I&@6HdW}Pc?=nV)`N8f>A!Xnj5Xr9&al=s-n!*=GQ{#%S>w9Lkj`p|wW*4&%ik9$jVyN>*v0Ai#zfc&t$`n*NF=`c6JIm}Y}{!SSY|)HA)XMAfHAk+hvH4q z=Zyxe%E!Q-xK_bu$XK*y_wXqNmwi!u3FuVl+A+hZmy2EPafGm}c|cFjXlVs|N66je zH1cKG@G5+USZd#Bh_6Ol9tZ7W5(6EEmUc{oe{b_LB<`xefuff1$)S)?XTm1x-A>(4 zz-P$IXg+A~U&U9?NHFbQBB0WsIPi$!K3i9vR0@~eaz&oLFb4di71(wxx z8j70ATUMHt#39-uDAy&PcKi(`5F}`?Z^{$l$bUOyX-zM16k=B!7tJ&Lgu?I~r))5gdj! z0i^I~cPj~;FReE!S(mlXQUGaSy@Es^RIg5d8#Ym|g-+BRk!HveG!J4^oD8`bwc4>g zbb?w}(^%$wr29D-9T=WT^s`XZk{eNd!`CzCyend59>w2$k-mXo%^&egqz|BI(iQH{@`q{9b-u@kQqAo+4A@LiH^@7kz7~&mTJsg%}W{A%z6fhy~ zGNeC)aVF}x=p_6eIL|H zxBUGy(%({rV#dC`0gY5Fl9r(N2PuBjK4GwkAVLfhgiz*Lz*EhD-7RKrbzod zgBD7)TtuCUT4pfMl&I~tAg{*^sfXtX8tyD5QoW;wi9A=I9E&X?4@0#>WuGz^rgZ4h zVFO5*38=NKr;;a@2C$*1?CiC=0Xa(yENoKVvFPOa>c^@`F` zOpzVMvY(<8ZRA%*L(7Tt$T6s82Gi#R^$R1fLz)?qCR*GQW(WvzxIN3xdXt@`@sx!i zJ~>e)?zlWqoSG~}STujzV*x(xc@Q5RwO$@n5c4=Sfph5X3McqCgLIxBUo5NIJQWz) z{$;d8>Za>Pt`^7Em%IT+;_&v9WnME*wC3u(tO(c7O7ue*`AAf@%RoHdmbF9(d}On% zmV|ZlBbV`mXgoiAMF(*BP*C<%RJX4;lxY9_yj;_)sm?&?5Avi;;^iP7r9R~k(xfz; z7uE#-dmj{#OPO*fh4b1nq=#J53U0uOH@ZAHeQ%+aWJQtPP1aXYjhI>wC71Y!6^ zIewR*!pJg=kn}G_ecY-*v_(#Kcspqf^Uzp^+=Yr)S9S_hj0kb&>3o^+w+gMWAe>~m z2O*s-JBimT@TsXqCEE`fMe9*+S9KXHsvaPnp?SzfSy>5fW(XN|KdMh)Y}Fc(0^f-tAqS`)WR_s-?0KD)k6Ga`nt!(3DF;r`TN?0PeTzjV1%V}~nvy<;If>A`gb zcM2bbe-EXzfvak6v~dlwbI^X5fG&eb0O6iDftC%uvRrWTU>i?;P?~u`hKB|?yof|o y>Uki8rYzka^H6c4)2!wv#OI>AgBQ2cCE$PSTQqZO$oh}~0000^QOQGa00$T{LmFU!Aq_L#|9t^(~bJ>IWyh;SH8Dytoo~b;_?I^ z{v7q=w%cw?Y1*`DRmX7}-FM%8O;4XbeI1_F!)FaVtK|3l%i+@pxPa`ZQ&LinXJ%$* z=jG+?*t&J=mfYOj9cRy;&Hmtn4+?Dc{}ZCYr~Qcm0|r$2=9_PxICA93_XPz7xsC-w zp^)<{;7m?V&YF4i=JoH_uU}(J`+s-{BVx>$F)jA*-~VM{VPW1SAUr0>dR65i5dEjG zzyA78qfdV*paJ{o%lPr*t7m6te*(muCqx;@N(cmlLHCaU+_UWc_up?J`{@_IbK=n8 z0`%qc&p*HQ!i5WaBoRW1CIScGPT3vj#Ol?npO*dh%fH_ZgbA^6YJhrIQV5bj)3?ba7+sQn>A}z8;ma(q9GJi^1&M!PJVv=(cQau55lAUPU%8W zwQEaL{e`vmH#w%?B)7PF;-!~fs&M}N`3xEp23+Wc6^IL$5HL@-jT<+v{)P=3I^uCb zta2p*d-v|`e)Q04ypF0-^ZX57xQbCk~UVTQN+{tn9;K48DSTAd$xVqxG z=bkGAg}pl#LNVwEmRKm=d4?|uE)TE18V`9 zSN84Ow-$VtN07cvT22K{oPbGDhzXHJeOR$#MQbmjsJ>INk3asnp14evzUxmw&bE^? z$qN}F5>NO%xn#+bW~u`C1HED)0)%qYGBPqA)Y^^c1ASS#bZProby<+?OrJizEs6&g zEn0L<97J)`Fri+M?Wcl*3CJbCg}X+5!nP=q>k=+H1(wwx?GLCR>wQS_Owmn|p~ z@WD6Uc%y=dZSlCG1a%&gsnCU9$QiFhv^7NqJCqDj+nkxXzqaN@*?G<3{M zM1@E!;PW)tyoM-Z@v>AFGi({@5l5P~gh1$!pTG@EG-O?%&sSb~#V_I?w3`oQ564Rs zniNpltwj+*1KYQ6|BL1;u|Q)(*~5Cf$$&DVXuw;Qva?b`%6t&*dr{nBx6?>oCkf?f z#K=Stu%*I*GDjy&m{3bxyb@LI6vQdMf|%`_wemjnd$^o1PAsC}qY#rOJibJNBE|u) z*{6^I5!FMM3y5MQtoDkKsZL0JhMEU$JZFq z6+Cd@z*|_GS*gY?i4xRtRvO?U6wcFJFUv!93J4~))e-53HPzK(O*!x%T=RF=u3hiw z+<}2E6eS0NDO09&icOF3M5rfC&YXte2Ejqo#DriA0Omp zuqbx5$k4!tAAZ)tBVr$hRL*zBc2w5PxYpV6qEj+fByM# z5@}|Bnml9-Jo3mRW!(Wtt|w89nA|#5UX&mN=FFMX&P13FsuO+s^rP@^-h2wS*tVG|L_8Ez+F%5KoZAu7LCziXO0#gSJ>E)g6!l>V;UyUsiU68ShYuhAfii&rV>AQ8%?m=^C`v$dxd_E<&h)UwNeNPv zmckjPcnO?3b?RAD%u#+Q$-8BK-`u%#b=gi|@tagkP~v&yU}mmAIn3 zAl3*`dI38>A3Po;Bgz6y0 z&>=NYt*nx_JCH{&>vm34`hI+`Y}WhalTX$XrMx-^9kn8sqX->$&{EV7bH2S}Ct?WW zRUs4~2ryBXEHvjJx;lto%O#izYdBG*h(s(bqGe+M^7OA3Q5A&pChJ8J&!3f*^_mkY z&yR`H2SVzd@i^`>eij8zkN2l zi{6kOc69Sn5zy=m9_2d4%ACC9_eANhUHdSR0!|z-a^%R`whAc1%ZV+lsr-k`=7(=J zK-X6!;_>XFM6wb$M>z*ja?MsfzYgGzNhW8J{SIZ?T@(}0H(GmS2{ctYI*(O z`k^|PkmP`AJW<3u@lfUjx4D2SgY_|vs|>8X)Z`AD9jjKYdR7=S0!c@8f?&pO7YPK4 z>Utp6B?#?oWdtqc(oD~>(%ybxn)x=tmI*|B5C zSP_d$P+1li>!IiH5qb&6Y|F{XS!yX8+_`fng|j2fVwis~=viqHSWUcHBuIZK;E<{z zE}tt4!>11?^03-CL(A$PJ$iHtlWtCNMA$lJKNL+}kG|Pvdbq5nJEDNL*a(B8K)j8n zmGZWp$3=pm?e3)#rfKwC)({*bI?LR8GNw{y{z6qQYNY7zC zy(k!&hq4n*?vP8jt8Mu$OmS)f7$&=d&W4q4Kp+lRPb;EN`s6tl|%O9aJAPmx&EAJFJ zh*qFA5-qC_0n@fNj-vNrgv60Tu5aA9ajgWu1wRDpPEy~EG#?xxG9QIm*5~tux_0eK zVe|-d_U^my{;OkHxZtGqA_?%-S6`8jaFk2O7zEsaYdoqr#U-?E-MXsw(@9H9TTk`r zKm>W{p@({_--;m4{rBHLSR&O1et4|^E$red)V_WD5q`fvWHy9>Otj>88a8bBHzP0U zlpjTs0Fz;(i!ZP`!Hf=K4&b@5sAOBnjZSu~Aqe z)WZ)yT!Tx$;)*M>Ibwp4X4r-F$P7I{;i*iSGBwoNi;FCH-4tCVC&jAmH*MPVx?q&~ z^Ups&KtvOYpb%DDrkN&}Wo-*Jdqk1|CirqwlqjI*@T#kFW#Kzz+sYEsp`W5e-OQOY zr(499XwjlYXKKzx@?Ce`buUL{qTu-P<7r*MmVw02)50Jmi9Yx%b8OY!aKjBPEYE3{ zvU0q%AvZTS(<>dm$W>e#q{AWGb3*#DW5-PEj9#1DV9VdJV@HZ)6yX?hFpb;VPmvN~ z2jAeqgB#ea3o>`f;};f=!xHGQ~NX?oxTiV!;o`EH4HPFh>k3Rb7b2iSxtPyaURtO7M)vjIp4)Yo6I46<_ zP`1>5A(swhmEqFR_p@9YCZ4Sv;Uvm33j_j{$pIw~I{*Z7GSW9^&YTHcUz#;*)=gND zo}Zt;fAQkQ)&WAHH#CWl->m7FKsduDoH@e=05dW&f?OJz{kG?jNVb(oQKwFw+WxBn z!UF~l97yst3hA^iosyDL$7f)d^as3loBnW}FnnBT>0r_c0>z!nrB$w6ndP2(_3E*) zH%xRZ=U1puVV_O1Tz~!bfA%A>zF+_m%AHJ-57tr#Tct!FEc9E&T=VA5YuPG;*>^%D z8KC#r5QY;S1_?ur8Z}Zq%AK@5U%!6+0h@}bRH;%sKeT&uhq16=DJ>ODTkYDl>tRcv zh88VaM3zKUr5JQ$ri``Yi3G^-LmtVjID@ZIiA%#oJ7Rkd$&zC}BN}w-)Tx;tCf{|O z4UUDV*@98%m>IV-&w^IXeRr z1rQfUJd)X+Fm2nmwQhnSoHl6Cz$ymImoINM1Ym(y^CSFQo#PP9bZdoh^Xm%+5Takn zrNMq^VtbD8LsCNTI=~n?&ZQx&lf$LSuu&8cPQf8uxfG@k75(&&DHKXiPq*6Gx88bd zd!~%Y%F0^IrC}CYqo(LQYsDlAFj-D~=~m){AW46n7|txqrC2++n&q@J5pabq4Kb^I zf`L}8TGipWkwlhNej_l(atK(s9+y_NYE^6JX`irQBF1pM%8MieW;*L}_w3ozIyraj z*uk!ooIQIsol84)>Qr5Z8w-iT*2Z8fds#kJY!B(s0FF%&ez49Y0z`}}h$I8#I=6@S z0%CU7F*Rw@gk9Q&^Y=ZM25ahSrmcKG%vY;?1fi^)BX?i~m@=%GLwKF#0#3;#5+K)Y zJ4;B+vLO{LTinKp9;~sj8|(x#q0v^Jf6JCF*|tP3pawsKMIM$ySuuyjDwadQS(QbS z0bEethTNk^kFvS|#?3uk+M92_xy$yvOP4OJn9Iw{JK=}dm}Tn-oENJC#@ahe5Eue1 zL5>_b!V&~+RS`)B_)isWavjR~XRZwNto{iq z^IAP)2_lM?h^>GC=j9->tR^|V*kgMxjTGBNa$pcm7k2P<=+L1HlSUF|E5)*`p$<^r ziX?dUR|+1^a|vKac991nE9q7i5Fe#zX(l7AV|yFecgroe+{9GnLxkf~VUGdQlDCy0 z)Yks}`_uhcV6nRQKoV#9loec0d_LB6gjvb@JajU(Tq{W$*kV#7ITkEfVATaT-+c2E zoah-eXb_1xYa*mUGtL2ByLN55A1i`%E>)DMAva#KWdy|Vs@XJ{(ZUNLcav)RLS)(2 zYJ$LzEpqMDty}jY+om^29B#v(4~}G0!6l+Dn993d1VVOY2n$6Kovmys$a=Kks&ZT! zL>60)W*V#<&y~y4227Zm<}*wZ>jn?vX1ELW&F2`0Z{50eBL%Z$@zhgKJ?ror^77}+ zn^$DoZrHHlE2_-Q(Wp_QW+HlfD61y`>?}v{j$L)5*?}2)ok$`eS`eYs32|78UT(=s z@3wU0Hg_N<_KmOuaaA{r)w&rG=JiIm4r(ARE$wUD=B@Va+fxuRq5?#0+4XO9h+REH zfR5c+LTzDfnvEbQ2%?M_%yvSqIZ+*~>Yravlx?8ty6}U3 ztWc_J`}XZ$M|Db_I^Y;@_|Ut6mP*(cB|Ty_ekTU>>(`In)Lfdda^=c*4B-%h#Y2bi zo96-xuJ13u{PMMeG5cY~rh6UZF?#gq>#b#B=FK-=OAA3!Z87s9G_cK9F||2j#E5?p z4BI08oZ|uTaPQ&DmL|Z$S!d!4!O%uVU;K*~FMiO>C-KF-{S|oTnP+Nv44KOEM$))( z<8HLQfGdw23U?0iuPF{gkQFOd{3q7{y<*JujLIVN{DDXUL7Zrgw?-VGBX>K79*`$u z|Epm7jey~N?yY8d^3neYuXojv?}Xkcu^QO-F}*>Ev1d3AJb3WnY_4vkr8^#gWz>Bl z!C=1BPvbgt=#Uvu^h2aKH2W(N%~_U#JMX-+k`n{GR#M%D>1G-ITtWG!XvCVy(4j*c za1GEQ`CR1)4NQ#HLAsGmBn~JL*v3FI9c}xNFlIc&^b+&XGdg~Rjv)~YLb2qqggcs+ zxC6n&n1_5S#Z{;)A^JLI%ouefj+U`38xqAMdHL39oaX^?2~-2UiE>_ zZ;A3#vHXSz+>ajdlM2 z@ZrNLm>TzzgCDH?l&(vt2M8^HE<5Xc@4fdLa*aXfuzJMtc+p;gJdv%`4>9}bqmSB} zZMSLDro7%ZSxGiMK%pQJjnEY3&vBL&osFK>i0>l=S>STpCY8PTe_qhTj<1PIw1VX1;s&o4b4$ts7 z!Bsym=Aswm0<&h#YN!)~WFlm)dcofZGjZ@#yofxE(_iAnYDo_gkygCe6im-5?(9sUDG>GER)vCfkYWMEluX17l=`JYO zcSR|REi68LtDP1~a$V`i@FAZ1sd|J^aKJ2CT<=#ImEYs!Zx=sO++VVvMHiy{NvA$_ z4y-qY5|=Go_8cR&f^dwc2n$jnl1|2;3Du`xwOx{6J-vuxPC^m`k={kR$to{%QT?%< zYkwF~2Cp0G=(MB9MG0Ok;KvMl(eOOj~FGvvZOY<1|p>64c*Sl)TvT zuU)(Laiz?OJG7ICQZUYYjg#)&9~Lv!B?czvs#tfaz&c$d%aqvUa*;v`U7b{|W)Liq ztFZhy%667IJEa2-_nf zk{F7UW_#UFB~SiVo7PJ_B;i$AVqM0k%o9mH4@+|?Qp6<>i<09K zKfLSPb?(5&4y{kQ`YjHGY#?17oDg;v=^tkOH~LLMCWMM_mMhgu!ju9l(1}9fhuO1d zcd{Xv1|{`P#4M+^mrx8gkpxz|@Gq4piaYX@Z$3aU zAkth(j1V)C+;T6{w#WThK#TQ?+bK(Gb zAqsm=@I^oXep4?cTB%;TqK^_#|LeNQm0S|OWK#dcI5&EUDMgGQe)yqSbG0JfBr30bd#q}3|$GkfmCvYWqN@}A6=b+ye&i$h#|C2i&!&9^_gTgf^~Pv z4(k`I#3TyUPe%>PcFV-sKbIRJl4fasg?ob!dE7cNWdDz8<8ld!NW~j&p*Sv0PD@Lx zBZ{vHOd7bz!FX20xDPixc*KnA3)yixls*{GEnUS#*(TeKr7Ydaf`H)!(;7k@OSJw_ zn1{GrZTq3!{dB)b6{O|JSXR)hPxv@3_Z?g|_p7^u$1>-uv1XG>!%{IFB2b3zJCw+Y z>snNafOB-tylmUFzT6u{X;l-E*Pr!^kIk-xitp)Fr>DA`#rN*rOSd+7UPG?RB6_`C z$HCv-n@D9 zaEZ{uSaksT;UjAiiSB|&`EhWCTNZjqorrYVaE1-s&VgIpa~wHtQP0WBq5SxI-V1?D z9pOt-@I8#@8vuCzZtIJAUpsp{=56K zj2Sa%T_?O&@_PZ-St5@YO8h+SuhB}X#E?^(AU*Vu{C;ZFrcECX8#e4uX21T(5Z>wWzyl9lMSE-EW6X7Au<}v~ zBnhCtpFwnB87!=4wf9ky8tTyDvgwEh)Ti)UNNw7*DRajicU;@5RjZa&s#Iyyq)C(J zsi~8Zy;hfpkjaU|hY#=CzI}Tpf{5ws*RS7zmCfBVXU;sU hwIc-cf`78${{d`xd+PkWUKs!Y002ovPDHLkV1oNgklp|Q literal 0 HcmV?d00001 diff --git a/clients/apps/app/targets/widget/Assets.xcassets/accent.colorset/Contents.json b/clients/apps/app/targets/widget/Assets.xcassets/accent.colorset/Contents.json new file mode 100644 index 0000000000..b74a1ad372 --- /dev/null +++ b/clients/apps/app/targets/widget/Assets.xcassets/accent.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "display-p3", + "components": { + "red": 1, + "green": 0.4823529411764706, + "blue": 0.32941176470588235, + "alpha": 1 + } + }, + "idiom": "universal" + } + ], + "info": { + "version": 1, + "author": "expo" + } +} diff --git a/clients/apps/app/targets/widget/Info.plist b/clients/apps/app/targets/widget/Info.plist new file mode 100644 index 0000000000..5510804d0e --- /dev/null +++ b/clients/apps/app/targets/widget/Info.plist @@ -0,0 +1,11 @@ + + + + + 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: {}