bw-expo-app/app/points.tsx

298 lines
7.7 KiB
TypeScript

import Ionicons from '@expo/vector-icons/Ionicons';
import { useRouter } from 'expo-router';
import React, { useCallback, useMemo, useState } from 'react';
import type { ListRenderItem } from 'react-native';
import {
ActivityIndicator,
FlatList,
RefreshControl,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useBalance } from '@/hooks/use-balance';
import { useTransactions } from '@/hooks/use-transactions';
import type { Transaction, TransactionKind } from '@/lib/api/transactions';
type FilterKey = 'all' | TransactionKind;
const screenPalette = {
background: '#080808',
surface: '#101010',
surfaceActive: '#1C1C1C',
surfaceOutline: '#1D1D1D',
divider: '#161616',
primaryText: '#F5F5F5',
secondaryText: '#6C6C6C',
accent: '#FEB840',
accentHalo: 'rgba(254, 184, 64, 0.22)',
negative: '#8F8F8F',
};
const FILTERS: { key: FilterKey; label: string }[] = [
{ key: 'all', label: 'All records' },
{ key: 'consumed', label: 'Consumed' },
{ key: 'obtained', label: 'Obtained' },
];
export default function PointsDetailsScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const [activeFilter, setActiveFilter] = useState<FilterKey>('all');
const { balance, isLoading: isBalanceLoading, refresh: refreshBalance } = useBalance();
const {
transactions,
isLoading: isTransactionsLoading,
refresh: refreshTransactions,
} = useTransactions();
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(async () => {
setRefreshing(true);
await Promise.all([refreshBalance(), refreshTransactions()]);
setRefreshing(false);
}, [refreshBalance, refreshTransactions]);
const filteredEntries = useMemo(() => {
if (activeFilter === 'all') {
return transactions;
}
return transactions.filter(entry => entry.kind === activeFilter);
}, [activeFilter, transactions]);
const listContentStyle = useMemo(
() => ({
paddingBottom: Math.max(insets.bottom + 36, 72),
paddingTop: 4,
}),
[insets.bottom],
);
const keyExtractor = useCallback((entry: Transaction) => entry.id, []);
const renderSeparator = useCallback(() => <View style={styles.listDivider} />, []);
const renderLedgerEntry: ListRenderItem<Transaction> = useCallback(({ item }) => {
const amount = Math.abs(item.amount);
const isPositive = item.amount > 0;
const displayDate = new Date(item.happenedAt).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
return (
<View style={styles.recordRow}>
<View>
<Text style={styles.recordTitle}>{item.title}</Text>
<Text style={styles.recordTimestamp}>{displayDate}</Text>
</View>
<Text
style={[
styles.recordValue,
isPositive ? styles.valuePositive : styles.valueNegative,
]}
>
{isPositive ? `+ ${amount}` : `- ${amount}`}
</Text>
</View>
);
}, []);
const isLoading = isBalanceLoading || isTransactionsLoading;
return (
<View style={[styles.screen, { paddingTop: insets.top + 12 }]}>
<StatusBar barStyle="light-content" />
{isLoading && !refreshing ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={screenPalette.accent} />
</View>
) : (
<>
<View style={styles.balanceCluster}>
<View style={styles.energyOrb}>
<Ionicons name="flash" size={22} color={screenPalette.accent} />
</View>
<Text style={styles.balanceValue}>{balance.remainingTokenBalance}</Text>
</View>
<View style={styles.segmentRail}>
{FILTERS.map(filter => {
const isActive = filter.key === activeFilter;
return (
<TouchableOpacity
key={filter.key}
style={[styles.segmentChip, isActive && styles.segmentChipActive]}
onPress={() => setActiveFilter(filter.key)}
accessibilityRole="button"
>
<Text style={[styles.segmentLabel, isActive && styles.segmentLabelActive]}>
{filter.label}
</Text>
</TouchableOpacity>
);
})}
</View>
<FlatList
data={filteredEntries}
keyExtractor={keyExtractor}
style={styles.list}
contentContainerStyle={listContentStyle}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={renderSeparator}
renderItem={renderLedgerEntry}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={screenPalette.accent}
/>
}
/>
</>
)}
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: screenPalette.background,
paddingHorizontal: 24,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 48,
},
iconButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 17,
lineHeight: 24,
fontWeight: '600',
color: screenPalette.primaryText,
letterSpacing: 0.2,
},
balanceCluster: {
alignItems: 'center',
marginTop: 32,
marginBottom: 28,
gap: 10,
},
energyOrb: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: screenPalette.accentHalo,
alignItems: 'center',
justifyContent: 'center',
shadowColor: screenPalette.accent,
shadowOpacity: 0.3,
shadowRadius: 16,
shadowOffset: { width: 0, height: 8 },
elevation: 8,
},
balanceValue: {
fontSize: 46,
lineHeight: 52,
fontWeight: '700',
color: screenPalette.primaryText,
letterSpacing: 0.5,
fontVariant: ['tabular-nums'],
},
segmentRail: {
flexDirection: 'row',
backgroundColor: screenPalette.surface,
padding: 4,
borderRadius: 28,
borderWidth: StyleSheet.hairlineWidth,
borderColor: screenPalette.surfaceOutline,
gap: 4,
},
segmentChip: {
flex: 1,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 11,
},
segmentChipActive: {
backgroundColor: screenPalette.surfaceActive,
},
segmentLabel: {
fontSize: 14,
lineHeight: 20,
color: screenPalette.secondaryText,
fontWeight: '500',
letterSpacing: 0.2,
},
segmentLabelActive: {
color: screenPalette.primaryText,
},
list: {
flex: 1,
marginTop: 28,
},
listDivider: {
height: StyleSheet.hairlineWidth,
backgroundColor: screenPalette.divider,
marginVertical: 0,
},
recordRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
},
recordTitle: {
fontSize: 15,
lineHeight: 22,
fontWeight: '600',
color: screenPalette.primaryText,
},
recordTimestamp: {
marginTop: 6,
fontSize: 12,
lineHeight: 18,
color: screenPalette.secondaryText,
letterSpacing: 0.2,
},
recordValue: {
fontSize: 16,
lineHeight: 22,
fontWeight: '600',
fontVariant: ['tabular-nums'],
},
valuePositive: {
color: screenPalette.accent,
},
valueNegative: {
color: screenPalette.negative,
},
});