From 71a942945f9c94b7c0064622ab9ae952a6e21de2 Mon Sep 17 00:00:00 2001
From: NeOMakinG <14963751+NeOMakinG@users.noreply.github.com>
Date: Fri, 21 Nov 2025 13:56:26 +0100
Subject: [PATCH 1/4] fix: basic market cap version of widget
---
WIDGET_README.md | 330 ++++++++++++++++++
app.json | 14 +-
.../expo-module.config.json | 6 +
modules/expo-widget-bridge/package.json | 5 +
modules/expo-widget-bridge/src/index.ts | 34 ++
package.json | 1 +
plugins/withAttributionToken.js | 2 +-
src/services/coinGeckoService.ts | 140 ++++++++
.../background.colorset/Contents.json | 20 ++
.../label.colorset/Contents.json | 20 ++
targets/widget/AsyncImageView.swift | 80 +++++
targets/widget/CoinGeckoService.swift | 171 +++++++++
targets/widget/Info.plist | 11 +
targets/widget/SparklineView.swift | 68 ++++
targets/widget/TokenEntry.swift | 9 +
targets/widget/TokenModel.swift | 157 +++++++++
targets/widget/TokenWidgetView.swift | 282 +++++++++++++++
targets/widget/Widget.swift | 192 ++++++++++
targets/widget/expo-target.config.json | 9 +
targets/widget/pods.rb | 2 +
targets/widget/widget.entitlements | 10 +
yarn.lock | 56 +++
22 files changed, 1616 insertions(+), 3 deletions(-)
create mode 100644 WIDGET_README.md
create mode 100644 modules/expo-widget-bridge/expo-module.config.json
create mode 100644 modules/expo-widget-bridge/package.json
create mode 100644 modules/expo-widget-bridge/src/index.ts
create mode 100644 src/services/coinGeckoService.ts
create mode 100644 targets/widget/Assets.xcassets/background.colorset/Contents.json
create mode 100644 targets/widget/Assets.xcassets/label.colorset/Contents.json
create mode 100644 targets/widget/AsyncImageView.swift
create mode 100644 targets/widget/CoinGeckoService.swift
create mode 100644 targets/widget/Info.plist
create mode 100644 targets/widget/SparklineView.swift
create mode 100644 targets/widget/TokenEntry.swift
create mode 100644 targets/widget/TokenModel.swift
create mode 100644 targets/widget/TokenWidgetView.swift
create mode 100644 targets/widget/Widget.swift
create mode 100644 targets/widget/expo-target.config.json
create mode 100644 targets/widget/pods.rb
create mode 100644 targets/widget/widget.entitlements
diff --git a/WIDGET_README.md b/WIDGET_README.md
new file mode 100644
index 0000000..eae1dd2
--- /dev/null
+++ b/WIDGET_README.md
@@ -0,0 +1,330 @@
+# ShapeShift iOS Widget - POC
+
+A proof-of-concept iOS widget implementation for the ShapeShift mobile app that displays cryptocurrency prices with configurable data sources.
+
+## Features
+
+- **Two Widget Sizes:**
+ - Small (systemSmall): Shows 1 token
+ - Medium (systemMedium): Shows 3 tokens
+
+- **Three Data Sources:**
+ - Market Cap: Top tokens by market capitalization
+ - Trading Volume: Top tokens by 24h trading volume
+ - Watchlist: User's favorite/watchlisted tokens
+
+- **Real-time Updates:**
+ - Data synced from web app via MessageManager
+ - Can fetch directly from CoinGecko API
+ - Widget refreshes every 15 minutes
+
+- **Deep Linking:**
+ - Tap any token to open the app to that token's detail page
+ - Uses `shapeshift://token/{tokenId}` URI scheme
+
+## Project Structure
+
+```
+targets/widget/
+├── expo-target.config.json # Widget target configuration
+├── Widget.swift # Main widget entry point
+├── TokenProvider.swift # Timeline provider
+├── TokenModel.swift # Data models and UserDefaults manager
+└── TokenWidgetView.swift # SwiftUI views for widget UI
+
+ios/ShapeShift/
+├── WidgetDataModule.swift # Native module for RN bridge
+└── WidgetDataModule.m # Objective-C bridge
+
+src/lib/
+├── widgetData.ts # TypeScript API for widget updates
+└── widgetIntegration.example.ts # Example integration code
+```
+
+## Setup Instructions
+
+### 1. Rebuild iOS App
+
+After installing the package and creating the widget files, you need to rebuild:
+
+```bash
+# Clean and rebuild
+yarn prebuild --clean
+cd ios && pod install && cd ..
+
+# Or if using EAS
+eas build --platform ios --profile development
+```
+
+### 2. Widget Configuration in Xcode
+
+The widget target should be automatically created by `@bacons/apple-targets`. Verify in Xcode:
+
+1. Open `ios/ShapeShift.xcworkspace`
+2. Check that you have a "widget" target in the target list
+3. Verify "ShapeShift.entitlements" includes App Groups:
+ ```xml
+ com.apple.security.application-groups
+
+ group.com.shapeShift.shapeShift
+
+ ```
+
+### 3. Testing the Widget
+
+#### Option A: Update from Web App
+
+From your web application, send a postMessage:
+
+```javascript
+// Update widget with market cap tokens
+window.ReactNativeWebView.postMessage(JSON.stringify({
+ cmd: 'updateWidgetMarketCap',
+ tokens: [
+ {
+ id: 'bitcoin',
+ symbol: 'BTC',
+ name: 'Bitcoin',
+ price: 43250.50,
+ priceChange24h: 2.45
+ },
+ {
+ id: 'ethereum',
+ symbol: 'ETH',
+ name: 'Ethereum',
+ price: 2280.75,
+ priceChange24h: -1.23
+ }
+ ]
+}))
+```
+
+#### Option B: Update Programmatically from React Native
+
+```typescript
+import { updateWidgetMarketCap, TokenDataSource } from './src/lib/widgetData'
+
+// Update with sample data
+const sampleTokens = [
+ {
+ id: 'bitcoin',
+ symbol: 'BTC',
+ name: 'Bitcoin',
+ price: 43250.50,
+ priceChange24h: 2.45
+ },
+ {
+ id: 'ethereum',
+ symbol: 'ETH',
+ name: 'Ethereum',
+ price: 2280.75,
+ priceChange24h: -1.23
+ },
+ {
+ id: 'cardano',
+ symbol: 'ADA',
+ name: 'Cardano',
+ price: 0.52,
+ priceChange24h: 4.56
+ }
+]
+
+await updateWidgetMarketCap(sampleTokens)
+```
+
+#### Option C: Fetch from CoinGecko API
+
+```typescript
+import { fetchAndUpdateMarketCapWidget } from './src/lib/widgetIntegration.example'
+
+// This will fetch top 10 tokens by market cap and update widget
+await fetchAndUpdateMarketCapWidget()
+```
+
+### 4. Adding Widget to Home Screen
+
+1. Run the app on a physical device or simulator
+2. Long-press on the home screen
+3. Tap the "+" button in the top-left
+4. Search for "ShapeShift"
+5. Select the widget size (Small or Medium)
+6. Add to home screen
+
+## Usage Examples
+
+### Register Widget Handlers (in App.tsx or Root.tsx)
+
+```typescript
+import { registerWidgetHandlers } from './src/lib/widgetIntegration.example'
+
+useEffect(() => {
+ registerWidgetHandlers()
+}, [])
+```
+
+### Update Widget with Different Data Sources
+
+```typescript
+import {
+ updateWidgetMarketCap,
+ updateWidgetTradingVolume,
+ updateWidgetWatchlist
+} from './src/lib/widgetData'
+
+// Market Cap
+await updateWidgetMarketCap(topMarketCapTokens)
+
+// Trading Volume
+await updateWidgetTradingVolume(topVolumeTokens)
+
+// Watchlist
+await updateWidgetWatchlist(userWatchlistTokens)
+```
+
+### Get Current Widget Data
+
+```typescript
+import { getWidgetData } from './src/lib/widgetData'
+
+const currentData = await getWidgetData()
+console.log('Current widget tokens:', currentData?.tokens)
+console.log('Current data source:', currentData?.dataSource)
+```
+
+## Widget Customization
+
+### Change Colors
+
+Edit `targets/widget/TokenWidgetView.swift`:
+
+```swift
+// Background color
+Color(hex: "#181C27") // Change to your brand color
+
+// Positive price change color
+Color(hex: "#00D68F") // Green
+
+// Negative price change color
+Color(hex: "#F04747") // Red
+```
+
+### Change Update Frequency
+
+Edit `targets/widget/TokenProvider.swift`:
+
+```swift
+// Change from 15 minutes to your desired interval
+let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
+```
+
+### Add More Widget Sizes
+
+Edit `targets/widget/Widget.swift`:
+
+```swift
+.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
+```
+
+Then implement `LargeWidgetView` in `TokenWidgetView.swift`.
+
+## Data Flow
+
+```
+Web App (CoinGecko/API)
+ ↓
+postMessage to React Native
+ ↓
+MessageManager Handler
+ ↓
+WidgetDataModule (Native Swift)
+ ↓
+UserDefaults (App Group Shared Container)
+ ↓
+Widget Extension reads data
+ ↓
+Widget UI Update
+```
+
+## Troubleshooting
+
+### Widget Not Updating
+
+1. Check that App Groups are properly configured in both targets
+2. Verify the suite name matches: `group.com.shapeShift.shapeShift`
+3. Call `WidgetCenter.shared.reloadAllTimelines()` after updating data
+4. Check Xcode console for widget logs
+
+### Widget Shows "No tokens available"
+
+1. Ensure you've called `updateWidgetData()` at least once
+2. Check that token data is valid (has id, symbol, price)
+3. Verify UserDefaults data:
+ ```swift
+ let defaults = UserDefaults(suiteName: "group.com.shapeShift.shapeShift")
+ print(defaults?.string(forKey: "widgetData"))
+ ```
+
+### Native Module Not Found
+
+1. Ensure you've run `pod install` after adding the Swift files
+2. Check that `WidgetDataModule.m` and `WidgetDataModule.swift` are in the Xcode project
+3. Verify the bridging header is configured correctly
+4. Rebuild the app completely
+
+## Next Steps
+
+1. **Integrate with your existing data fetching:**
+ - Connect to your existing CoinGecko API calls
+ - Hook into your watchlist state management
+ - Add periodic background fetching
+
+2. **Add widget configuration:**
+ - Let users choose which data source to show
+ - Allow customization of which tokens appear
+ - Add preferences UI in the app
+
+3. **Enhance UI:**
+ - Add token icons/logos
+ - Implement loading states
+ - Add error states with retry
+
+4. **Advanced Features:**
+ - Live Activities for real-time price tracking
+ - Interactive widgets (iOS 17+)
+ - Lock screen widgets
+ - Widget suggestions based on user behavior
+
+## API Reference
+
+See `src/lib/widgetData.ts` for the complete TypeScript API.
+
+### Main Functions
+
+- `updateWidgetData(tokens, dataSource)` - Update widget with new data
+- `getWidgetData()` - Get current widget data
+- `updateWidgetMarketCap(tokens)` - Helper for market cap data
+- `updateWidgetTradingVolume(tokens)` - Helper for trading volume data
+- `updateWidgetWatchlist(tokens)` - Helper for watchlist data
+
+### Types
+
+```typescript
+interface Token {
+ id: string
+ symbol: string
+ name: string
+ price: number
+ priceChange24h: number
+ iconUrl?: string
+}
+
+enum TokenDataSource {
+ MarketCap = 'market_cap',
+ TradingVolume = 'trading_volume',
+ Watchlist = 'watchlist'
+}
+```
+
+## License
+
+Same as parent project.
diff --git a/app.json b/app.json
index 4bb7c67..a11d07c 100644
--- a/app.json
+++ b/app.json
@@ -36,7 +36,13 @@
],
["./plugins/withCustomGradleProperties"],
["./plugins/withWalletConnectScheme"],
- ["./plugins/withAttributionToken"]
+ ["./plugins/withAttributionToken"],
+ [
+ "@bacons/apple-targets",
+ {
+ "appleTeamId": "7882V35EPB"
+ }
+ ]
],
"android": {
"versionCode": 329,
@@ -56,7 +62,11 @@
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSCameraUsageDescription": "This app uses the camera to scan QR codes for addresses."
- }
+ },
+ "entitlements": {
+ "com.apple.security.application-groups": ["group.com.shapeShift.shapeShift"]
+ },
+ "appleTeamId": "7882V35EPB"
},
"extra": {
"eas": {
diff --git a/modules/expo-widget-bridge/expo-module.config.json b/modules/expo-widget-bridge/expo-module.config.json
new file mode 100644
index 0000000..29ef7d4
--- /dev/null
+++ b/modules/expo-widget-bridge/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["ios"],
+ "ios": {
+ "modules": ["ExpoWidgetBridgeModule"]
+ }
+}
diff --git a/modules/expo-widget-bridge/package.json b/modules/expo-widget-bridge/package.json
new file mode 100644
index 0000000..93a1627
--- /dev/null
+++ b/modules/expo-widget-bridge/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "expo-widget-bridge",
+ "version": "1.0.0",
+ "main": "src/index.ts"
+}
diff --git a/modules/expo-widget-bridge/src/index.ts b/modules/expo-widget-bridge/src/index.ts
new file mode 100644
index 0000000..af9d56d
--- /dev/null
+++ b/modules/expo-widget-bridge/src/index.ts
@@ -0,0 +1,34 @@
+import { NativeModulesProxy } from 'expo-modules-core'
+
+const ExpoWidgetBridge = NativeModulesProxy.ExpoWidgetBridgeModule
+
+export enum TokenDataSource {
+ MarketCap = 'market_cap',
+ TradingVolume = 'trading_volume',
+ Watchlist = 'watchlist',
+}
+
+export interface Token {
+ id: string
+ symbol: string
+ name: string
+ price: number
+ priceChange24h: number
+ iconUrl?: string
+}
+
+export async function updateWidgetData(
+ tokens: Token[],
+ dataSource: TokenDataSource = TokenDataSource.MarketCap,
+): Promise<{ success: boolean; message: string }> {
+ const tokensJSON = JSON.stringify(tokens)
+ return await ExpoWidgetBridge.updateWidgetData(tokensJSON, dataSource)
+}
+
+export async function getWidgetData(): Promise<{
+ tokens: string
+ dataSource: string
+ lastUpdated: string
+}> {
+ return await ExpoWidgetBridge.getWidgetData()
+}
diff --git a/package.json b/package.json
index f0b67a3..80c00f1 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"type-check": "tsc --project ./tsconfig.json --noEmit"
},
"dependencies": {
+ "@bacons/apple-targets": "^3.0.5",
"@react-hook/async": "^3.1.1",
"@react-native-async-storage/async-storage": "2.2.0",
"@solana-mobile/dapp-store-cli": "^0.11.0",
diff --git a/plugins/withAttributionToken.js b/plugins/withAttributionToken.js
index f982cfc..e96d334 100644
--- a/plugins/withAttributionToken.js
+++ b/plugins/withAttributionToken.js
@@ -1,4 +1,4 @@
-const { withXcodeProject, withPodfile } = require('expo/config-plugins');
+const { withXcodeProject } = require('expo/config-plugins');
function withAttributionToken(config) {
config = withXcodeProject(config, async (config) => {
diff --git a/src/services/coinGeckoService.ts b/src/services/coinGeckoService.ts
new file mode 100644
index 0000000..50fa823
--- /dev/null
+++ b/src/services/coinGeckoService.ts
@@ -0,0 +1,140 @@
+import { Token } from '../../modules/expo-widget-bridge/src'
+
+const COINGECKO_API_BASE = 'https://api.coingecko.com/api/v3'
+
+// Helper to handle rate limiting - fail fast on 429
+async function fetchWithRetry(url: string, maxRetries = 2): Promise {
+ let lastError: Error | null = null
+
+ for (let i = 0; i < maxRetries; i++) {
+ try {
+ const response = await fetch(url)
+
+ // If rate limited, fail immediately - don't spam the API
+ if (response.status === 429) {
+ console.log(`[CoinGecko] Rate limited (429), aborting to avoid further rate limit hits`)
+ throw new Error('CoinGecko API error: 429')
+ }
+
+ return response
+ } catch (error) {
+ lastError = error as Error
+ // Only retry on network errors, not rate limits
+ if (i < maxRetries - 1 && !(error as Error).message.includes('429')) {
+ const waitTime = 1000 // Just 1 second between retries
+ console.log(`[CoinGecko] Network error, waiting ${waitTime}ms before retry ${i + 1}/${maxRetries}`)
+ await new Promise(resolve => setTimeout(resolve, waitTime))
+ } else {
+ throw error
+ }
+ }
+ }
+
+ throw lastError || new Error('Max retries exceeded')
+}
+
+export interface CoinGeckoToken {
+ id: string
+ symbol: string
+ name: string
+ current_price: number
+ price_change_percentage_24h: number
+ image: string
+ market_cap: number
+ total_volume: number
+}
+
+/**
+ * Fetches top tokens by market cap from CoinGecko
+ * @param limit Number of tokens to fetch (default: 10)
+ * @returns Array of tokens
+ */
+export async function fetchTopTokensByMarketCap(limit: number = 10): Promise {
+ try {
+ const url = `${COINGECKO_API_BASE}/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=${limit}&page=1&sparkline=false&price_change_percentage=24h`
+ const response = await fetchWithRetry(url)
+
+ if (!response.ok) {
+ throw new Error(`CoinGecko API error: ${response.status}`)
+ }
+
+ const data: CoinGeckoToken[] = await response.json()
+
+ return data.map(coin => ({
+ id: coin.id,
+ symbol: coin.symbol.toUpperCase(),
+ name: coin.name,
+ price: coin.current_price,
+ priceChange24h: coin.price_change_percentage_24h || 0,
+ iconUrl: coin.image,
+ }))
+ } catch (error) {
+ console.error('[CoinGecko] Error fetching market cap data:', error)
+ throw error
+ }
+}
+
+/**
+ * Fetches top tokens by trading volume from CoinGecko
+ * @param limit Number of tokens to fetch (default: 10)
+ * @returns Array of tokens
+ */
+export async function fetchTopTokensByVolume(limit: number = 10): Promise {
+ try {
+ const url = `${COINGECKO_API_BASE}/coins/markets?vs_currency=usd&order=volume_desc&per_page=${limit}&page=1&sparkline=false&price_change_percentage=24h`
+ const response = await fetchWithRetry(url)
+
+ if (!response.ok) {
+ throw new Error(`CoinGecko API error: ${response.status}`)
+ }
+
+ const data: CoinGeckoToken[] = await response.json()
+
+ return data.map(coin => ({
+ id: coin.id,
+ symbol: coin.symbol.toUpperCase(),
+ name: coin.name,
+ price: coin.current_price,
+ priceChange24h: coin.price_change_percentage_24h || 0,
+ iconUrl: coin.image,
+ }))
+ } catch (error) {
+ console.error('[CoinGecko] Error fetching volume data:', error)
+ throw error
+ }
+}
+
+/**
+ * Fetches specific tokens by their CoinGecko IDs (for watchlist)
+ * @param tokenIds Array of CoinGecko token IDs (e.g., ['bitcoin', 'ethereum'])
+ * @returns Array of tokens
+ */
+export async function fetchTokensByIds(tokenIds: string[]): Promise {
+ if (tokenIds.length === 0) {
+ return []
+ }
+
+ try {
+ const ids = tokenIds.join(',')
+ const url = `${COINGECKO_API_BASE}/coins/markets?vs_currency=usd&ids=${ids}&order=market_cap_desc&sparkline=false&price_change_percentage=24h`
+ const response = await fetchWithRetry(url)
+
+ if (!response.ok) {
+ throw new Error(`CoinGecko API error: ${response.status}`)
+ }
+
+ const data: CoinGeckoToken[] = await response.json()
+
+ return data.map(coin => ({
+ id: coin.id,
+ symbol: coin.symbol.toUpperCase(),
+ name: coin.name,
+ price: coin.current_price,
+ priceChange24h: coin.price_change_percentage_24h || 0,
+ iconUrl: coin.image,
+ }))
+ } catch (error) {
+ console.error('[CoinGecko] Error fetching tokens by IDs:', error)
+ throw error
+ }
+}
diff --git a/targets/widget/Assets.xcassets/background.colorset/Contents.json b/targets/widget/Assets.xcassets/background.colorset/Contents.json
new file mode 100644
index 0000000..ce262c1
--- /dev/null
+++ b/targets/widget/Assets.xcassets/background.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors": [
+ {
+ "color": {
+ "color-space": "display-p3",
+ "components": {
+ "red": 0.09411764705882353,
+ "green": 0.10980392156862745,
+ "blue": 0.15294117647058825,
+ "alpha": 1
+ }
+ },
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "version": 1,
+ "author": "expo"
+ }
+}
\ No newline at end of file
diff --git a/targets/widget/Assets.xcassets/label.colorset/Contents.json b/targets/widget/Assets.xcassets/label.colorset/Contents.json
new file mode 100644
index 0000000..9f08638
--- /dev/null
+++ b/targets/widget/Assets.xcassets/label.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors": [
+ {
+ "color": {
+ "color-space": "display-p3",
+ "components": {
+ "red": 1,
+ "green": 1,
+ "blue": 1,
+ "alpha": 1
+ }
+ },
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "version": 1,
+ "author": "expo"
+ }
+}
\ No newline at end of file
diff --git a/targets/widget/AsyncImageView.swift b/targets/widget/AsyncImageView.swift
new file mode 100644
index 0000000..6f1b45b
--- /dev/null
+++ b/targets/widget/AsyncImageView.swift
@@ -0,0 +1,80 @@
+import SwiftUI
+
+/// Custom async image view optimized for widgets with caching
+struct CachedAsyncImage: View {
+ let url: URL?
+ let fallback: AnyView
+
+ @State private var image: UIImage?
+ @State private var isLoading = false
+
+ var body: some View {
+ Group {
+ if let image = image {
+ Image(uiImage: image)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ } else {
+ fallback
+ }
+ }
+ .onAppear {
+ loadImage()
+ }
+ }
+
+ private func loadImage() {
+ guard let url = url, !isLoading else { return }
+
+ isLoading = true
+
+ // Check cache first
+ if let cachedImage = ImageCache.shared.get(forKey: url.absoluteString) {
+ self.image = cachedImage
+ isLoading = false
+ return
+ }
+
+ // Load from network
+ let task = URLSession.shared.dataTask(with: url) { data, response, error in
+ defer { isLoading = false }
+
+ guard let data = data,
+ let loadedImage = UIImage(data: data) else {
+ print("[Widget] Failed to load image from \(url.absoluteString): \(error?.localizedDescription ?? "unknown error")")
+ return
+ }
+
+ // Cache the image
+ ImageCache.shared.set(loadedImage, forKey: url.absoluteString)
+
+ DispatchQueue.main.async {
+ self.image = loadedImage
+ }
+ }
+ task.resume()
+ }
+}
+
+/// Simple in-memory image cache
+class ImageCache {
+ static let shared = ImageCache()
+
+ private var cache: [String: UIImage] = [:]
+ private let maxCacheSize = 50
+
+ private init() {}
+
+ func get(forKey key: String) -> UIImage? {
+ return cache[key]
+ }
+
+ func set(_ image: UIImage, forKey key: String) {
+ // Simple cache size management
+ if cache.count >= maxCacheSize {
+ // Remove oldest entry (simplified approach)
+ cache.removeValue(forKey: cache.keys.first ?? "")
+ }
+ cache[key] = image
+ }
+}
diff --git a/targets/widget/CoinGeckoService.swift b/targets/widget/CoinGeckoService.swift
new file mode 100644
index 0000000..96e83c3
--- /dev/null
+++ b/targets/widget/CoinGeckoService.swift
@@ -0,0 +1,171 @@
+import Foundation
+
+class CoinGeckoService {
+ static let shared = CoinGeckoService()
+
+ private let baseURL = "https://api.coingecko.com/api/v3"
+
+ private init() {}
+
+ enum FetchError: Error {
+ case invalidURL
+ case networkError(Error)
+ case invalidResponse
+ case decodingError(Error)
+ case rateLimited
+ }
+
+ /// Helper to fetch with retry logic for rate limiting
+ private func fetchWithRetry(url: URL, maxRetries: Int = 3) async throws -> Data {
+ var lastError: Error?
+
+ for attempt in 0.. [Token] {
+ let urlString = "\(baseURL)/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=\(limit)&page=1&sparkline=true&price_change_percentage=24h"
+
+ guard let url = URL(string: urlString) else {
+ throw FetchError.invalidURL
+ }
+
+ do {
+ let data = try await fetchWithRetry(url: url)
+
+ let decoder = JSONDecoder()
+ let coins = try decoder.decode([CoinGeckoToken].self, from: data)
+
+ return coins.map { coin in
+ Token(
+ id: coin.id,
+ symbol: coin.symbol.uppercased(),
+ name: coin.name,
+ price: coin.current_price,
+ priceChange24h: coin.price_change_percentage_24h ?? 0,
+ iconUrl: coin.image,
+ sparkline: coin.sparkline_in_7d?.price
+ )
+ }
+ } catch let error as DecodingError {
+ throw FetchError.decodingError(error)
+ } catch {
+ throw FetchError.networkError(error)
+ }
+ }
+
+ /// Fetches top tokens by trading volume from CoinGecko
+ func fetchTopTokensByVolume(limit: Int = 10) async throws -> [Token] {
+ let urlString = "\(baseURL)/coins/markets?vs_currency=usd&order=volume_desc&per_page=\(limit)&page=1&sparkline=true&price_change_percentage=24h"
+
+ guard let url = URL(string: urlString) else {
+ throw FetchError.invalidURL
+ }
+
+ do {
+ let data = try await fetchWithRetry(url: url)
+
+ let decoder = JSONDecoder()
+ let coins = try decoder.decode([CoinGeckoToken].self, from: data)
+
+ return coins.map { coin in
+ Token(
+ id: coin.id,
+ symbol: coin.symbol.uppercased(),
+ name: coin.name,
+ price: coin.current_price,
+ priceChange24h: coin.price_change_percentage_24h ?? 0,
+ iconUrl: coin.image,
+ sparkline: coin.sparkline_in_7d?.price
+ )
+ }
+ } catch let error as DecodingError {
+ throw FetchError.decodingError(error)
+ } catch {
+ throw FetchError.networkError(error)
+ }
+ }
+
+ /// Fetches specific tokens by their IDs (for watchlist)
+ func fetchTokensByIds(_ tokenIds: [String]) async throws -> [Token] {
+ if tokenIds.isEmpty {
+ return []
+ }
+
+ let ids = tokenIds.joined(separator: ",")
+ let urlString = "\(baseURL)/coins/markets?vs_currency=usd&ids=\(ids)&order=market_cap_desc&sparkline=true&price_change_percentage=24h"
+
+ guard let url = URL(string: urlString) else {
+ throw FetchError.invalidURL
+ }
+
+ do {
+ let data = try await fetchWithRetry(url: url)
+
+ let decoder = JSONDecoder()
+ let coins = try decoder.decode([CoinGeckoToken].self, from: data)
+
+ return coins.map { coin in
+ Token(
+ id: coin.id,
+ symbol: coin.symbol.uppercased(),
+ name: coin.name,
+ price: coin.current_price,
+ priceChange24h: coin.price_change_percentage_24h ?? 0,
+ iconUrl: coin.image,
+ sparkline: coin.sparkline_in_7d?.price
+ )
+ }
+ } catch let error as DecodingError {
+ throw FetchError.decodingError(error)
+ } catch {
+ throw FetchError.networkError(error)
+ }
+ }
+}
diff --git a/targets/widget/Info.plist b/targets/widget/Info.plist
new file mode 100644
index 0000000..5510804
--- /dev/null
+++ b/targets/widget/Info.plist
@@ -0,0 +1,11 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
\ No newline at end of file
diff --git a/targets/widget/SparklineView.swift b/targets/widget/SparklineView.swift
new file mode 100644
index 0000000..c344f0e
--- /dev/null
+++ b/targets/widget/SparklineView.swift
@@ -0,0 +1,68 @@
+import SwiftUI
+
+struct SparklineView: View {
+ let data: [Double]
+ let isPositive: Bool
+ let lineWidth: CGFloat
+
+ init(data: [Double], isPositive: Bool, lineWidth: CGFloat = 1.5) {
+ self.data = data
+ self.isPositive = isPositive
+ self.lineWidth = lineWidth
+ }
+
+ var body: some View {
+ GeometryReader { geometry in
+ Path { path in
+ guard !data.isEmpty else { return }
+
+ let minValue = data.min() ?? 0
+ let maxValue = data.max() ?? 1
+ let range = maxValue - minValue
+
+ // Avoid division by zero
+ guard range > 0 else { return }
+
+ let width = geometry.size.width
+ let height = geometry.size.height
+ let stepX = width / CGFloat(data.count - 1)
+
+ // Start path
+ let firstY = height - (CGFloat((data[0] - minValue) / range) * height)
+ path.move(to: CGPoint(x: 0, y: firstY))
+
+ // Draw line through all points
+ for (index, value) in data.enumerated() {
+ let x = CGFloat(index) * stepX
+ let y = height - (CGFloat((value - minValue) / range) * height)
+ path.addLine(to: CGPoint(x: x, y: y))
+ }
+ }
+ .stroke(isPositive ? Color.green : Color.red, lineWidth: lineWidth)
+ }
+ }
+}
+
+// Preview
+struct SparklineView_Previews: PreviewProvider {
+ static var previews: some View {
+ VStack(spacing: 20) {
+ // Positive trend
+ SparklineView(
+ data: [100, 105, 103, 108, 112, 110, 115],
+ isPositive: true
+ )
+ .frame(height: 30)
+ .padding()
+
+ // Negative trend
+ SparklineView(
+ data: [100, 95, 97, 92, 88, 90, 85],
+ isPositive: false
+ )
+ .frame(height: 30)
+ .padding()
+ }
+ .background(Color(hex: "#181C27"))
+ }
+}
diff --git a/targets/widget/TokenEntry.swift b/targets/widget/TokenEntry.swift
new file mode 100644
index 0000000..0b15335
--- /dev/null
+++ b/targets/widget/TokenEntry.swift
@@ -0,0 +1,9 @@
+import WidgetKit
+import SwiftUI
+
+// Timeline Entry
+struct TokenEntry: TimelineEntry {
+ let date: Date
+ let tokens: [Token]
+ let dataSource: TokenDataSource
+}
diff --git a/targets/widget/TokenModel.swift b/targets/widget/TokenModel.swift
new file mode 100644
index 0000000..4e564cb
--- /dev/null
+++ b/targets/widget/TokenModel.swift
@@ -0,0 +1,157 @@
+import Foundation
+
+// Token data model matching what we'll receive from the app
+struct Token: Codable, Identifiable {
+ let id: String
+ let symbol: String
+ let name: String
+ let price: Double
+ let priceChange24h: Double
+ let iconUrl: String?
+ let sparkline: [Double]?
+
+ var formattedPrice: String {
+ if price >= 1 {
+ return String(format: "$%.2f", price)
+ } else if price >= 0.01 {
+ return String(format: "$%.4f", price)
+ } else {
+ return String(format: "$%.6f", price)
+ }
+ }
+
+ var formattedPriceChange: String {
+ let sign = priceChange24h >= 0 ? "+" : ""
+ return String(format: "%@%.2f%%", sign, priceChange24h)
+ }
+
+ var isPriceUp: Bool {
+ return priceChange24h >= 0
+ }
+}
+
+// Widget configuration for data source selection
+enum TokenDataSource: String, Codable {
+ case marketCap = "market_cap"
+ case tradingVolume = "trading_volume"
+ case watchlist = "watchlist"
+
+ var displayName: String {
+ switch self {
+ case .marketCap: return "Market Cap"
+ case .tradingVolume: return "Trading Volume"
+ case .watchlist: return "Watchlist"
+ }
+ }
+}
+
+// Widget data structure stored in UserDefaults
+struct WidgetData: Codable {
+ let tokens: [Token]
+ let dataSource: TokenDataSource
+ let lastUpdated: Date
+
+ init(tokens: [Token] = [], dataSource: TokenDataSource = .marketCap, lastUpdated: Date = Date()) {
+ self.tokens = tokens
+ self.dataSource = dataSource
+ self.lastUpdated = lastUpdated
+ }
+}
+
+// Shared defaults accessor
+class WidgetDataManager {
+ static let shared = WidgetDataManager()
+ private let appGroupIdentifier = "group.com.shapeShift.shapeShift"
+ private let dataKey = "widgetData"
+
+ private var sharedDefaults: UserDefaults? {
+ return UserDefaults(suiteName: appGroupIdentifier)
+ }
+
+ func saveWidgetData(_ data: WidgetData) {
+ guard let defaults = sharedDefaults else {
+ print("[Widget] Failed to access shared UserDefaults")
+ return
+ }
+
+ do {
+ // Encode tokens array to JSON string (matching native module format)
+ let tokensEncoder = JSONEncoder()
+ let tokensData = try tokensEncoder.encode(data.tokens)
+ guard let tokensJSON = String(data: tokensData, encoding: .utf8) else {
+ print("[Widget] Failed to convert tokens to JSON string")
+ return
+ }
+
+ // Create widget data structure matching native module format
+ let widgetData: [String: Any] = [
+ "tokens": tokensJSON,
+ "dataSource": data.dataSource.rawValue,
+ "lastUpdated": ISO8601DateFormatter().string(from: data.lastUpdated)
+ ]
+
+ // Convert to JSON string
+ let jsonData = try JSONSerialization.data(withJSONObject: widgetData)
+ guard let jsonString = String(data: jsonData, encoding: .utf8) else {
+ print("[Widget] Failed to convert widget data to JSON string")
+ return
+ }
+
+ // Save JSON string to UserDefaults (matching native module format)
+ defaults.set(jsonString, forKey: dataKey)
+ defaults.synchronize()
+ print("[Widget] Successfully saved widget data: \(data.tokens.count) tokens")
+ } catch {
+ print("[Widget] Failed to encode widget data: \(error)")
+ }
+ }
+
+ func loadWidgetData() -> WidgetData {
+ guard let defaults = sharedDefaults else {
+ print("[Widget] Failed to access shared UserDefaults")
+ return WidgetData()
+ }
+
+ // Try to get the JSON string saved by the native module
+ guard let jsonString = defaults.string(forKey: dataKey),
+ let jsonData = jsonString.data(using: .utf8) else {
+ print("[Widget] No widget data found in UserDefaults")
+ return WidgetData()
+ }
+
+ do {
+ // Parse the outer JSON structure
+ guard let dict = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else {
+ print("[Widget] Failed to parse widget data as dictionary")
+ return WidgetData()
+ }
+
+ // Extract tokens JSON string
+ guard let tokensJSON = dict["tokens"] as? String,
+ let tokensData = tokensJSON.data(using: .utf8) else {
+ print("[Widget] Failed to extract tokens JSON")
+ return WidgetData()
+ }
+
+ // Decode tokens array
+ let decoder = JSONDecoder()
+ let tokens = try decoder.decode([Token].self, from: tokensData)
+
+ // Extract data source
+ let dataSourceString = dict["dataSource"] as? String ?? "market_cap"
+ let dataSource = TokenDataSource(rawValue: dataSourceString) ?? .marketCap
+
+ // Extract last updated
+ let lastUpdatedString = dict["lastUpdated"] as? String ?? ""
+ let dateFormatter = ISO8601DateFormatter()
+ let lastUpdated = dateFormatter.date(from: lastUpdatedString) ?? Date()
+
+ print("[Widget] Successfully loaded \(tokens.count) tokens, source: \(dataSource.rawValue)")
+
+ return WidgetData(tokens: tokens, dataSource: dataSource, lastUpdated: lastUpdated)
+ } catch {
+ print("[Widget] Failed to decode widget data: \(error)")
+ return WidgetData()
+ }
+ }
+}
diff --git a/targets/widget/TokenWidgetView.swift b/targets/widget/TokenWidgetView.swift
new file mode 100644
index 0000000..7fdbc42
--- /dev/null
+++ b/targets/widget/TokenWidgetView.swift
@@ -0,0 +1,282 @@
+import SwiftUI
+import WidgetKit
+
+struct TokenWidgetView: View {
+ let entry: TokenEntry
+ @Environment(\.widgetFamily) var widgetFamily
+
+ var body: some View {
+ switch widgetFamily {
+ case .systemSmall:
+ SmallWidgetView(entry: entry)
+ case .systemMedium:
+ MediumWidgetView(entry: entry)
+ default:
+ SmallWidgetView(entry: entry)
+ }
+ }
+}
+
+// MARK: - Small Widget (Single Token)
+struct SmallWidgetView: View {
+ let entry: TokenEntry
+
+ var body: some View {
+ if let token = entry.tokens.first {
+ ZStack {
+ Color(hex: "#0F1419")
+
+ VStack(alignment: .leading, spacing: 0) {
+ // Token Icon + Symbol
+ HStack(spacing: 10) {
+ // Token Icon
+ CachedAsyncImage(
+ url: token.iconUrl.flatMap { URL(string: $0) },
+ fallback: AnyView(
+ TokenIconFallback(symbol: token.symbol)
+ )
+ )
+ .frame(width: 32, height: 32)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(token.name)
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(.white)
+ .lineLimit(1)
+ Text(token.symbol.uppercased())
+ .font(.system(size: 12, weight: .regular))
+ .foregroundColor(Color.white.opacity(0.6))
+ }
+ Spacer()
+ }
+ .padding(.bottom, 8)
+
+ Spacer()
+
+ // Sparkline
+ if let sparkline = token.sparkline, !sparkline.isEmpty {
+ SparklineView(
+ data: sparkline,
+ isPositive: token.isPriceUp,
+ lineWidth: 2
+ )
+ .frame(height: 45)
+ .padding(.bottom, 8)
+ }
+
+ // Price
+ Text(token.formattedPrice)
+ .font(.system(size: 22, weight: .bold))
+ .foregroundColor(.white)
+ .padding(.bottom, 2)
+
+ // Price Change
+ HStack(spacing: 3) {
+ Image(systemName: token.isPriceUp ? "arrow.up" : "arrow.down")
+ .font(.system(size: 10, weight: .bold))
+ Text(token.formattedPriceChange)
+ .font(.system(size: 13, weight: .medium))
+ }
+ .foregroundColor(token.isPriceUp ? Color(hex: "#16C784") : Color(hex: "#EA3943"))
+ }
+ .padding(16)
+ }
+ .widgetURL(URL(string: "shapeshift://token/\(token.id)"))
+ } else {
+ PlaceholderView(message: "No tokens available")
+ }
+ }
+}
+
+// MARK: - Medium Widget (3 Tokens)
+struct MediumWidgetView: View {
+ let entry: TokenEntry
+
+ var body: some View {
+ if entry.tokens.isEmpty {
+ PlaceholderView(message: "No tokens available")
+ } else {
+ ZStack {
+ Color(hex: "#0F1419")
+
+ VStack(alignment: .leading, spacing: 0) {
+ // Header
+ HStack {
+ Text(entry.dataSource.displayName)
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundColor(.white)
+ Spacer()
+ Image(systemName: "chart.line.uptrend.xyaxis")
+ .foregroundColor(Color(hex: "#5B8DEE"))
+ .font(.system(size: 10))
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 12)
+ .padding(.bottom, 8)
+
+ // Token List
+ VStack(spacing: 0) {
+ ForEach(Array(entry.tokens.prefix(3).enumerated()), id: \.element.id) { index, token in
+ Link(destination: URL(string: "shapeshift://token/\(token.id)")!) {
+ TokenRowView(token: token, isLast: index == min(2, entry.tokens.count - 1))
+ }
+ }
+ }
+
+ Spacer(minLength: 0)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Token Row Component
+struct TokenRowView: View {
+ let token: Token
+ let isLast: Bool
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: 8) {
+ // Token Icon + Info
+ HStack(spacing: 8) {
+ // Token Icon
+ CachedAsyncImage(
+ url: token.iconUrl.flatMap { URL(string: $0) },
+ fallback: AnyView(
+ TokenIconFallback(symbol: token.symbol)
+ )
+ )
+ .frame(width: 28, height: 28)
+
+ // Token Name and Symbol
+ VStack(alignment: .leading, spacing: 1) {
+ Text(token.name)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundColor(.white)
+ .lineLimit(1)
+ Text(token.symbol.uppercased())
+ .font(.system(size: 11, weight: .regular))
+ .foregroundColor(Color.white.opacity(0.6))
+ }
+ }
+ .frame(width: 110, alignment: .leading)
+
+ // Sparkline
+ if let sparkline = token.sparkline, !sparkline.isEmpty {
+ SparklineView(
+ data: sparkline,
+ isPositive: token.isPriceUp,
+ lineWidth: 1.5
+ )
+ .frame(width: 80, height: 24)
+ }
+
+ Spacer(minLength: 4)
+
+ // Price + Change
+ VStack(alignment: .trailing, spacing: 1) {
+ Text(token.formattedPrice)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundColor(.white)
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+
+ HStack(spacing: 2) {
+ Image(systemName: token.isPriceUp ? "arrow.up" : "arrow.down")
+ .font(.system(size: 8, weight: .bold))
+ Text(token.formattedPriceChange)
+ .font(.system(size: 11, weight: .medium))
+ }
+ .foregroundColor(token.isPriceUp ? Color(hex: "#16C784") : Color(hex: "#EA3943"))
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+
+ if !isLast {
+ Divider()
+ .background(Color.white.opacity(0.08))
+ .padding(.horizontal, 16)
+ }
+ }
+ }
+}
+
+// MARK: - Token Icon Fallback
+struct TokenIconFallback: View {
+ let symbol: String
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .fill(
+ LinearGradient(
+ colors: [Color(hex: "#3861FB"), Color(hex: "#5B8DEE")],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
+
+ Text(symbol.prefix(2).uppercased())
+ .font(.system(size: 11, weight: .bold))
+ .foregroundColor(.white)
+ }
+ }
+}
+
+// MARK: - Placeholder View
+struct PlaceholderView: View {
+ let message: String
+
+ var body: some View {
+ ZStack {
+ Color(hex: "#181C27")
+
+ VStack(spacing: 12) {
+ Image(systemName: "chart.bar.xaxis")
+ .font(.system(size: 32))
+ .foregroundColor(Color.white.opacity(0.4))
+
+ Text(message)
+ .font(.system(size: 14, weight: .medium))
+ .foregroundColor(Color.white.opacity(0.6))
+ .multilineTextAlignment(.center)
+
+ Text("Open ShapeShift to configure")
+ .font(.system(size: 11))
+ .foregroundColor(Color.white.opacity(0.4))
+ }
+ .padding()
+ }
+ .widgetURL(URL(string: "shapeshift://"))
+ }
+}
+
+// MARK: - Color Extension
+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/targets/widget/Widget.swift b/targets/widget/Widget.swift
new file mode 100644
index 0000000..037de45
--- /dev/null
+++ b/targets/widget/Widget.swift
@@ -0,0 +1,192 @@
+import WidgetKit
+import SwiftUI
+
+@main
+struct ShapeShiftWidget: Widget {
+ let kind: String = "ShapeShiftWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: TokenProvider()) { entry in
+ TokenWidgetView(entry: entry)
+ .containerBackground(for: .widget) {
+ Color(hex: "#181C27")
+ }
+ }
+ .configurationDisplayName("ShapeShift Tokens")
+ .description("Track crypto prices by market cap or trading volume")
+ .supportedFamilies([.systemSmall, .systemMedium])
+ }
+}
+
+// Timeline Provider
+struct TokenProvider: TimelineProvider {
+ func placeholder(in context: Context) -> TokenEntry {
+ TokenEntry(
+ date: Date(),
+ tokens: [
+ Token(
+ id: "bitcoin",
+ symbol: "BTC",
+ name: "Bitcoin",
+ price: 43250.50,
+ priceChange24h: 2.45,
+ iconUrl: nil,
+ sparkline: Array(repeating: 0.0, count: 168).enumerated().map { index, _ in
+ 40000 + Double(index) * 20 + Double.random(in: -500...500)
+ }
+ )
+ ],
+ dataSource: .marketCap
+ )
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (TokenEntry) -> Void) {
+ let data = WidgetDataManager.shared.loadWidgetData()
+ let entry = TokenEntry(
+ date: Date(),
+ tokens: data.tokens.isEmpty ? [placeholder(in: context).tokens[0]] : data.tokens,
+ dataSource: data.dataSource
+ )
+ completion(entry)
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ fetchAndCreateTimeline(dataSource: .marketCap, completion: completion)
+ }
+
+ private func fetchAndCreateTimeline(dataSource: TokenDataSource, completion: @escaping (Timeline) -> Void) {
+ print("[Widget Timeline] Loading widget data for source: \(dataSource.rawValue)")
+
+ // Load saved data from UserDefaults
+ let savedData = WidgetDataManager.shared.loadWidgetData()
+ print("[Widget Timeline] Loaded \(savedData.tokens.count) saved tokens, last updated: \(savedData.lastUpdated)")
+
+ let currentDate = Date()
+ let dataAge = currentDate.timeIntervalSince(savedData.lastUpdated)
+ let isStale = dataAge > 600 // Consider stale if older than 10 minutes
+
+ // Only fetch from CoinGecko if data is stale or missing
+ if !savedData.tokens.isEmpty && !isStale {
+ print("[Widget Timeline] Using cached data (age: \(Int(dataAge))s)")
+ let entry = TokenEntry(
+ date: currentDate,
+ tokens: savedData.tokens,
+ dataSource: savedData.dataSource
+ )
+
+ // Schedule next update in 15 minutes
+ let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
+ let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
+ completion(timeline)
+ return
+ }
+
+ // Data is stale or missing - fetch from CoinGecko
+ print("[Widget Timeline] Data is \(savedData.tokens.isEmpty ? "missing" : "stale (\(Int(dataAge))s old)"), fetching from CoinGecko...")
+
+ Task {
+ var tokens = savedData.tokens
+ let targetDataSource = savedData.dataSource == .watchlist ? savedData.dataSource : dataSource
+
+ do {
+ let freshTokens: [Token]
+ switch targetDataSource {
+ case .marketCap:
+ freshTokens = try await CoinGeckoService.shared.fetchTopTokensByMarketCap(limit: 10)
+ case .tradingVolume:
+ freshTokens = try await CoinGeckoService.shared.fetchTopTokensByVolume(limit: 10)
+ case .watchlist:
+ let tokenIds = savedData.tokens.map { $0.id }
+ freshTokens = tokenIds.isEmpty ? [] : try await CoinGeckoService.shared.fetchTokensByIds(tokenIds)
+ }
+
+ print("[Widget Timeline] Fetched \(freshTokens.count) fresh tokens")
+ if !freshTokens.isEmpty {
+ tokens = freshTokens
+ WidgetDataManager.shared.saveWidgetData(WidgetData(tokens: freshTokens, dataSource: targetDataSource))
+ print("[Widget Timeline] Saved fresh data to UserDefaults")
+ }
+ } catch {
+ print("[Widget Timeline] Error fetching: \(error), using cached data")
+ }
+
+ let entry = TokenEntry(
+ date: currentDate,
+ tokens: tokens,
+ dataSource: targetDataSource
+ )
+
+ if tokens.isEmpty {
+ print("[Widget Timeline] WARNING: No tokens available")
+ } else {
+ print("[Widget Timeline] Using \(tokens.count) tokens, first: \(tokens[0].symbol) - $\(tokens[0].price)")
+ }
+
+ // Schedule next update in 15 minutes
+ let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
+ let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
+ completion(timeline)
+ }
+ }
+}
+
+// Preview Provider
+struct Widget_Previews: PreviewProvider {
+ static var previews: some View {
+ Group {
+ // Small widget preview
+ TokenWidgetView(entry: TokenEntry(
+ date: Date(),
+ tokens: [
+ Token(
+ id: "bitcoin",
+ symbol: "BTC",
+ name: "Bitcoin",
+ price: 43250.50,
+ priceChange24h: 2.45,
+ iconUrl: nil,
+ sparkline: (0..<168).map { _ in Double.random(in: 40000...45000) }
+ )
+ ],
+ dataSource: .marketCap
+ ))
+ .previewContext(WidgetPreviewContext(family: .systemSmall))
+
+ // Medium widget preview
+ TokenWidgetView(entry: TokenEntry(
+ date: Date(),
+ tokens: [
+ Token(
+ id: "bitcoin",
+ symbol: "BTC",
+ name: "Bitcoin",
+ price: 43250.50,
+ priceChange24h: 2.45,
+ iconUrl: nil,
+ sparkline: (0..<168).map { _ in Double.random(in: 40000...45000) }
+ ),
+ Token(
+ id: "ethereum",
+ symbol: "ETH",
+ name: "Ethereum",
+ price: 2280.75,
+ priceChange24h: -1.23,
+ iconUrl: nil,
+ sparkline: (0..<168).map { _ in Double.random(in: 2000...2500) }
+ ),
+ Token(
+ id: "cardano",
+ symbol: "ADA",
+ name: "Cardano",
+ price: 0.52,
+ priceChange24h: 4.56,
+ iconUrl: nil,
+ sparkline: (0..<168).map { _ in Double.random(in: 0.45...0.60) }
+ )
+ ],
+ dataSource: .tradingVolume
+ ))
+ .previewContext(WidgetPreviewContext(family: .systemMedium))
+ }
+ }
+}
diff --git a/targets/widget/expo-target.config.json b/targets/widget/expo-target.config.json
new file mode 100644
index 0000000..a74f2e3
--- /dev/null
+++ b/targets/widget/expo-target.config.json
@@ -0,0 +1,9 @@
+{
+ "type": "widget",
+ "accentColor": "#3861FB",
+ "colors": {
+ "background": "#181C27",
+ "label": "#FFFFFF"
+ },
+ "frameworks": ["WidgetKit", "SwiftUI"]
+}
diff --git a/targets/widget/pods.rb b/targets/widget/pods.rb
new file mode 100644
index 0000000..7d36af3
--- /dev/null
+++ b/targets/widget/pods.rb
@@ -0,0 +1,2 @@
+# Widget target Podfile configuration
+# Minimal dependencies for widget extension
diff --git a/targets/widget/widget.entitlements b/targets/widget/widget.entitlements
new file mode 100644
index 0000000..202154f
--- /dev/null
+++ b/targets/widget/widget.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.shapeShift.shapeShift
+
+
+
diff --git a/yarn.lock b/yarn.lock
index a348a0a..abe5c4f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3378,6 +3378,29 @@ __metadata:
languageName: node
linkType: hard
+"@bacons/apple-targets@npm:^3.0.5":
+ version: 3.0.5
+ resolution: "@bacons/apple-targets@npm:3.0.5"
+ dependencies:
+ "@bacons/xcode": 1.0.0-alpha.27
+ "@react-native/normalize-colors": ^0.79.2
+ debug: ^4.3.4
+ glob: ^10.4.2
+ checksum: 24617985142c164eb76a8f2c5c86f65f2eef6622393a87a9670d208e400257faf6e1d1d5ac8871f8f6d4b33ea620d085ebbbc7a23a1892210e4e2c94f8f445b3
+ languageName: node
+ linkType: hard
+
+"@bacons/xcode@npm:1.0.0-alpha.27":
+ version: 1.0.0-alpha.27
+ resolution: "@bacons/xcode@npm:1.0.0-alpha.27"
+ dependencies:
+ "@expo/plist": ^0.0.18
+ debug: ^4.3.4
+ uuid: ^8.3.2
+ checksum: 39808b21ff9575baa5fa9dbcdca86d9466daa366e07665f5803406b1af269208ca8329ad1687ed41ab1df747e098dad3b41d99bdb29faa0dd04ba59483e1270c
+ languageName: node
+ linkType: hard
+
"@bcoe/v8-coverage@npm:^0.2.3":
version: 0.2.3
resolution: "@bcoe/v8-coverage@npm:0.2.3"
@@ -4169,6 +4192,17 @@ __metadata:
languageName: node
linkType: hard
+"@expo/plist@npm:^0.0.18":
+ version: 0.0.18
+ resolution: "@expo/plist@npm:0.0.18"
+ dependencies:
+ "@xmldom/xmldom": ~0.7.0
+ base64-js: ^1.2.3
+ xmlbuilder: ^14.0.0
+ checksum: 42f5743fcd2a07b55a9f048d27cf0f273510ab35dde1f7030b22dc8c30ab2cfb65c6e68f8aa58fbcfa00177fdc7c9696d0004083c9a47c36fd4ac7fea27d6ccc
+ languageName: node
+ linkType: hard
+
"@expo/plist@npm:^0.4.7":
version: 0.4.7
resolution: "@expo/plist@npm:0.4.7"
@@ -5698,6 +5732,13 @@ __metadata:
languageName: node
linkType: hard
+"@react-native/normalize-colors@npm:^0.79.2":
+ version: 0.79.7
+ resolution: "@react-native/normalize-colors@npm:0.79.7"
+ checksum: 0c9420bbacb93965c50d872ab65edc17669a51ba7404ae845a6ee51356d0ef5c616c41782bb7b0af7b795f0a63e579ae28145450788fbcf053abf423038c2389
+ languageName: node
+ linkType: hard
+
"@react-native/virtualized-lists@npm:0.81.4":
version: 0.81.4
resolution: "@react-native/virtualized-lists@npm:0.81.4"
@@ -7245,6 +7286,13 @@ __metadata:
languageName: node
linkType: hard
+"@xmldom/xmldom@npm:~0.7.0":
+ version: 0.7.13
+ resolution: "@xmldom/xmldom@npm:0.7.13"
+ checksum: b4054078530e5fa8ede9677425deff0fce6d965f4c477ca73f8490d8a089e60b8498a15560425a1335f5ff99ecb851ed2c734b0a9a879299a5694302f212f37a
+ languageName: node
+ linkType: hard
+
"JSONStream@npm:^1.3.5":
version: 1.3.5
resolution: "JSONStream@npm:1.3.5"
@@ -16425,6 +16473,7 @@ __metadata:
"@babel/core": ^7.26.0
"@babel/preset-env": ^7.19.3
"@babel/runtime": ^7.19.0
+ "@bacons/apple-targets": ^3.0.5
"@react-hook/async": ^3.1.1
"@react-native-async-storage/async-storage": 2.2.0
"@react-native-community/eslint-config": ^3.1.0
@@ -17947,6 +17996,13 @@ __metadata:
languageName: node
linkType: hard
+"xmlbuilder@npm:^14.0.0":
+ version: 14.0.0
+ resolution: "xmlbuilder@npm:14.0.0"
+ checksum: 9e93d3c73957dbb21acde63afa5d241b19057bdbdca9d53534d8351e70f1d5c9db154e3ca19bd3e9ea84c082539ab6e7845591c8778a663e8b5d3470d5427a8b
+ languageName: node
+ linkType: hard
+
"xmlbuilder@npm:^15.1.1":
version: 15.1.1
resolution: "xmlbuilder@npm:15.1.1"
From 03dcadcfdb8204989a7646c30e034ae69d3c0063 Mon Sep 17 00:00:00 2001
From: NeOMakinG <14963751+NeOMakinG@users.noreply.github.com>
Date: Fri, 21 Nov 2025 14:05:35 +0100
Subject: [PATCH 2/4] feat: better ui
---
targets/widget/TokenWidgetView.swift | 23 ++++++++++++-----------
1 file changed, 12 insertions(+), 11 deletions(-)
diff --git a/targets/widget/TokenWidgetView.swift b/targets/widget/TokenWidgetView.swift
index 7fdbc42..6e2da27 100644
--- a/targets/widget/TokenWidgetView.swift
+++ b/targets/widget/TokenWidgetView.swift
@@ -110,9 +110,9 @@ struct MediumWidgetView: View {
.foregroundColor(Color(hex: "#5B8DEE"))
.font(.system(size: 10))
}
- .padding(.horizontal, 16)
- .padding(.top, 12)
- .padding(.bottom, 8)
+ .padding(.horizontal, 12)
+ .padding(.top, 10)
+ .padding(.bottom, 6)
// Token List
VStack(spacing: 0) {
@@ -152,15 +152,15 @@ struct TokenRowView: View {
// Token Name and Symbol
VStack(alignment: .leading, spacing: 1) {
Text(token.name)
- .font(.system(size: 13, weight: .semibold))
+ .font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
Text(token.symbol.uppercased())
- .font(.system(size: 11, weight: .regular))
+ .font(.system(size: 10, weight: .regular))
.foregroundColor(Color.white.opacity(0.6))
}
}
- .frame(width: 110, alignment: .leading)
+ .frame(width: 95, alignment: .leading)
// Sparkline
if let sparkline = token.sparkline, !sparkline.isEmpty {
@@ -169,10 +169,10 @@ struct TokenRowView: View {
isPositive: token.isPriceUp,
lineWidth: 1.5
)
- .frame(width: 80, height: 24)
+ .frame(width: 70, height: 22)
}
- Spacer(minLength: 4)
+ Spacer(minLength: 2)
// Price + Change
VStack(alignment: .trailing, spacing: 1) {
@@ -180,7 +180,8 @@ struct TokenRowView: View {
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
- .minimumScaleFactor(0.8)
+ .minimumScaleFactor(0.7)
+ .fixedSize(horizontal: false, vertical: true)
HStack(spacing: 2) {
Image(systemName: token.isPriceUp ? "arrow.up" : "arrow.down")
@@ -191,13 +192,13 @@ struct TokenRowView: View {
.foregroundColor(token.isPriceUp ? Color(hex: "#16C784") : Color(hex: "#EA3943"))
}
}
- .padding(.horizontal, 16)
+ .padding(.horizontal, 12)
.padding(.vertical, 8)
if !isLast {
Divider()
.background(Color.white.opacity(0.08))
- .padding(.horizontal, 16)
+ .padding(.horizontal, 12)
}
}
}
From f84b965d1d6f5242a11fcef05d715122da051799 Mon Sep 17 00:00:00 2001
From: NeOMakinG <14963751+NeOMakinG@users.noreply.github.com>
Date: Fri, 21 Nov 2025 14:47:11 +0100
Subject: [PATCH 3/4] feat: config poc
---
src/components/WidgetSettings.tsx | 211 ++++++++++++++++++
targets/widget/TokenModel.swift | 7 +
targets/widget/TokenWidgetView.swift | 123 +++++++++-
targets/widget/Widget.swift | 137 +++++++++++-
.../widget/WidgetConfigurationAppIntent.swift | 123 ++++++++++
5 files changed, 588 insertions(+), 13 deletions(-)
create mode 100644 src/components/WidgetSettings.tsx
create mode 100644 targets/widget/WidgetConfigurationAppIntent.swift
diff --git a/src/components/WidgetSettings.tsx b/src/components/WidgetSettings.tsx
new file mode 100644
index 0000000..51c1a0c
--- /dev/null
+++ b/src/components/WidgetSettings.tsx
@@ -0,0 +1,211 @@
+import React, { useState, useEffect } from 'react'
+import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native'
+import { updateWidgetData, TokenDataSource } from '../../modules/expo-widget-bridge/src'
+
+interface WidgetSettingsProps {
+ visible: boolean
+ onClose: () => void
+}
+
+export const WidgetSettings: React.FC = ({ visible, onClose }) => {
+ const [selectedSource, setSelectedSource] = useState(TokenDataSource.MarketCap)
+
+ const handleSelectSource = async (source: TokenDataSource) => {
+ setSelectedSource(source)
+
+ // Update widget configuration
+ // This will trigger the widget to refresh with the new data source
+ console.log('[Widget Settings] Selected:', source)
+
+ // Close the modal after selection
+ setTimeout(onClose, 300)
+ }
+
+ return (
+
+
+
+ {/* Header */}
+
+ Widget Data Source
+
+ ✕
+
+
+
+ {/* Description */}
+
+ Choose which tokens to display in your widget
+
+
+ {/* Options */}
+
+ {/* Market Cap Option */}
+ handleSelectSource(TokenDataSource.MarketCap)}
+ >
+
+
+ 📊
+
+
+ Market Cap
+
+ Top tokens by market capitalization
+
+
+
+ {selectedSource === TokenDataSource.MarketCap && (
+ ✓
+ )}
+
+
+ {/* Trading Volume Option */}
+ handleSelectSource(TokenDataSource.TradingVolume)}
+ >
+
+
+ 📈
+
+
+ Trading Volume
+
+ Top tokens by 24h trading volume
+
+
+
+ {selectedSource === TokenDataSource.TradingVolume && (
+ ✓
+ )}
+
+
+
+ {/* Info */}
+
+ Your widget will update automatically after selecting a data source.
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0, 0, 0, 0.7)',
+ justifyContent: 'flex-end',
+ },
+ container: {
+ backgroundColor: '#181C27',
+ borderTopLeftRadius: 24,
+ borderTopRightRadius: 24,
+ paddingBottom: 40,
+ },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: 24,
+ paddingBottom: 16,
+ },
+ title: {
+ fontSize: 22,
+ fontWeight: '700',
+ color: '#FFFFFF',
+ },
+ closeButton: {
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ closeButtonText: {
+ fontSize: 18,
+ color: '#FFFFFF',
+ fontWeight: '600',
+ },
+ description: {
+ fontSize: 14,
+ color: 'rgba(255, 255, 255, 0.6)',
+ paddingHorizontal: 24,
+ marginBottom: 24,
+ },
+ options: {
+ paddingHorizontal: 24,
+ gap: 12,
+ },
+ option: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ backgroundColor: 'rgba(255, 255, 255, 0.05)',
+ borderRadius: 16,
+ padding: 16,
+ borderWidth: 2,
+ borderColor: 'transparent',
+ },
+ optionSelected: {
+ borderColor: '#3861FB',
+ backgroundColor: 'rgba(56, 97, 251, 0.1)',
+ },
+ optionContent: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flex: 1,
+ },
+ iconContainer: {
+ width: 48,
+ height: 48,
+ borderRadius: 12,
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginRight: 16,
+ },
+ icon: {
+ fontSize: 24,
+ },
+ optionText: {
+ flex: 1,
+ },
+ optionTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#FFFFFF',
+ marginBottom: 4,
+ },
+ optionSubtitle: {
+ fontSize: 13,
+ color: 'rgba(255, 255, 255, 0.6)',
+ },
+ checkmark: {
+ fontSize: 20,
+ color: '#3861FB',
+ fontWeight: '700',
+ marginLeft: 12,
+ },
+ info: {
+ fontSize: 12,
+ color: 'rgba(255, 255, 255, 0.4)',
+ paddingHorizontal: 24,
+ marginTop: 20,
+ textAlign: 'center',
+ },
+})
diff --git a/targets/widget/TokenModel.swift b/targets/widget/TokenModel.swift
index 4e564cb..e908b25 100644
--- a/targets/widget/TokenModel.swift
+++ b/targets/widget/TokenModel.swift
@@ -154,4 +154,11 @@ class WidgetDataManager {
return WidgetData()
}
}
+
+ func isShowingSelection() -> Bool {
+ guard let defaults = sharedDefaults else {
+ return false
+ }
+ return defaults.bool(forKey: "widgetShowingSelection")
+ }
}
diff --git a/targets/widget/TokenWidgetView.swift b/targets/widget/TokenWidgetView.swift
index 6e2da27..d0e7dac 100644
--- a/targets/widget/TokenWidgetView.swift
+++ b/targets/widget/TokenWidgetView.swift
@@ -93,22 +93,38 @@ struct MediumWidgetView: View {
let entry: TokenEntry
var body: some View {
- if entry.tokens.isEmpty {
+ // Check if we should show the selection view
+ let showingSelection = WidgetDataManager.shared.isShowingSelection()
+
+ if showingSelection {
+ DataSourceSelectionView()
+ } else if entry.tokens.isEmpty {
PlaceholderView(message: "No tokens available")
} else {
ZStack {
Color(hex: "#0F1419")
VStack(alignment: .leading, spacing: 0) {
- // Header
+ // Header with settings button
HStack {
Text(entry.dataSource.displayName)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
Spacer()
- Image(systemName: "chart.line.uptrend.xyaxis")
- .foregroundColor(Color(hex: "#5B8DEE"))
- .font(.system(size: 10))
+
+ if #available(iOS 17.0, *) {
+ Button(intent: ShowSelectionIntent()) {
+ ZStack {
+ Circle()
+ .fill(Color(hex: "#5B8DEE").opacity(0.2))
+ .frame(width: 24, height: 24)
+ Image(systemName: "gearshape.fill")
+ .foregroundColor(Color(hex: "#5B8DEE"))
+ .font(.system(size: 11, weight: .semibold))
+ }
+ }
+ .buttonStyle(.plain)
+ }
}
.padding(.horizontal, 12)
.padding(.top, 10)
@@ -254,6 +270,103 @@ struct PlaceholderView: View {
}
}
+// MARK: - Data Source Selection View
+@available(iOS 17.0, *)
+struct DataSourceSelectionView: View {
+ var body: some View {
+ ZStack {
+ Color(hex: "#0F1419")
+
+ VStack(spacing: 0) {
+ // Header
+ HStack {
+ Text("Select Data Source")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(.white)
+ Spacer()
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 16)
+ .padding(.bottom, 12)
+
+ // Options
+ VStack(spacing: 8) {
+ // Market Cap Option
+ Button(intent: SelectDataSourceIntent(dataSource: "market_cap")) {
+ HStack(spacing: 12) {
+ ZStack {
+ Circle()
+ .fill(Color(hex: "#3861FB").opacity(0.2))
+ .frame(width: 40, height: 40)
+ Image(systemName: "chart.bar.fill")
+ .foregroundColor(Color(hex: "#3861FB"))
+ .font(.system(size: 16, weight: .semibold))
+ }
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Market Cap")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundColor(.white)
+ Text("Top tokens by market cap")
+ .font(.system(size: 11, weight: .regular))
+ .foregroundColor(Color.white.opacity(0.6))
+ }
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .foregroundColor(Color.white.opacity(0.4))
+ .font(.system(size: 12, weight: .semibold))
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .background(Color.white.opacity(0.05))
+ .cornerRadius(12)
+ }
+ .buttonStyle(.plain)
+
+ // Trading Volume Option
+ Button(intent: SelectDataSourceIntent(dataSource: "trading_volume")) {
+ HStack(spacing: 12) {
+ ZStack {
+ Circle()
+ .fill(Color(hex: "#16C784").opacity(0.2))
+ .frame(width: 40, height: 40)
+ Image(systemName: "chart.line.uptrend.xyaxis")
+ .foregroundColor(Color(hex: "#16C784"))
+ .font(.system(size: 16, weight: .semibold))
+ }
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Trading Volume")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundColor(.white)
+ Text("Top tokens by 24h volume")
+ .font(.system(size: 11, weight: .regular))
+ .foregroundColor(Color.white.opacity(0.6))
+ }
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .foregroundColor(Color.white.opacity(0.4))
+ .font(.system(size: 12, weight: .semibold))
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .background(Color.white.opacity(0.05))
+ .cornerRadius(12)
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, 16)
+
+ Spacer()
+ }
+ }
+ }
+}
+
// MARK: - Color Extension
extension Color {
init(hex: String) {
diff --git a/targets/widget/Widget.swift b/targets/widget/Widget.swift
index 037de45..ce31431 100644
--- a/targets/widget/Widget.swift
+++ b/targets/widget/Widget.swift
@@ -6,19 +6,140 @@ struct ShapeShiftWidget: Widget {
let kind: String = "ShapeShiftWidget"
var body: some WidgetConfiguration {
- StaticConfiguration(kind: kind, provider: TokenProvider()) { entry in
- TokenWidgetView(entry: entry)
- .containerBackground(for: .widget) {
- Color(hex: "#181C27")
+ if #available(iOS 17.0, *) {
+ return AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: AppIntentTokenProvider()) { entry in
+ TokenWidgetView(entry: entry)
+ .containerBackground(for: .widget) {
+ Color(hex: "#0F1419")
+ }
+ }
+ .configurationDisplayName("ShapeShift Tokens")
+ .description("Track crypto prices with configurable data source")
+ .supportedFamilies([.systemSmall, .systemMedium])
+ } else {
+ return StaticConfiguration(kind: kind, provider: TokenProvider()) { entry in
+ TokenWidgetView(entry: entry)
+ .containerBackground(for: .widget) {
+ Color(hex: "#0F1419")
+ }
+ }
+ .configurationDisplayName("ShapeShift Tokens")
+ .description("Track crypto prices by market cap or trading volume")
+ .supportedFamilies([.systemSmall, .systemMedium])
+ }
+ }
+}
+
+// App Intent Timeline Provider (iOS 17+)
+@available(iOS 17.0, *)
+struct AppIntentTokenProvider: AppIntentTimelineProvider {
+ func placeholder(in context: Context) -> TokenEntry {
+ TokenEntry(
+ date: Date(),
+ tokens: [
+ Token(
+ id: "bitcoin",
+ symbol: "BTC",
+ name: "Bitcoin",
+ price: 43250.50,
+ priceChange24h: 2.45,
+ iconUrl: nil,
+ sparkline: Array(repeating: 0.0, count: 168).enumerated().map { index, _ in
+ 40000 + Double(index) * 20 + Double.random(in: -500...500)
+ }
+ )
+ ],
+ dataSource: .marketCap
+ )
+ }
+
+ func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> TokenEntry {
+ // Check if there's a data source selected via button intents
+ let defaults = UserDefaults(suiteName: "group.com.shapeShift.shapeShift")
+
+ var dataSource: TokenDataSource
+ if let savedDataSourceString = defaults?.string(forKey: "widgetDataSource"),
+ let savedDataSource = TokenDataSource(rawValue: savedDataSourceString) {
+ dataSource = savedDataSource
+ } else {
+ dataSource = mapIntentToDataSource(configuration.dataSource)
+ }
+
+ let data = WidgetDataManager.shared.loadWidgetData()
+ return TokenEntry(
+ date: Date(),
+ tokens: data.tokens.isEmpty ? [placeholder(in: context).tokens[0]] : data.tokens,
+ dataSource: dataSource
+ )
+ }
+
+ func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline {
+ // Check if there's a data source selected via button intents
+ let defaults = UserDefaults(suiteName: "group.com.shapeShift.shapeShift")
+
+ var dataSource: TokenDataSource
+ if let savedDataSourceString = defaults?.string(forKey: "widgetDataSource"),
+ let savedDataSource = TokenDataSource(rawValue: savedDataSourceString) {
+ // Use data source from button selection
+ dataSource = savedDataSource
+ print("[Widget] Using button-selected data source: \(savedDataSource.rawValue)")
+ } else {
+ // Fall back to configuration data source
+ dataSource = mapIntentToDataSource(configuration.dataSource)
+ defaults?.set(dataSource.rawValue, forKey: "widgetDataSource")
+ defaults?.synchronize()
+ print("[Widget] Using config data source: \(dataSource.rawValue)")
+ }
+
+ return await fetchTimelineForDataSource(dataSource: dataSource)
+ }
+
+ private func mapIntentToDataSource(_ intentDataSource: DataSourceAppEnum) -> TokenDataSource {
+ switch intentDataSource {
+ case .marketCap:
+ return .marketCap
+ case .tradingVolume:
+ return .tradingVolume
+ }
+ }
+
+ private func fetchTimelineForDataSource(dataSource: TokenDataSource) async -> Timeline {
+ let savedData = WidgetDataManager.shared.loadWidgetData()
+ let currentDate = Date()
+ let dataAge = currentDate.timeIntervalSince(savedData.lastUpdated)
+ let isStale = dataAge > 600
+
+ var tokens = savedData.tokens
+
+ if savedData.tokens.isEmpty || isStale || savedData.dataSource != dataSource {
+ do {
+ let freshTokens: [Token]
+ switch dataSource {
+ case .marketCap:
+ freshTokens = try await CoinGeckoService.shared.fetchTopTokensByMarketCap(limit: 10)
+ case .tradingVolume:
+ freshTokens = try await CoinGeckoService.shared.fetchTopTokensByVolume(limit: 10)
+ case .watchlist:
+ let tokenIds = savedData.tokens.map { $0.id }
+ freshTokens = tokenIds.isEmpty ? [] : try await CoinGeckoService.shared.fetchTokensByIds(tokenIds)
+ }
+
+ if !freshTokens.isEmpty {
+ tokens = freshTokens
+ WidgetDataManager.shared.saveWidgetData(WidgetData(tokens: freshTokens, dataSource: dataSource))
}
+ } catch {
+ print("[Widget] Error fetching: \(error)")
+ }
}
- .configurationDisplayName("ShapeShift Tokens")
- .description("Track crypto prices by market cap or trading volume")
- .supportedFamilies([.systemSmall, .systemMedium])
+
+ let entry = TokenEntry(date: currentDate, tokens: tokens, dataSource: dataSource)
+ let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
+ return Timeline(entries: [entry], policy: .after(nextUpdate))
}
}
-// Timeline Provider
+// Timeline Provider (iOS 16 and below)
struct TokenProvider: TimelineProvider {
func placeholder(in context: Context) -> TokenEntry {
TokenEntry(
diff --git a/targets/widget/WidgetConfigurationAppIntent.swift b/targets/widget/WidgetConfigurationAppIntent.swift
new file mode 100644
index 0000000..1b30dea
--- /dev/null
+++ b/targets/widget/WidgetConfigurationAppIntent.swift
@@ -0,0 +1,123 @@
+import AppIntents
+import WidgetKit
+
+@available(iOS 17.0, *)
+struct ConfigurationAppIntent: WidgetConfigurationIntent {
+ static var title: LocalizedStringResource = "Configuration"
+ static var description = IntentDescription("Choose your widget data source")
+
+ @Parameter(title: "Data Source", default: .marketCap)
+ var dataSource: DataSourceAppEnum
+}
+
+@available(iOS 17.0, *)
+enum DataSourceAppEnum: String, AppEnum, CaseDisplayRepresentable {
+ case marketCap
+ case tradingVolume
+
+ static var typeDisplayRepresentation: TypeDisplayRepresentation {
+ TypeDisplayRepresentation(name: "Data Source")
+ }
+
+ static var caseDisplayRepresentations: [DataSourceAppEnum: DisplayRepresentation] {
+ [
+ .marketCap: DisplayRepresentation(
+ title: "Market Cap",
+ subtitle: "Top tokens by market capitalization",
+ image: .init(systemName: "chart.bar.fill")
+ ),
+ .tradingVolume: DisplayRepresentation(
+ title: "Trading Volume",
+ subtitle: "Top tokens by 24h trading volume",
+ image: .init(systemName: "chart.line.uptrend.xyaxis")
+ )
+ ]
+ }
+}
+
+// Show selection menu intent
+@available(iOS 17.0, *)
+struct ShowSelectionIntent: AppIntent {
+ static var title: LocalizedStringResource = "Show Selection"
+ static var description = IntentDescription("Show data source selection")
+
+ static var openAppWhenRun: Bool = false
+
+ func perform() async throws -> some IntentResult {
+ print("[Widget Intent] ShowSelectionIntent triggered!")
+
+ let defaults = UserDefaults(suiteName: "group.com.shapeShift.shapeShift")
+ defaults?.set(true, forKey: "widgetShowingSelection")
+ defaults?.synchronize()
+
+ WidgetCenter.shared.reloadAllTimelines()
+
+ return .result()
+ }
+}
+
+// Select data source intent
+@available(iOS 17.0, *)
+struct SelectDataSourceIntent: AppIntent {
+ static var title: LocalizedStringResource = "Select Data Source"
+ static var description = IntentDescription("Select a data source")
+
+ static var openAppWhenRun: Bool = false
+
+ @Parameter(title: "Data Source")
+ var dataSource: String
+
+ init() {
+ self.dataSource = "market_cap"
+ }
+
+ init(dataSource: String) {
+ self.dataSource = dataSource
+ }
+
+ func perform() async throws -> some IntentResult {
+ print("[Widget Intent] SelectDataSourceIntent triggered with: \(dataSource)")
+
+ let defaults = UserDefaults(suiteName: "group.com.shapeShift.shapeShift")
+ defaults?.set(dataSource, forKey: "widgetDataSource")
+ defaults?.set(false, forKey: "widgetShowingSelection")
+ defaults?.synchronize()
+
+ WidgetCenter.shared.reloadAllTimelines()
+
+ return .result()
+ }
+}
+
+// Toggle Intent for in-widget button (keeping for backward compatibility)
+@available(iOS 17.0, *)
+struct ToggleDataSourceIntent: AppIntent {
+ static var title: LocalizedStringResource = "Toggle Data Source"
+ static var description = IntentDescription("Switch between Market Cap and Trading Volume")
+
+ static var openAppWhenRun: Bool = false
+
+ func perform() async throws -> some IntentResult {
+ print("[Widget Intent] ToggleDataSourceIntent triggered!")
+
+ // Get current configuration from UserDefaults
+ let defaults = UserDefaults(suiteName: "group.com.shapeShift.shapeShift")
+ let currentSource = defaults?.string(forKey: "widgetDataSource") ?? "market_cap"
+
+ print("[Widget Intent] Current source: \(currentSource)")
+
+ // Toggle the data source
+ let newSource = currentSource == "market_cap" ? "trading_volume" : "market_cap"
+ defaults?.set(newSource, forKey: "widgetDataSource")
+ defaults?.synchronize()
+
+ print("[Widget Intent] New source: \(newSource)")
+
+ // Reload all widgets
+ WidgetCenter.shared.reloadAllTimelines()
+
+ print("[Widget Intent] Reloaded all timelines")
+
+ return .result()
+ }
+}
From 502a5906cdca17a77fd4379e7a08dd75e7169726 Mon Sep 17 00:00:00 2001
From: NeOMakinG <14963751+NeOMakinG@users.noreply.github.com>
Date: Fri, 21 Nov 2025 14:57:43 +0100
Subject: [PATCH 4/4] feat: click
---
targets/widget/CaipMapping.swift | 62 ++++++++++++++++++++++++++++
targets/widget/TokenWidgetView.swift | 5 ++-
2 files changed, 65 insertions(+), 2 deletions(-)
create mode 100644 targets/widget/CaipMapping.swift
diff --git a/targets/widget/CaipMapping.swift b/targets/widget/CaipMapping.swift
new file mode 100644
index 0000000..0670541
--- /dev/null
+++ b/targets/widget/CaipMapping.swift
@@ -0,0 +1,62 @@
+import Foundation
+
+/// Maps CoinGecko token IDs to CAIP-10 identifiers for deep linking
+class CaipMapping {
+ static let shared = CaipMapping()
+
+ private init() {}
+
+ // Map of CoinGecko ID -> CAIP identifier
+ private let mappings: [String: String] = [
+ // Major chains native tokens
+ "bitcoin": "bip122:000000000019d6689c085ae165831e93/slip44:0",
+ "ethereum": "eip155:1/slip44:60",
+ "solana": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501",
+ "binancecoin": "bip122:000000000019d6689c085ae165831e93/slip44:714",
+ "cardano": "bip122:1a3be38bcbb7911969283716ad7799d33e5e6c3e/slip44:1815",
+ "avalanche-2": "eip155:43114/slip44:9000",
+ "polygon": "eip155:137/slip44:966",
+ "polkadot": "polkadot:91b171bb158e2d3848fa23a9f1c25182/slip44:354",
+ "dogecoin": "bip122:1a91e3dace36e2be3bf030a65679fe82/slip44:3",
+ "litecoin": "bip122:12a765e31ffd4059bada1e25190f6e98/slip44:2",
+ "cosmos": "cosmos:cosmoshub-4/slip44:118",
+
+ // ERC20 tokens on Ethereum mainnet
+ "tether": "eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7",
+ "usd-coin": "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
+ "binance-usd": "eip155:1/erc20:0x4fabb145d64652a948d72533023f6e7a623c7c53",
+ "dai": "eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f",
+ "wrapped-bitcoin": "eip155:1/erc20:0x2260fac5e5542a773aa44fbcfedf7c193bc2c599",
+ "chainlink": "eip155:1/erc20:0x514910771af9ca656af840dff83e8264ecf986ca",
+ "uniswap": "eip155:1/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984",
+ "shiba-inu": "eip155:1/erc20:0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce",
+ "aave": "eip155:1/erc20:0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9",
+ "maker": "eip155:1/erc20:0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2",
+ "compound-governance-token": "eip155:1/erc20:0xc00e94cb662c3520282e6f5717214004a7f26888",
+ "the-graph": "eip155:1/erc20:0xc944e90c64b2c07662a292be6244bdf05cda44a7",
+ "curve-dao-token": "eip155:1/erc20:0xd533a949740bb3306d119cc777fa900ba034cd52",
+ "synthetix-network-token": "eip155:1/erc20:0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f",
+ "yearn-finance": "eip155:1/erc20:0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e",
+ "1inch": "eip155:1/erc20:0x111111111117dc0aa78b770fa6a738034120c302",
+ "matic-network": "eip155:1/erc20:0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0",
+ ]
+
+ /// Get CAIP identifier for a CoinGecko token ID
+ /// Returns nil if no mapping exists
+ func getCaipId(forCoinGeckoId coinGeckoId: String) -> String? {
+ return mappings[coinGeckoId.lowercased()]
+ }
+
+ /// Construct ShapeShift asset URL from CAIP identifier
+ func getShapeShiftUrl(forCoinGeckoId coinGeckoId: String) -> String? {
+ guard let caipId = getCaipId(forCoinGeckoId: coinGeckoId) else {
+ return nil
+ }
+ return "shapeshift://assets/\(caipId)"
+ }
+
+ /// Get a fallback URL that opens the assets page without specific token
+ func getFallbackUrl() -> String {
+ return "shapeshift://assets"
+ }
+}
diff --git a/targets/widget/TokenWidgetView.swift b/targets/widget/TokenWidgetView.swift
index d0e7dac..f8da6cb 100644
--- a/targets/widget/TokenWidgetView.swift
+++ b/targets/widget/TokenWidgetView.swift
@@ -81,7 +81,7 @@ struct SmallWidgetView: View {
}
.padding(16)
}
- .widgetURL(URL(string: "shapeshift://token/\(token.id)"))
+ .widgetURL(URL(string: CaipMapping.shared.getShapeShiftUrl(forCoinGeckoId: token.id) ?? CaipMapping.shared.getFallbackUrl()))
} else {
PlaceholderView(message: "No tokens available")
}
@@ -133,7 +133,8 @@ struct MediumWidgetView: View {
// Token List
VStack(spacing: 0) {
ForEach(Array(entry.tokens.prefix(3).enumerated()), id: \.element.id) { index, token in
- Link(destination: URL(string: "shapeshift://token/\(token.id)")!) {
+ let urlString = CaipMapping.shared.getShapeShiftUrl(forCoinGeckoId: token.id) ?? CaipMapping.shared.getFallbackUrl()
+ Link(destination: URL(string: urlString)!) {
TokenRowView(token: token, isLast: index == min(2, entry.tokens.count - 1))
}
}