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)) } }