diff --git a/.agent/skills/api-akomo/SKILL.md b/.agent/skills/api-akomo/SKILL.md index af7c9f6..a2e71ec 100644 --- a/.agent/skills/api-akomo/SKILL.md +++ b/.agent/skills/api-akomo/SKILL.md @@ -1,26 +1,28 @@ --- name: api-akomo-docs -description: provides detailed technical documentation and interaction guidelines for the akomo backend api (api.akomo.jodaz.xyz). +description: provides detailed technical documentation and interaction guidelines for the akomo backend api (api.akomo.xyz). --- # AKomo API Documentation Skill ## When to use this skill - when a user or another agent needs to interact with the AKomo backend API. - when exploring the available endpoints for exchange rates and mobile app builds. -- when planning a new application that consumes data from `api.akomo.jodaz.xyz`. +- when planning a new application that consumes data from `api.akomo.xyz`. - when debugging issues related to fetching BCV, Binance rates, or app builds from the AKomo server. ## Overview -The AKomo API serves as the centralized backend for the AKomo mobile application (market tracking / currency exchange). The root domain is `https://api.akomo.jodaz.xyz/api`. It mainly handles: +The AKomo API serves as the centralized backend for the AKomo mobile application (market tracking / currency exchange). The root domain is `https://api.akomo.xyz/api`. It mainly handles: 1. **Exchange Rates**: Syncing and providing latest currency rates (USD, EUR, USDT) from sources like BCV and Binance P2P. 2. **Builds**: Managing the download URLs and versions of the mobile application across platforms. +Interactive documentation is available via Swagger at: `https://api.akomo.xyz/api/docs` + ## Necessary Inputs -None. This skill provides documentation. If making requests, the HTTP client must respect the base URL and standard headers (e.g., `Content-Type: application/json` for POST requests). Standard REST conventions apply. Authentication/Authorization is not explicitly required for standard `GET` requests for rates and builds (they are public), but internal `POST` routes may be protected in deployment (if enforced at a higher level). +None. This skill provides documentation. If making requests, the HTTP client must respect the base URL and standard headers (e.g., `Content-Type: application/json` for POST requests). Standard REST conventions apply. Authentication/Authorization currently relies on environment secrets for protected operations (e.g., Supabase service role), but public `GET` endpoints are open. ## Workflow -1. **Identify the Data Required**: Determine if you need current rates, manual rates update, a specific Binance average, or mobile build information. -2. **Choose the Corresponding Endpoint**: Reference the "Endpoints" section to find the exact path and method. +1. **Identify the Data Required**: Determine if you need current rates, historical data, manual rates update, a specific Binance average, or mobile build information. +2. **Choose the Corresponding Endpoint**: Reference the "Endpoints" section or the Swagger UI to find the exact path and method. 3. **Construct the Request**: Include the proper Query Parameters or JSON Body as defined. 4. **Parse the Response**: Ensure your application can parse the specific output formats provided by the API. @@ -47,6 +49,12 @@ Returns the most recently recorded exchange rates for tracked currencies (USD, E ``` *(Note: The `value` is returned as a comma-separated string `36,25` for UI rendering).* +#### `GET /api/exchange-rates/history` +Returns historical exchange rates for the last N days. +- **Query Parameters:** + - `days` (optional, default: `7`): Number of days of history to fetch. +- **Output (JSON):** Returns an array of objects containing symbol, current price, percentage change, and history points. + #### `POST /api/exchange-rates/bcv` Manually updates the historical BCV record for USD and EUR. - **Body:** @@ -77,13 +85,13 @@ Calculates and returns the average Binance P2P price for a specific asset/fiat p } ``` -*(Note: Standard CRUD routes like `POST /`, `GET /:id`, `PATCH /:id`, and `DELETE /:id` exist under `/api/exchange-rates` but are currently stubbed implementations).* +*(Note: Standard CRUD routes like `POST /`, `GET /:id`, `PATCH /:id`, and `DELETE /:id` exist but are mostly for internal use or administrative manual updates).* ### 2. Builds Manages the mobile application build references (links to APKs/IPAs). #### `GET /api/builds` -Returns a list of available app builds, ordered by creation date (newest first). +Returns a list of available app builds. - **Output (JSON):** ```json [ @@ -105,7 +113,6 @@ Registers a newly compiled mobile application build. "platform": "ios" } ``` -- **Output:** Returns the created database record (JSON). ## Error Handling If requests fail, standard NestJS HTTP exceptions will be returned in this generic format: @@ -116,7 +123,7 @@ If requests fail, standard NestJS HTTP exceptions will be returned in this gener "error": "Bad Request" } ``` -Validation errors for POST bodies return an array of user-friendly validation messages under the `message` property due to the NestJS global ValidationPipe. +Validation errors for POST bodies return an array of user-friendly validation messages under the `message` property. ## Output Structure when Queried -When another agent queries this skill to understand the API, you must serve these exact endpoint definitions, keeping the expected inputs (Query/Body) and outputs (Responses) explicitly clear. +When another agent queries this skill to understand the API, you must serve these exact endpoint definitions, keeping the expected inputs (Query/Body) and outputs (Responses) explicitly clear. Also point them to the Swagger UI for real-time exploratory testing. diff --git a/mobile/app/(tabs)/history.tsx b/mobile/app/(tabs)/history.tsx index acd500d..f9a2090 100644 --- a/mobile/app/(tabs)/history.tsx +++ b/mobile/app/(tabs)/history.tsx @@ -12,11 +12,11 @@ import { } from 'react-native'; import { Text } from '@/components/Themed'; import { useExchangeHistory } from '@/hooks/use-exchange-history'; -import { HistoryChart } from '@/components/HistoryChart'; +// import { HistoryChart } from '@/components/HistoryChart'; import { GradientBackground } from '@/components/GradientBackground'; import { TopHeader } from '@/components/TopHeader'; import { CreditsFooter } from '@/components/CreditsFooter'; -import { ArrowUpRight, ArrowDownRight } from 'lucide-react-native'; +import { ArrowUpRight, ArrowDownRight, TrendingUp } from 'lucide-react-native'; export default function HistoryScreen() { const [days, setDays] = useState(7); @@ -24,6 +24,14 @@ export default function HistoryScreen() { const { width } = useWindowDimensions(); const isDesktop = Platform.OS === 'web' && width > 768; + // Calculate gaps between USDT and BCV rates + const usdtPrice = data?.find(i => i.symbol.toUpperCase().includes('USDT'))?.currentPrice; + const usdPrice = data?.find(i => i.symbol === 'USD')?.currentPrice; + const eurPrice = data?.find(i => i.symbol === 'EUR')?.currentPrice; + + const usdGap = (usdtPrice && usdPrice) ? ((usdtPrice / usdPrice) - 1) * 100 : 0; + const eurGap = (usdtPrice && eurPrice) ? ((usdtPrice / eurPrice) - 1) * 100 : 0; + const renderTab = (value: number, label: string) => { const isActive = days === value; return ( @@ -97,12 +105,36 @@ export default function HistoryScreen() { {data?.map((item) => renderRateCard(item.symbol, item.currentPrice, item.change))} - {/* Chart section — card rules: borderRadius 20, padding 24, border */} + {/* Gap Section — Breach Analysis */} + + + + Brecha Cambiaria + + + + USDT / USD BCV + = 0 ? '#F1C40F' : '#14b8a6' }]}> + {usdGap >= 0 ? '+' : ''}{usdGap.toFixed(2)}% + + + + + USDT / EUR BCV + = 0 ? '#F1C40F' : '#14b8a6' }]}> + {eurGap >= 0 ? '+' : ''}{eurGap.toFixed(2)}% + + + + + {/* Chart section hidden per user request */} + {/* Evolución últimos {days} días {data && } {days} días de datos + */} @@ -214,7 +246,7 @@ const styles = StyleSheet.create({ rateChangeNegative: { color: '#14b8a6', // teal-accent for negative change }, - // Chart card: borderRadius 20, padding 24, border per guidelines + /* chartSection: { backgroundColor: '#1B6B3E', borderRadius: 20, @@ -235,6 +267,41 @@ const styles = StyleSheet.create({ fontSize: 12, opacity: 0.8, }, + */ + gapSection: { + backgroundColor: '#155230', // dark green for breach analysis + borderRadius: 20, + padding: 20, + marginBottom: 20, + borderWidth: 1, + borderColor: '#448A44', + }, + gapHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 16, + }, + gapTitle: { + color: '#F1C40F', + fontSize: 16, + fontWeight: 'bold', + fontFamily: 'Zain_700Bold', + }, + gapRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + gapLabel: { + color: '#FFFFFF', + fontSize: 14, + opacity: 0.8, + }, + gapValue: { + fontSize: 20, + fontWeight: 'bold', + }, footer: { alignItems: 'center', marginTop: 40, diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 16cd686..b1e253f 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -74,6 +74,7 @@ function RootLayoutNav() { case '/info': return 'Información'; case '/builds': return 'Descargas'; case '/history': return 'Historial'; + case '/calculator': return 'Calculadora'; case '/modal': return 'Detalles'; case '/privacy-policy': return 'Privacidad'; default: return 'Tasas'; @@ -105,6 +106,7 @@ function RootLayoutNav() { + diff --git a/mobile/app/calculator.tsx b/mobile/app/calculator.tsx new file mode 100644 index 0000000..e3308f3 --- /dev/null +++ b/mobile/app/calculator.tsx @@ -0,0 +1,303 @@ +import React, { useMemo, useState } from 'react'; +import { View, StyleSheet, SafeAreaView, StatusBar, Platform, useWindowDimensions, FlatList, TouchableOpacity, Image } from 'react-native'; +import { Text } from '@/components/Themed'; +import { GradientBackground } from '@/components/GradientBackground'; +import { TopHeader } from '@/components/TopHeader'; +import { useExchangeRates } from '@/hooks/use-exchange-rates'; +import { useCalculatorStore } from '@/stores/calculator-store'; +import { CalculatorItem } from '@/components/CalculatorItem'; +import { formatNumber } from '@/utils/format'; +import { Plus, Trash2, DollarSign, Euro, RotateCcw, Copy } from 'lucide-react-native'; +import * as Clipboard from 'expo-clipboard'; + +export default function CalculatorScreen() { + const { data } = useExchangeRates(); + const { items, addItem, clearAll } = useCalculatorStore(); + const [selectedUnit, setSelectedUnit] = useState<'USD' | 'EUR' | 'USDT'>('USD'); + const { width } = useWindowDimensions(); + const isDesktop = Platform.OS === 'web' && width > 768; + + // Calculate totals + const totalBase = useMemo(() => { + return items.reduce((sum, item) => { + const val = parseFloat(item.amount.replace(',', '.')) || 0; + return sum + val; + }, 0); + }, [items]); + + const getRateValue = (symbol: string) => { + const rate = data?.rates.find(r => r.label === symbol); + if (!rate) return 0; + return parseFloat(rate.value.replace(',', '.')) || 0; + }; + + const rateUSD = getRateValue('USD'); + const rateEUR = getRateValue('EUR'); + const rateUSDT = getRateValue('USDT'); + + const totalBcvUSD = totalBase * rateUSD; + const totalBcvEUR = totalBase * rateEUR; + const totalBinanceUSDT = totalBase * rateUSDT; + + const unitLabels = { + 'USDT': 'USDT', + 'USD': 'USD BCV', + 'EUR': 'EURO BCV' + }; + + const handleCopy = async () => { + const text = `${formatNumber(totalBase)} ${unitLabels[selectedUnit]}\nBs. ${formatNumber(currentVesTotal)}`; + await Clipboard.setStringAsync(text); + }; + + const currentVesTotal = useMemo(() => { + switch (selectedUnit) { + case 'USD': return totalBcvUSD; + case 'EUR': return totalBcvEUR; + case 'USDT': return totalBinanceUSDT; + default: return totalBcvUSD; + } + }, [selectedUnit, totalBcvUSD, totalBcvEUR, totalBinanceUSDT]); + + return ( + + + + + + + + + + + {(['USDT', 'USD', 'EUR'] as const).map((unit) => { + const isActive = selectedUnit === unit; + return ( + setSelectedUnit(unit)} + activeOpacity={0.8} + > + {unit === 'USDT' ? ( + + ) : unit === 'USD' ? ( + + ) : ( + + )} + + ); + })} + + + + + + Calculadora + + + + + + {selectedUnit === 'USDT' ? ( + + ) : selectedUnit === 'USD' ? ( + + ) : ( + + )} + + {formatNumber(totalBase)} + + + + Bs. {formatNumber(currentVesTotal)} + + + + + + + + + + + item.id} + renderItem={({ item }) => } + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + No hay items. Agrega uno debajo. + + } + /> + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: 'transparent', + }, + content: { + flex: 1, + paddingHorizontal: 16, + paddingTop: 10, + width: '100%', + }, + desktopContent: { + maxWidth: 600, + alignSelf: 'center', + }, + summaryCard: { + backgroundColor: 'transparent', + borderRadius: 20, + padding: 24, + marginBottom: 24, + borderWidth: 1, + borderColor: '#1B6B3E', + }, + summaryHeaderRow: { + flexDirection: 'row-reverse', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + summaryTitle: { + color: '#fff', + fontSize: 20, + fontWeight: 'bold', + opacity: 0.8, + }, + headerResetButton: { + backgroundColor: '#1B6B3E', + padding: 10, + borderRadius: 12, + borderWidth: 1, + borderColor: '#448A44', + }, + summaryFooterRow: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: 12, + }, + copyButton: { + backgroundColor: '#1B6B3E', + padding: 10, + borderRadius: 12, + borderWidth: 1, + borderColor: '#448A44', + }, + totalsStack: { + alignItems: 'center', + gap: 4, + }, + totalBase: { + flexDirection: 'row', + alignItems: 'baseline', + gap: 6, + }, + totalBaseAmount: { + color: '#fff', + fontSize: 56, + fontWeight: 'bold', + }, + totalIconContainer: { + justifyContent: 'center', + alignItems: 'center', + }, + totalIconImage: { + width: 32, + height: 32, + }, + totalVesSub: { + color: '#F1C40F', + fontSize: 24, + fontWeight: 'bold', + opacity: 0.9, + }, + customToggleContainer: { + flexDirection: 'row', + backgroundColor: '#1B6B3E', + borderRadius: 12, + borderWidth: 1, + borderColor: '#448A44', + padding: 2, + }, + toggleSection: { + paddingHorizontal: 10, + paddingVertical: 10, + borderRadius: 10, + }, + activeToggleSection: { + backgroundColor: '#F1C40F', + }, + toggleText: { + color: '#F1C40F', + fontSize: 12, + fontWeight: 'bold', + }, + activeToggleText: { + color: '#1B6B3E', + }, + clearButton: { + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(27, 107, 62, 0.4)', + padding: 10, + borderRadius: 12, + borderWidth: 1, + borderColor: '#1B6B3E', + }, + listContent: { + paddingBottom: 20, + }, + emptyContainer: { + padding: 40, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.2)', + borderRadius: 16, + borderStyle: 'dashed', + }, + emptyText: { + color: 'rgba(255,255,255,0.6)', + fontSize: 16, + textAlign: 'center', + }, + addButton: { + backgroundColor: '#1B6B3E', + padding: 16, + borderRadius: 50, + borderWidth: 1, + borderColor: '#448A44', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 40, + marginTop: 20, + alignSelf: 'center', + // Shadow for premium feel + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 5, + elevation: 8, + }, +}); diff --git a/mobile/assets/images/adaptive-icon.png b/mobile/assets/images/adaptive-icon.png index a7ec7d5..e2ad59c 100644 Binary files a/mobile/assets/images/adaptive-icon.png and b/mobile/assets/images/adaptive-icon.png differ diff --git a/mobile/assets/images/favicon.png b/mobile/assets/images/favicon.png index 44307dd..3fad646 100644 Binary files a/mobile/assets/images/favicon.png and b/mobile/assets/images/favicon.png differ diff --git a/mobile/assets/images/ios-dark.png b/mobile/assets/images/ios-dark.png deleted file mode 100644 index ceb6e65..0000000 Binary files a/mobile/assets/images/ios-dark.png and /dev/null differ diff --git a/mobile/assets/images/logo.png b/mobile/assets/images/logo.png index 44307dd..3fad646 100644 Binary files a/mobile/assets/images/logo.png and b/mobile/assets/images/logo.png differ diff --git a/mobile/assets/images/logo_arepa.svg b/mobile/assets/images/logo_arepa.svg deleted file mode 100644 index 6079b2e..0000000 --- a/mobile/assets/images/logo_arepa.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/mobile/assets/images/splash-icon-dark.png b/mobile/assets/images/splash-icon-dark.png index 97a20dd..3fad646 100644 Binary files a/mobile/assets/images/splash-icon-dark.png and b/mobile/assets/images/splash-icon-dark.png differ diff --git a/mobile/assets/images/splash-icon-light.png b/mobile/assets/images/splash-icon-light.png index f9c34d5..2fafd98 100644 Binary files a/mobile/assets/images/splash-icon-light.png and b/mobile/assets/images/splash-icon-light.png differ diff --git a/mobile/assets/images/splash.png b/mobile/assets/images/splash.png index 42245a6..3fad646 100644 Binary files a/mobile/assets/images/splash.png and b/mobile/assets/images/splash.png differ diff --git a/mobile/components/CalculatorItem.tsx b/mobile/components/CalculatorItem.tsx new file mode 100644 index 0000000..5ce7daf --- /dev/null +++ b/mobile/components/CalculatorItem.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { View, StyleSheet, TouchableOpacity } from 'react-native'; +import { TextInput } from '@/components/Themed'; +import { Trash2 } from 'lucide-react-native'; +import { CalculatorItem as CalculatorItemType, useCalculatorStore } from '../stores/calculator-store'; +import { formatInputDisplay } from '../utils/format'; + +interface Props { + item: CalculatorItemType; +} + +export function CalculatorItem({ item }: Props) { + const updateItem = useCalculatorStore((state) => state.updateItem); + const removeItem = useCalculatorStore((state) => state.removeItem); + + const handleAmountChange = (text: string) => { + updateItem(item.id, { amount: formatInputDisplay(text) }); + }; + + return ( + + + updateItem(item.id, { description: text })} + placeholder="Descripción" + placeholderTextColor="rgba(255,255,255,0.4)" + /> + + + removeItem(item.id)}> + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + gap: 16, + }, + inputContainer: { + flex: 1, + flexDirection: 'row', + gap: 10, + }, + descriptionInput: { + flex: 2, + backgroundColor: '#1B6B3E', + borderRadius: 12, + borderWidth: 1, + borderColor: '#448A44', + paddingHorizontal: 16, + paddingVertical: 12, + color: '#fff', + fontSize: 16, + }, + amountInput: { + flex: 1.2, + backgroundColor: '#1B6B3E', + borderRadius: 12, + borderWidth: 1, + borderColor: '#448A44', + paddingHorizontal: 16, + paddingVertical: 12, + color: '#F1C40F', + fontSize: 16, + fontWeight: 'bold', + textAlign: 'right', + }, + deleteButton: { + justifyContent: 'center', + alignItems: 'center', + } +}); diff --git a/mobile/components/TopHeader.tsx b/mobile/components/TopHeader.tsx index 092b01b..107ff0a 100644 --- a/mobile/components/TopHeader.tsx +++ b/mobile/components/TopHeader.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { View, StyleSheet, TouchableOpacity, Image, Platform, useWindowDimensions } from 'react-native'; import { Text } from '@/components/Themed'; import { useRouter, usePathname } from 'expo-router'; -import { Home, HelpCircle, BarChart3 } from 'lucide-react-native'; +import { Home, HelpCircle, BarChart3, Calculator } from 'lucide-react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; export function TopHeader() { @@ -23,6 +23,7 @@ export function TopHeader() { const isHomeActive = pathname === '/' || pathname === '/index'; const isHistoryActive = pathname === '/history' || pathname.includes('history'); + const isCalculatorActive = pathname === '/calculator'; const isInfoActive = pathname === '/info'; return ( @@ -40,6 +41,10 @@ export function TopHeader() { + navigateTo('calculator')} style={styles.iconBtn}> + + + {/* History button */} navigateTo('(tabs)/history')} style={styles.iconBtn}> diff --git a/mobile/stores/calculator-store.ts b/mobile/stores/calculator-store.ts new file mode 100644 index 0000000..a74c422 --- /dev/null +++ b/mobile/stores/calculator-store.ts @@ -0,0 +1,41 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { storage } from '../utils/storage'; + +export interface CalculatorItem { + id: string; + description: string; + amount: string; +} + +interface CalculatorState { + items: CalculatorItem[]; + addItem: () => void; + removeItem: (id: string) => void; + updateItem: (id: string, data: Partial) => void; + clearAll: () => void; +} + +const generateId = () => Math.random().toString(36).substring(2, 9); + +export const useCalculatorStore = create()( + persist( + (set) => ({ + items: [], + addItem: () => set((state) => ({ + items: [...state.items, { id: generateId(), description: `Item ${state.items.length + 1}`, amount: '' }] + })), + removeItem: (id) => set((state) => ({ + items: state.items.filter(item => item.id !== id) + })), + updateItem: (id, data) => set((state) => ({ + items: state.items.map(item => item.id === id ? { ...item, ...data } : item) + })), + clearAll: () => set({ items: [] }), + }), + { + name: 'calculator-storage', + storage: createJSONStorage(() => storage), + } + ) +);