From c83f831a2132f3ebd82853a783616dd34532f13e Mon Sep 17 00:00:00 2001 From: Big-cedar Date: Fri, 29 May 2026 22:26:31 +0100 Subject: [PATCH] feat(mobile): empty states + logger + notif tap routing + locale formatting --- mobile/__tests__/utils/formatXLM.test.ts | 25 ++- mobile/app/(tabs)/groups/page.tsx | 36 ++--- mobile/app/referrals/index.tsx | 25 ++- mobile/app/transaction/history.tsx | 12 +- mobile/app/transactions/index.tsx | 3 +- .../components/groups/ContributionHistory.tsx | 15 +- mobile/components/groups/PayoutSchedule.tsx | 8 +- mobile/components/ui/EmptyState.tsx | 46 +----- mobile/components/ui/empty.tsx | 143 ++++++++++++++++++ mobile/screens/NotificationsScreen.tsx | 17 ++- mobile/services/api/groupsApi.ts | 10 +- mobile/services/api/notificationsApi.ts | 5 +- mobile/services/api/transactionsApi.ts | 5 +- mobile/services/logger/index.ts | 139 +++++++++++++++++ mobile/services/notifications.ts | 7 +- mobile/services/security/securityLogger.ts | 4 +- mobile/utils/formatDate.ts | 2 +- mobile/utils/logger.ts | 42 +---- mobile/utils/stellar.ts | 23 +-- 19 files changed, 390 insertions(+), 177 deletions(-) create mode 100644 mobile/components/ui/empty.tsx create mode 100644 mobile/services/logger/index.ts diff --git a/mobile/__tests__/utils/formatXLM.test.ts b/mobile/__tests__/utils/formatXLM.test.ts index eb19059..004b9e9 100644 --- a/mobile/__tests__/utils/formatXLM.test.ts +++ b/mobile/__tests__/utils/formatXLM.test.ts @@ -1,31 +1,40 @@ import { formatXLM } from '@/utils/stellar'; +function normalizeSpaces(value: string): string { + return value.replace(/\u00a0|\u202f/g, ' '); +} + describe('formatXLM', () => { it('formats an integer amount with thousand separators and decimals', () => { - expect(formatXLM(1234)).toBe('1,234.00 XLM'); + expect(formatXLM(1234, 2, 'en')).toBe('1,234.00 XLM'); }); it('formats a large amount with multiple separators', () => { - expect(formatXLM(1000000.5)).toBe('1,000,000.50 XLM'); + expect(formatXLM(1000000.5, 2, 'en')).toBe('1,000,000.50 XLM'); }); it('handles zero with fixed decimals', () => { - expect(formatXLM(0)).toBe('0.00 XLM'); + expect(formatXLM(0, 2, 'en')).toBe('0.00 XLM'); }); it('formats a decimal amount rounded to specified decimals', () => { - expect(formatXLM(1.555, 2)).toBe('1.56 XLM'); - expect(formatXLM(1.5, 1)).toBe('1.5 XLM'); + expect(formatXLM(1.555, 2, 'en')).toBe('1.56 XLM'); + expect(formatXLM(1.5, 1, 'en')).toBe('1.5 XLM'); }); it('returns 0.00 XLM for NaN input', () => { // @ts-ignore - testing runtime NaN - expect(formatXLM(NaN)).toBe('0.00 XLM'); + expect(formatXLM(NaN, 2, 'en')).toBe('0.00 XLM'); // @ts-ignore - testing runtime invalid string - expect(formatXLM('abc' as any)).toBe('0.00 XLM'); + expect(formatXLM('abc' as any, 2, 'en')).toBe('0.00 XLM'); }); it('handles negative numbers', () => { - expect(formatXLM(-1234.5)).toBe('-1,234.50 XLM'); + expect(formatXLM(-1234.5, 2, 'en')).toBe('-1,234.50 XLM'); + }); + + it('uses a comma decimal separator for French locale', () => { + const formatted = normalizeSpaces(formatXLM(1234.5, 2, 'fr')); + expect(formatted).toMatch(/^1[ \u00a0\u202f]234,50 XLM$/); }); }); diff --git a/mobile/app/(tabs)/groups/page.tsx b/mobile/app/(tabs)/groups/page.tsx index d9710b0..7c033f7 100644 --- a/mobile/app/(tabs)/groups/page.tsx +++ b/mobile/app/(tabs)/groups/page.tsx @@ -1,9 +1,9 @@ 'use client'; -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect, useMemo } from 'react'; import { FlatList, RefreshControl, SafeAreaView, View, Text, Pressable, StyleSheet } from 'react-native'; import { useRouter } from 'expo-router'; -import { Badge, ErrorState, LoadingSkeleton, TextInput } from '../../../components/ui'; +import { Badge, EmptyState, ErrorState, LoadingSkeleton, TextInput } from '../../../components/ui'; import { useDebounce } from '../../../hooks/useDebounce'; import { formatXLM } from '../../../utils/stellar'; @@ -188,11 +188,11 @@ export default function GroupsPage() { ) : error ? ( ) : ( - data={filteredGroups} - keyExtractor={(item) => item.id} + keyExtractor={(item: Group) => item.id} renderItem={renderGroup} - getItemLayout={(_, index) => ({ length: 110, offset: 110 * index, index })} + getItemLayout={(_: unknown, index: number) => ({ length: 110, offset: 110 * index, index })} removeClippedSubviews maxToRenderPerBatch={10} windowSize={5} @@ -205,10 +205,12 @@ export default function GroupsPage() { /> } ListEmptyComponent={ - - No groups to show - Try another filter to see matching groups. - + } /> )} @@ -320,20 +322,4 @@ const styles = StyleSheet.create({ color: '#475569', marginTop: 4, }, - emptyState: { - marginTop: 32, - alignItems: 'center', - }, - emptyTitle: { - fontSize: 18, - fontWeight: '700', - color: '#0F172A', - marginBottom: 8, - }, - emptyMessage: { - fontSize: 15, - color: '#64748B', - textAlign: 'center', - maxWidth: 260, - }, }); diff --git a/mobile/app/referrals/index.tsx b/mobile/app/referrals/index.tsx index 9e767a1..a5fb28f 100644 --- a/mobile/app/referrals/index.tsx +++ b/mobile/app/referrals/index.tsx @@ -11,6 +11,8 @@ import { } from 'react-native'; import * as Clipboard from 'expo-clipboard'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { EmptyState } from '../../components/ui'; +import { formatDate } from '../../utils/formatDate'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -115,11 +117,7 @@ function StatsRow({ stats }: { stats: ReferralStats }) { } function ReferralRow({ item }: { item: Referral }) { - const date = new Date(item.joinedAt).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - }); + const date = formatDate(item.joinedAt, { month: 'short', day: 'numeric', year: 'numeric' }); return ( @@ -196,7 +194,7 @@ export default function ReferralsScreen({ publicKey }: ReferralsScreenProps) { style={styles.screen} contentContainerStyle={styles.content} data={referrals} - keyExtractor={(item) => item.id} + keyExtractor={(item: Referral) => item.id} ListHeaderComponent={ <> Invite & earn @@ -208,11 +206,14 @@ export default function ReferralsScreen({ publicKey }: ReferralsScreenProps) { {referrals.length > 0 && Your referrals} } - renderItem={({ item }) => } + renderItem={({ item }: { item: Referral }) => } ListEmptyComponent={ - - No referrals yet — share your code to get started! - + } /> ); @@ -289,6 +290,4 @@ const styles = StyleSheet.create({ badgeTextConfirmed: { color: '#065F46' }, badgeTextPending: { color: '#92400E' }, - emptyList: { alignItems: 'center', paddingVertical: 32 }, - emptyText: { fontSize: 14, color: '#94A3B8', textAlign: 'center' }, -}); \ No newline at end of file +}); diff --git a/mobile/app/transaction/history.tsx b/mobile/app/transaction/history.tsx index f77d8c8..716e819 100644 --- a/mobile/app/transaction/history.tsx +++ b/mobile/app/transaction/history.tsx @@ -11,6 +11,7 @@ import { } from 'react-native'; import { useRouter } from 'expo-router'; import { TransactionItem, TransactionType } from '../../components/transactions/TransactionItem'; +import { EmptyState } from '../../components/ui'; type TabKey = 'All' | 'Contributions' | 'Payouts'; @@ -143,9 +144,12 @@ export default function TransactionHistoryScreen() { contentContainerStyle={styles.list} refreshControl={} ListEmptyComponent={ - - No {activeTab.toLowerCase()} to show. - + } /> @@ -191,6 +195,4 @@ const styles = StyleSheet.create({ backgroundColor: '#6366F1', }, list: { paddingHorizontal: 16, paddingTop: 8 }, - empty: { marginTop: 48, alignItems: 'center' }, - emptyText: { color: '#64748B', fontSize: 15 }, }); diff --git a/mobile/app/transactions/index.tsx b/mobile/app/transactions/index.tsx index 1e846b7..77fdb9e 100644 --- a/mobile/app/transactions/index.tsx +++ b/mobile/app/transactions/index.tsx @@ -98,7 +98,8 @@ export default function TransactionHistory() { onEndReachedThreshold={0.3} ListEmptyComponent={ diff --git a/mobile/components/groups/ContributionHistory.tsx b/mobile/components/groups/ContributionHistory.tsx index 18866bc..573e4ff 100644 --- a/mobile/components/groups/ContributionHistory.tsx +++ b/mobile/components/groups/ContributionHistory.tsx @@ -5,6 +5,7 @@ import { View, Text, StyleSheet, FlatList } from 'react-native'; import { SectionHeader } from '../ui/SectionHeader'; import { EmptyState } from '../ui/EmptyState'; import { formatXLM } from '../../utils/stellar'; +import { formatDate } from '../../utils/formatDate'; type Transaction = { contributor: string; @@ -22,11 +23,6 @@ export function ContributionHistory({ transactions }: ContributionHistoryProps) return `${address.slice(0, 6)}...${address.slice(-4)}`; }; - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); - }; - const renderTransaction = ({ item }: { item: Transaction }) => ( @@ -38,7 +34,9 @@ export function ContributionHistory({ transactions }: ContributionHistoryProps) - {formatDate(item.date)} + + {formatDate(item.date, { month: 'short', day: 'numeric', year: 'numeric' })} + @@ -52,7 +50,8 @@ export function ContributionHistory({ transactions }: ContributionHistoryProps) @@ -67,7 +66,7 @@ export function ContributionHistory({ transactions }: ContributionHistoryProps) `${item.contributor}-${item.round}-${index}`} + keyExtractor={(item: Transaction, index: number) => `${item.contributor}-${item.round}-${index}`} showsVerticalScrollIndicator={false} ItemSeparatorComponent={() => } /> diff --git a/mobile/components/groups/PayoutSchedule.tsx b/mobile/components/groups/PayoutSchedule.tsx index 8ff42af..9fa9cd4 100644 --- a/mobile/components/groups/PayoutSchedule.tsx +++ b/mobile/components/groups/PayoutSchedule.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { View, Text, StyleSheet } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { formatDate } from '../../utils/formatDate'; type RoundStatus = 'upcoming' | 'completed' | 'current'; @@ -22,11 +23,6 @@ export function PayoutSchedule({ rounds }: PayoutScheduleProps) { return `${address.slice(0, 6)}...${address.slice(-4)}`; }; - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); - }; - const getStatusIcon = (status: RoundStatus) => { switch (status) { case 'completed': @@ -94,7 +90,7 @@ export function PayoutSchedule({ rounds }: PayoutScheduleProps) { styles.dateText, round.status === 'current' && styles.currentText ]}> - {formatDate(round.date)} + {formatDate(round.date, { month: 'short', day: 'numeric', year: 'numeric' })} diff --git a/mobile/components/ui/EmptyState.tsx b/mobile/components/ui/EmptyState.tsx index 40be38f..9eb38d1 100644 --- a/mobile/components/ui/EmptyState.tsx +++ b/mobile/components/ui/EmptyState.tsx @@ -1,44 +1,2 @@ -import React from 'react'; -import { View, Text, Pressable, StyleSheet } from 'react-native'; -import { getIllustration, type IllustrationType } from './Illustration'; - -interface Props { - icon?: string; - illustration?: IllustrationType; - title: string; - message: string; - actionLabel?: string; - onAction?: () => void; -} - -export function EmptyState({ icon, illustration, title, message, actionLabel, onAction }: Props) { - // Support both icon prop and illustration type - const displayIcon = icon || (illustration ? getIllustration(illustration).emoji : '📦'); - - return ( - - {displayIcon} - {title} - {message} - {actionLabel && ( - - {actionLabel} - - )} - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, - icon: { fontSize: 48, marginBottom: 12 }, - title: { fontSize: 18, fontWeight: '600', color: '#F1F5F9', marginBottom: 8 }, - message: { fontSize: 14, color: '#94A3B8', textAlign: 'center', marginBottom: 20 }, - button: { - backgroundColor: '#334155', - borderRadius: 8, - paddingVertical: 10, - paddingHorizontal: 20, - }, - buttonText: { color: '#F1F5F9', fontWeight: '600', fontSize: 14 }, -}); +export { EmptyState } from './empty'; +export type { EmptyStateIllustration } from './empty'; diff --git a/mobile/components/ui/empty.tsx b/mobile/components/ui/empty.tsx new file mode 100644 index 0000000..61781a4 --- /dev/null +++ b/mobile/components/ui/empty.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import Svg, { Circle, Path, Rect, Ellipse, Line } from 'react-native-svg'; +import { useTheme } from '../../context/ThemeContext'; + +export type EmptyStateIllustration = 'groups' | 'transactions' | 'notifications' | 'default'; + +function GroupsIllustration() { + return ( + + + + + + + + + + ); +} + +function TransactionsIllustration() { + return ( + + + + + + + + + + + + ); +} + +function NotificationsIllustration() { + return ( + + + + + + + ); +} + +function DefaultIllustration() { + return ( + + + + + + + + ); +} + +function Illustration({ type }: { type: EmptyStateIllustration }) { + switch (type) { + case 'groups': + return ; + case 'transactions': + return ; + case 'notifications': + return ; + default: + return ; + } +} + +type Tone = 'light' | 'dark' | 'auto'; + +interface Props { + title: string; + message: string; + illustration?: EmptyStateIllustration; + icon?: string; + actionLabel?: string; + onAction?: () => void; + tone?: Tone; +} + +export function EmptyState({ title, message, illustration, icon, actionLabel, onAction, tone = 'auto' }: Props) { + const { resolvedColorScheme, colors } = useTheme(); + const effectiveTone = tone === 'auto' ? resolvedColorScheme : tone; + const titleColor = effectiveTone === 'dark' ? '#F1F5F9' : '#0F172A'; + const messageColor = effectiveTone === 'dark' ? '#94A3B8' : '#64748B'; + const buttonBg = effectiveTone === 'dark' ? '#334155' : colors.accent; + const buttonText = effectiveTone === 'dark' ? '#F1F5F9' : '#FFFFFF'; + + return ( + + + {illustration ? : icon ? {icon} : } + + {title} + {message} + {actionLabel ? ( + + {actionLabel} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, + illustration: { marginBottom: 18, opacity: 0.98 }, + icon: { fontSize: 48 }, + title: { fontSize: 18, fontWeight: '700', marginBottom: 8, textAlign: 'center' }, + message: { fontSize: 14, textAlign: 'center', marginBottom: 18, lineHeight: 20 }, + button: { borderRadius: 10, paddingVertical: 10, paddingHorizontal: 18 }, + buttonText: { fontWeight: '700', fontSize: 14 }, +}); + diff --git a/mobile/screens/NotificationsScreen.tsx b/mobile/screens/NotificationsScreen.tsx index 019b6ec..3e3becb 100644 --- a/mobile/screens/NotificationsScreen.tsx +++ b/mobile/screens/NotificationsScreen.tsx @@ -2,6 +2,8 @@ import React, { useEffect } from 'react'; import { View, FlatList } from 'react-native'; import { useNotificationsStore } from '../stores/notificationsStore'; import { NotificationItem } from '../components/NotificationItem'; +import { EmptyState } from '../components/ui'; +import type { Notification } from '../types/notification'; export const NotificationsScreen = () => { const { notifications, setNotifications } = useNotificationsStore(); @@ -32,9 +34,18 @@ export const NotificationsScreen = () => { item.id} - renderItem={({ item }) => } + keyExtractor={(item: Notification) => item.id} + renderItem={({ item }: { item: Notification }) => } + contentContainerStyle={notifications.length === 0 ? { flexGrow: 1 } : undefined} + ListEmptyComponent={ + + } /> ); -}; \ No newline at end of file +}; diff --git a/mobile/services/api/groupsApi.ts b/mobile/services/api/groupsApi.ts index df894ed..1023a52 100644 --- a/mobile/services/api/groupsApi.ts +++ b/mobile/services/api/groupsApi.ts @@ -3,6 +3,8 @@ * Handles REST API calls for group-related operations */ +import { logger } from '../logger'; + export interface Group { id: string; name: string; @@ -37,7 +39,7 @@ class GroupsApiService { */ async getUserGroups(userAddress: string): Promise> { try { - console.log(`Fetching groups for user: ${userAddress}`); + logger.debug('GroupsApi', 'Fetching user groups'); // Mock API call - replace with actual implementation await new Promise(resolve => setTimeout(resolve, 1000)); @@ -76,7 +78,7 @@ class GroupsApiService { data: mockGroups, }; } catch (error) { - console.error('Failed to fetch user groups:', error); + logger.error('GroupsApi', 'Failed to fetch user groups', error); return { success: false, error: 'Failed to fetch groups', @@ -89,7 +91,7 @@ class GroupsApiService { */ async getGroupById(groupId: string): Promise> { try { - console.log(`Fetching group details for: ${groupId}`); + logger.debug('GroupsApi', 'Fetching group details', { groupId }); // Mock API call - replace with actual implementation await new Promise(resolve => setTimeout(resolve, 800)); @@ -113,7 +115,7 @@ class GroupsApiService { data: mockGroup, }; } catch (error) { - console.error('Failed to fetch group details:', error); + logger.error('GroupsApi', 'Failed to fetch group details', error); return { success: false, error: 'Failed to fetch group details', diff --git a/mobile/services/api/notificationsApi.ts b/mobile/services/api/notificationsApi.ts index 23d608d..e4a9914 100644 --- a/mobile/services/api/notificationsApi.ts +++ b/mobile/services/api/notificationsApi.ts @@ -5,6 +5,7 @@ import { ApiResponse } from './groupsApi'; import { Notification } from '../../types/notification'; +import { logger } from '../logger'; class NotificationsApiService { private baseUrl: string; @@ -18,7 +19,7 @@ class NotificationsApiService { */ async getUserNotifications(userAddress: string): Promise> { try { - console.log(`Fetching notifications for user: ${userAddress}`); + logger.debug('NotificationsApi', 'Fetching user notifications'); // Mock API call - replace with actual implementation await new Promise(resolve => setTimeout(resolve, 800)); @@ -45,7 +46,7 @@ class NotificationsApiService { data: mockNotifications, }; } catch (error) { - console.error('Failed to fetch notifications:', error); + logger.error('NotificationsApi', 'Failed to fetch notifications', error); return { success: false, error: 'Failed to fetch notifications', diff --git a/mobile/services/api/transactionsApi.ts b/mobile/services/api/transactionsApi.ts index 7b82b03..7e3ad01 100644 --- a/mobile/services/api/transactionsApi.ts +++ b/mobile/services/api/transactionsApi.ts @@ -4,6 +4,7 @@ */ import { ApiResponse } from './groupsApi'; +import { logger } from '../logger'; export interface Transaction { id: string; @@ -28,7 +29,7 @@ class TransactionsApiService { */ async getUserTransactions(userAddress: string): Promise> { try { - console.log(`Fetching transactions for user: ${userAddress}`); + logger.debug('TransactionsApi', 'Fetching user transactions'); // Mock API call - replace with actual implementation await new Promise(resolve => setTimeout(resolve, 1000)); @@ -61,7 +62,7 @@ class TransactionsApiService { data: mockTransactions, }; } catch (error) { - console.error('Failed to fetch transactions:', error); + logger.error('TransactionsApi', 'Failed to fetch transactions', error); return { success: false, error: 'Failed to fetch transactions', diff --git a/mobile/services/logger/index.ts b/mobile/services/logger/index.ts new file mode 100644 index 0000000..f3ad6d3 --- /dev/null +++ b/mobile/services/logger/index.ts @@ -0,0 +1,139 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import config, { isProd } from '../../config/env'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +type LogContext = string; + +const LOG_STORAGE_KEY = 'dev:logs'; + +const LEVEL_ORDER: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +const DEFAULT_MIN_LEVEL: LogLevel = isProd() ? 'warn' : 'debug'; + +type LoggerConfig = { + minLevel: LogLevel; + persistInDev: boolean; + maxPersistedLines: number; + includeStack: boolean; +}; + +let loggerConfig: LoggerConfig = { + minLevel: DEFAULT_MIN_LEVEL, + persistInDev: false, + maxPersistedLines: 400, + includeStack: false, +}; + +function nowIso(): string { + return new Date().toISOString(); +} + +function shouldLog(level: LogLevel): boolean { + return LEVEL_ORDER[level] >= LEVEL_ORDER[loggerConfig.minLevel]; +} + +function isSensitiveKey(key: string): boolean { + return /(password|passphrase|secret|token|private|seed|mnemonic|key)/i.test(key); +} + +function sanitizeUnknown(value: unknown, depth = 0): unknown { + if (value === null || value === undefined) return value; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value; + if (typeof value === 'bigint') return value.toString(); + + if (value instanceof Error) { + const base = { + name: value.name, + message: value.message, + }; + if (loggerConfig.includeStack && value.stack) { + return { ...base, stack: value.stack }; + } + return base; + } + + if (Array.isArray(value)) { + if (depth >= 2) return '[Array]'; + return value.map((entry) => sanitizeUnknown(entry, depth + 1)); + } + + if (typeof value === 'object') { + if (depth >= 2) return '[Object]'; + const input = value as Record; + const output: Record = {}; + Object.keys(input).forEach((key) => { + if (isSensitiveKey(key)) { + output[key] = '[REDACTED]'; + } else { + output[key] = sanitizeUnknown(input[key], depth + 1); + } + }); + return output; + } + + return String(value); +} + +async function persistLine(line: string): Promise { + if (isProd() || !loggerConfig.persistInDev) return; + + try { + const existing = await AsyncStorage.getItem(LOG_STORAGE_KEY); + const parsed = existing ? (JSON.parse(existing) as string[]) : []; + const next = [...parsed, line].slice(-loggerConfig.maxPersistedLines); + await AsyncStorage.setItem(LOG_STORAGE_KEY, JSON.stringify(next)); + } catch { + return; + } +} + +function formatLine(level: LogLevel, context: LogContext, message: string): string { + return `[${nowIso()}][${config.env}][${level.toUpperCase()}][${context}] ${message}`; +} + +function write(level: LogLevel, context: LogContext, message: string, args: unknown[]) { + if (!shouldLog(level)) return; + + const line = formatLine(level, context, message); + const safeArgs = args.map((arg) => sanitizeUnknown(arg)); + + if (level === 'error') console.error(line, ...safeArgs); + else if (level === 'warn') console.warn(line, ...safeArgs); + else if (level === 'info') console.info(line, ...safeArgs); + else console.debug(line, ...safeArgs); + + void persistLine( + safeArgs.length ? `${line} ${JSON.stringify(safeArgs)}` : line, + ); +} + +export function configureLogger(partial: Partial) { + loggerConfig = { ...loggerConfig, ...partial }; +} + +export async function getPersistedLogs(): Promise { + const existing = await AsyncStorage.getItem(LOG_STORAGE_KEY); + return existing ? (JSON.parse(existing) as string[]) : []; +} + +export async function clearPersistedLogs(): Promise { + await AsyncStorage.removeItem(LOG_STORAGE_KEY); +} + +export const logger = { + debug: (context: LogContext, message: string, ...args: unknown[]) => + write('debug', context, message, args), + info: (context: LogContext, message: string, ...args: unknown[]) => + write('info', context, message, args), + warn: (context: LogContext, message: string, ...args: unknown[]) => + write('warn', context, message, args), + error: (context: LogContext, message: string, ...args: unknown[]) => + write('error', context, message, args), +}; + diff --git a/mobile/services/notifications.ts b/mobile/services/notifications.ts index efab2a7..363fe90 100644 --- a/mobile/services/notifications.ts +++ b/mobile/services/notifications.ts @@ -1,6 +1,7 @@ import * as Notifications from 'expo-notifications'; import * as Device from 'expo-device'; import { Platform } from 'react-native'; +import { logger } from './logger'; Notifications.setNotificationHandler({ handleNotification: async () => ({ @@ -12,7 +13,7 @@ Notifications.setNotificationHandler({ export async function registerForPushNotificationsAsync(): Promise { if (!Device.isDevice) { - console.warn('[notifications] Push tokens require a physical device'); + logger.warn('Notifications', 'Push tokens require a physical device'); return null; } @@ -23,7 +24,7 @@ export async function registerForPushNotificationsAsync(): Promise Localization.locale ?? 'en'; diff --git a/mobile/utils/logger.ts b/mobile/utils/logger.ts index 53c6831..baf9732 100644 --- a/mobile/utils/logger.ts +++ b/mobile/utils/logger.ts @@ -1,40 +1,2 @@ -/** - * Standardized logger for the mobile app. - * In production builds all output is suppressed. - */ - -const IS_DEV = __DEV__; - -type LogLevel = 'debug' | 'info' | 'warn' | 'error'; - -function log(level: LogLevel, tag: string, message: string, ...args: unknown[]) { - if (!IS_DEV && level !== 'error') return; - - const prefix = `[${level.toUpperCase()}][${tag}]`; - - switch (level) { - case 'debug': - console.debug(prefix, message, ...args); - break; - case 'info': - console.info(prefix, message, ...args); - break; - case 'warn': - console.warn(prefix, message, ...args); - break; - case 'error': - console.error(prefix, message, ...args); - break; - } -} - -export const logger = { - debug: (tag: string, message: string, ...args: unknown[]) => - log('debug', tag, message, ...args), - info: (tag: string, message: string, ...args: unknown[]) => - log('info', tag, message, ...args), - warn: (tag: string, message: string, ...args: unknown[]) => - log('warn', tag, message, ...args), - error: (tag: string, message: string, ...args: unknown[]) => - log('error', tag, message, ...args), -}; +export { logger } from '../services/logger'; +export type { LogLevel } from '../services/logger'; diff --git a/mobile/utils/stellar.ts b/mobile/utils/stellar.ts index cd3d13a..21023bf 100644 --- a/mobile/utils/stellar.ts +++ b/mobile/utils/stellar.ts @@ -1,3 +1,7 @@ +import * as Localization from 'expo-localization'; + +const getLocale = (): string => Localization.locale ?? 'en'; + /** * Truncates a Stellar address to the format GXXX...XXXX. * Returns the original string if it is too short to truncate. @@ -13,17 +17,14 @@ export function truncateAddress(address: string, leading = 4, trailing = 4): str * * @param amount The numeric amount to format * @param decimals The number of decimal places (default 2) + * @param locale Optional locale override (defaults to device locale) * @returns A formatted string e.g., "1,234.50 XLM" */ -export function formatXLM(amount: number, decimals = 2): string { - const value = isNaN(amount) || amount === null || amount === undefined ? 0 : amount; - - const sign = value >= 0 ? 1 : -1; - const factor = Math.pow(10, decimals); - const rounded = sign * (Math.round(Math.abs(value) * factor) / factor); - - const parts = rounded.toFixed(decimals).split('.'); - parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); - - return `${parts.join('.')} XLM`; +export function formatXLM(amount: number, decimals = 2, locale?: string): string { + const value = typeof amount === 'number' && Number.isFinite(amount) ? amount : 0; + const formatter = new Intl.NumberFormat(locale ?? getLocale(), { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + return `${formatter.format(value)} XLM`; }