diff --git a/App.tsx b/App.tsx index a898cb374..f9f8357ca 100644 --- a/App.tsx +++ b/App.tsx @@ -131,6 +131,7 @@ import AddContact from './views/Settings/AddContact'; import ContactDetails from './views/ContactDetails'; import CurrencyConverter from './views/Settings/CurrencyConverter'; import PendingHTLCs from './views/PendingHTLCs'; +import ActivityExportOptions from './views/ActivityExportOptions'; // POS import Order from './views/Order'; @@ -903,6 +904,12 @@ export default class App extends React.PureComponent { OnChainAddresses } /> + diff --git a/locales/en.json b/locales/en.json index 4adc44b82..fe2d78512 100644 --- a/locales/en.json +++ b/locales/en.json @@ -996,8 +996,14 @@ "views.ActivityFilter.ampInvoices": "AMP invoices", "views.ActivityToCsv.title": "Download Activity", "views.ActivityToCsv.csvDownloaded": "CSV file has been downloaded", + "views.ActivityToCsv.csvDownloadFailed": "Failed to download CSV file", "views.ActivityToCsv.textInputPlaceholder": "File name (optional)", "views.ActivityToCsv.downloadButton": "Download CSV", + "views.activityExport.title": "Export CSV Data", + "views.activityExport.noData": "No data to export", + "views.activityExport.exportPayments": "Export Payments", + "views.activityExport.exportInvoices": "Export Invoices", + "views.activityExport.exportTransactions": "Export Transactions", "views.Routing.RoutingEvent.sourceChannel": "Source Channel", "views.Routing.RoutingEvent.destinationChannel": "Destination Channel", "views.Olympians.title": "Olympians", diff --git a/views/ActivityExportOptions.tsx b/views/ActivityExportOptions.tsx new file mode 100644 index 000000000..458c0657b --- /dev/null +++ b/views/ActivityExportOptions.tsx @@ -0,0 +1,358 @@ +import * as React from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Alert, + Platform, + ScrollView, + ActivityIndicator +} from 'react-native'; +import RNFS from 'react-native-fs'; +import Icon from 'react-native-vector-icons/Feather'; +import { inject, observer } from 'mobx-react'; + +import Header from '../components/Header'; +import LoadingIndicator from '../components/LoadingIndicator'; + +import { localeString } from '../utils/LocaleUtils'; +import { themeColor } from '../utils/ThemeUtils'; + +import ActivityStore from '../stores/ActivityStore'; +import SettingsStore from '../stores/SettingsStore'; + +import Invoice from '../models/Invoice'; +import Payment from '../models/Payment'; +import Transaction from '../models/Transaction'; + +interface ActivityExportOptionsProps { + navigation: any; + ActivityStore: ActivityStore; + SettingsStore: SettingsStore; +} + +interface ActivityExportOptionsState { + isCsvLoading: boolean; + isActivityFetching: boolean; + filteredActivity: any; +} + +@inject('ActivityStore', 'SettingsStore') +@observer +export default class ActivityExportOptions extends React.Component< + ActivityExportOptionsProps, + ActivityExportOptionsState +> { + constructor(props: ActivityExportOptionsProps) { + super(props); + this.state = { + isCsvLoading: false, + isActivityFetching: true, + filteredActivity: [] + }; + } + + componentDidMount() { + this.fetchAndFilterActivity(); + } + + fetchAndFilterActivity = async () => { + const { SettingsStore, ActivityStore } = this.props; + const { locale } = SettingsStore.settings; + + try { + // Call getActivityAndFilter to fetch and filter activity data + await ActivityStore.getActivityAndFilter(locale); + + // Update filteredActivity in state + this.setState({ + filteredActivity: ActivityStore.filteredActivity, + isActivityFetching: false + }); + } catch (err) { + console.error('Failed to fetch activity data:', err); + this.setState({ isActivityFetching: false }); + Alert.alert(localeString('general.error')); + } + }; + + getFormattedDateTime = () => { + const now = new Date(); + const year = now.getFullYear(); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const day = now.getDate().toString().padStart(2, '0'); + const hours = now.getHours().toString().padStart(2, '0'); + const minutes = now.getMinutes().toString().padStart(2, '0'); + const seconds = now.getSeconds().toString().padStart(2, '0'); + + return `${year}${month}${day}_${hours}${minutes}${seconds}`; + }; + + convertActivityToCsv = async ( + data: Array, + keysToInclude: Array + ) => { + if (!data || data.length === 0) { + return ''; + } + + try { + const header = keysToInclude.map((field) => field.label).join(','); + const rows = data + ?.map((item: any) => + keysToInclude + .map((field) => `"${item[field.value]}"` || '') + .join(',') + ) + .join('\n'); + + return `${header}\n${rows}`; + } catch (err) { + console.error(err); + return ''; + } + }; + + downloadCsv = async (type: 'invoice' | 'payment' | 'transaction') => { + const { filteredActivity } = this.state; + + // If filteredActivity is empty, try fetching it again + if (!filteredActivity || filteredActivity.length === 0) { + Alert.alert( + localeString('general.warning'), + localeString('views.ActivityToCsv.noData') + ); + await this.fetchAndFilterActivity(); + return; + } + + this.setState({ isCsvLoading: true }); + + let keysToInclude: any; + let filteredData: any; + + switch (type) { + case 'invoice': + keysToInclude = [ + { label: 'Amount Paid (sat)', value: 'getAmount' }, + { label: 'Payment Request', value: 'getPaymentRequest' }, + { label: 'Payment Hash', value: 'getRHash' }, + { label: 'Memo', value: 'getMemo' }, + { label: 'Note', value: 'getNote' }, + { label: 'Creation Date', value: 'getCreationDate' }, + { label: 'Expiry', value: 'formattedTimeUntilExpiry' } + ]; + filteredData = filteredActivity.filter( + (item: any) => item instanceof Invoice + ); + break; + + case 'payment': + keysToInclude = [ + { label: 'Destination', value: 'getDestination' }, + { label: 'Payment Request', value: 'getPaymentRequest' }, + { label: 'Payment Hash', value: 'paymentHash' }, + { label: 'Amount Paid (sat)', value: 'getAmount' }, + { label: 'Memo', value: 'getMemo' }, + { label: 'Note', value: 'getNote' }, + { label: 'Creation Date', value: 'getDate' } + ]; + filteredData = filteredActivity.filter( + (item: any) => + item instanceof Payment && item?.getDestination + ); + break; + + case 'transaction': + keysToInclude = [ + { label: 'Transaction Hash', value: 'tx' }, + { label: 'Amount (sat)', value: 'getAmount' }, + { label: 'Total Fees (sat)', value: 'getFee' }, + { label: 'Note', value: 'getNote' }, + { label: 'Timestamp', value: 'getDate' } + ]; + filteredData = filteredActivity.filter( + (item: any) => item instanceof Transaction + ); + break; + + default: + keysToInclude = []; + filteredData = []; + break; + } + + const csvData = await this.convertActivityToCsv( + filteredData, + keysToInclude + ); + + if (!csvData) { + this.setState({ isCsvLoading: false }); + Alert.alert( + localeString('general.error'), + localeString('views.ActivityToCsv.noData') + ); + return; + } + + try { + const dateTime = this.getFormattedDateTime(); + const baseFileName = `zeus_${dateTime}_${type}.csv`; + const filePath = + Platform.OS === 'android' + ? `${RNFS.DownloadDirectoryPath}/${baseFileName}` + : `${RNFS.DocumentDirectoryPath}/${baseFileName}`; + + await RNFS.writeFile(filePath, csvData, 'utf8'); + + this.setState({ isCsvLoading: false }); + + Alert.alert( + localeString('general.success'), + localeString('views.ActivityToCsv.csvDownloaded') + ); + } catch (err) { + console.error('Failed to save CSV file:', err); + Alert.alert( + localeString('general.error'), + localeString('views.ActivityToCsv.csvDownloadFailed') + ); + } finally { + this.setState({ isCsvLoading: false }); + } + }; + + render() { + const { isCsvLoading, isActivityFetching } = this.state; + + return ( + +
+ {isCsvLoading && ( + + )} + + + {isActivityFetching ? ( + + ) : ( + <> + this.downloadCsv('invoice')} + disabled={isCsvLoading} + > + + + {localeString( + 'views.activityExport.exportInvoices' + )} + + + + this.downloadCsv('payment')} + disabled={isCsvLoading} + > + + + {localeString( + 'views.activityExport.exportPayments' + )} + + + + this.downloadCsv('transaction')} + disabled={isCsvLoading} + > + + + {localeString( + 'views.activityExport.exportTransactions' + )} + + + + )} + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20 + }, + optionButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 15, + marginVertical: 10, + borderRadius: 10 + }, + optionText: { + marginLeft: 15, + fontSize: 16 + } +}); diff --git a/views/Tools.tsx b/views/Tools.tsx index 7bbb1d884..09b27e1de 100644 --- a/views/Tools.tsx +++ b/views/Tools.tsx @@ -23,6 +23,7 @@ import { localeString } from '../utils/LocaleUtils'; import { themeColor } from '../utils/ThemeUtils'; import SettingsStore from '../stores/SettingsStore'; +import { Icon } from 'react-native-elements'; interface ToolsProps { navigation: StackNavigationProp; @@ -240,6 +241,44 @@ export default class Tools extends React.Component { + + + + navigation.navigate('ActivityExportOptions') + } + > + + + + + {localeString('views.activityExport.title')} + + + + + + );