diff --git a/mobile/lib/features/analytics/presentation/pages/analytics.dart b/mobile/lib/features/analytics/presentation/pages/analytics.dart index 7c8586d..f181d5a 100644 --- a/mobile/lib/features/analytics/presentation/pages/analytics.dart +++ b/mobile/lib/features/analytics/presentation/pages/analytics.dart @@ -5,7 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:twoaxis_finance/features/user/presentation/bloc/user_cubit.dart'; import 'package:twoaxis_finance/features/user/presentation/bloc/user_state.dart'; -import 'package:twoaxis_finance/features/transactions/presentation/bloc/transactions_bloc.dart'; +import 'package:twoaxis_finance/features/transactions/domain/transaction_repository.dart'; import 'package:twoaxis_finance/features/transactions/domain/transaction_type.dart'; import 'package:twoaxis_finance/core/util/money_format.dart'; @@ -140,13 +140,24 @@ class AnalyticsPage extends StatelessWidget { style: TextStyle(fontWeight: FontWeight.bold, fontSize: 25), ), SizedBox(height: 30), - BlocBuilder( - builder: (context, state) { - if (state is! TransactionsStateLoaded) { + FutureBuilder( + future: () { + var now = DateTime.now(); + var start = DateTime(now.year, now.month, now.day).subtract(Duration(days: 6)); + var end = DateTime(now.year, now.month, now.day, 23, 59, 59); + return context.read().getTransactionsByDateRange(start, end); + }(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } - var spots = generateExpenseSpots(state.transactions); + if (snapshot.hasError) { + return Center(child: Text("Error loading chart data")); + } + + var transactions = snapshot.data ?? []; + var spots = generateExpenseSpots(transactions); return SizedBox( height: 200, diff --git a/mobile/lib/features/transactions/data/transaction_repository_impl.dart b/mobile/lib/features/transactions/data/transaction_repository_impl.dart index e40b08c..b1e2c32 100644 --- a/mobile/lib/features/transactions/data/transaction_repository_impl.dart +++ b/mobile/lib/features/transactions/data/transaction_repository_impl.dart @@ -43,23 +43,30 @@ class TransactionRepositoryImpl implements TransactionRepository { } @override - Future> getAllTransactions() async { + Future> getTransactions({int limit = 30, Object? startAfter}) async { try { var uid = _auth.currentUser?.uid; if (uid == null) { - developer.log("getAllTransactions: User ID is null"); - return []; + developer.log("getTransactions: User ID is null"); + return {'transactions': [], 'lastDocument': null}; } - var result = await _firestore + Query query = _firestore .collection("users") .doc(uid) .collection("transactions") - .get(); + .orderBy("date", descending: true) + .limit(limit); + + if (startAfter != null && startAfter is DocumentSnapshot) { + query = query.startAfterDocument(startAfter); + } + + var result = await query.get(); - developer.log("getAllTransactions: Found ${result.docs.length} documents"); + developer.log("getTransactions: Found ${result.docs.length} documents"); - return result.docs.map((doc) { + List transactions = result.docs.map((doc) { try { return Transaction.fromFirestore(doc); } catch (e) { @@ -67,8 +74,41 @@ class TransactionRepositoryImpl implements TransactionRepository { rethrow; } }).toList(); + + DocumentSnapshot? lastDocument = result.docs.isNotEmpty ? result.docs.last : null; + + return { + 'transactions': transactions, + 'lastDocument': lastDocument, + }; + } catch (e) { + developer.log("Error in getTransactions: $e"); + rethrow; + } + } + + @override + Future> getTransactionsByDateRange(DateTime start, DateTime end) async { + try { + var uid = _auth.currentUser?.uid; + if (uid == null) { + return []; + } + + var result = await _firestore + .collection("users") + .doc(uid) + .collection("transactions") + .where("date", isGreaterThanOrEqualTo: start) + .where("date", isLessThanOrEqualTo: end) + .orderBy("date", descending: true) + .get(); + + return result.docs.map((doc) { + return Transaction.fromFirestore(doc); + }).toList(); } catch (e) { - developer.log("Error in getAllTransactions: $e"); + developer.log("Error in getTransactionsByDateRange: $e"); rethrow; } } diff --git a/mobile/lib/features/transactions/domain/transaction_repository.dart b/mobile/lib/features/transactions/domain/transaction_repository.dart index 722eb75..4657986 100644 --- a/mobile/lib/features/transactions/domain/transaction_repository.dart +++ b/mobile/lib/features/transactions/domain/transaction_repository.dart @@ -2,8 +2,8 @@ import 'package:twoaxis_finance/features/transactions/domain/transaction.dart'; import 'package:twoaxis_finance/features/transactions/domain/transaction_type.dart'; abstract class TransactionRepository { - - Future> getAllTransactions(); + Future> getTransactions({int limit = 30, Object? startAfter}); + Future> getTransactionsByDateRange(DateTime start, DateTime end); Future addTransaction(String name, TransactionType type, double amount, {String? source, String? category, DateTime? date}); Future deleteTransaction(String id); } diff --git a/mobile/lib/features/transactions/presentation/bloc/transactions_bloc.dart b/mobile/lib/features/transactions/presentation/bloc/transactions_bloc.dart index 0baf650..2c44e8b 100644 --- a/mobile/lib/features/transactions/presentation/bloc/transactions_bloc.dart +++ b/mobile/lib/features/transactions/presentation/bloc/transactions_bloc.dart @@ -15,6 +15,7 @@ class TransactionsBloc extends Bloc { : _transactionRepository = transactionRepository, super(TransactionsStateInitial()) { on(_onLoadTransactions); + on(_onLoadMoreTransactions); on(_onAddTransaction); on(_onDeleteTransaction); } @@ -25,7 +26,9 @@ class TransactionsBloc extends Bloc { ) async { emit(TransactionsStateLoading()); try { - var transactions = await _transactionRepository.getAllTransactions(); + var result = await _transactionRepository.getTransactions(limit: 30); + var transactions = result['transactions'] as List; + var lastDocument = result['lastDocument']; // Sort latest to oldest transactions.sort((a, b) => b.date.compareTo(a.date)); @@ -35,6 +38,8 @@ class TransactionsBloc extends Bloc { emit(TransactionsStateLoaded( transactions: transactions, groupedTransactions: _groupTransactions(transactions), + hasReachedMax: transactions.length < 30, + lastDocument: lastDocument, )); } catch (e, stackTrace) { @@ -43,6 +48,44 @@ class TransactionsBloc extends Bloc { } } + Future _onLoadMoreTransactions( + LoadMoreTransactionsEvent event, + Emitter emit, + ) async { + var currentState = state; + if (currentState is TransactionsStateLoaded && !currentState.hasReachedMax) { + try { + var result = await _transactionRepository.getTransactions( + limit: 30, + startAfter: currentState.lastDocument, + ); + var newTransactions = result['transactions'] as List; + var lastDocument = result['lastDocument']; + + if (newTransactions.isEmpty) { + emit(TransactionsStateLoaded( + transactions: currentState.transactions, + groupedTransactions: currentState.groupedTransactions, + hasReachedMax: true, + lastDocument: currentState.lastDocument, + )); + } else { + var allTransactions = List.from(currentState.transactions)..addAll(newTransactions); + allTransactions.sort((a, b) => b.date.compareTo(a.date)); + + emit(TransactionsStateLoaded( + transactions: allTransactions, + groupedTransactions: _groupTransactions(allTransactions), + hasReachedMax: newTransactions.length < 30, + lastDocument: lastDocument, + )); + } + } catch (e, stackTrace) { + developer.log("Error loading more transactions", error: e, stackTrace: stackTrace); + } + } + } + Future _onAddTransaction( AddTransactionEvent event, Emitter emit, @@ -81,6 +124,8 @@ class TransactionsBloc extends Bloc { emit(TransactionsStateLoaded( transactions: updatedTransactions, groupedTransactions: _groupTransactions(updatedTransactions), + hasReachedMax: currentState.hasReachedMax, + lastDocument: currentState.lastDocument, )); } else { add(LoadTransactionsEvent()); @@ -107,6 +152,8 @@ class TransactionsBloc extends Bloc { emit(TransactionsStateLoaded( transactions: updatedTransactions, groupedTransactions: _groupTransactions(updatedTransactions), + hasReachedMax: currentState.hasReachedMax, + lastDocument: currentState.lastDocument, )); } else { add(LoadTransactionsEvent()); diff --git a/mobile/lib/features/transactions/presentation/bloc/transactions_event.dart b/mobile/lib/features/transactions/presentation/bloc/transactions_event.dart index 46b24a3..03598b9 100644 --- a/mobile/lib/features/transactions/presentation/bloc/transactions_event.dart +++ b/mobile/lib/features/transactions/presentation/bloc/transactions_event.dart @@ -8,6 +8,8 @@ abstract class TransactionsEvent extends Equatable { class LoadTransactionsEvent extends TransactionsEvent {} +class LoadMoreTransactionsEvent extends TransactionsEvent {} + class AddTransactionEvent extends TransactionsEvent { final String name; final TransactionType type; diff --git a/mobile/lib/features/transactions/presentation/bloc/transactions_state.dart b/mobile/lib/features/transactions/presentation/bloc/transactions_state.dart index f3c49d4..5a4c35b 100644 --- a/mobile/lib/features/transactions/presentation/bloc/transactions_state.dart +++ b/mobile/lib/features/transactions/presentation/bloc/transactions_state.dart @@ -13,14 +13,18 @@ class TransactionsStateLoading extends TransactionsState {} class TransactionsStateLoaded extends TransactionsState { final List transactions; final Map> groupedTransactions; + final bool hasReachedMax; + final Object? lastDocument; const TransactionsStateLoaded({ required this.transactions, required this.groupedTransactions, + this.hasReachedMax = false, + this.lastDocument, }); @override - List get props => [transactions, groupedTransactions]; + List get props => [transactions, groupedTransactions, hasReachedMax, lastDocument]; } class TransactionsStateError extends TransactionsState { diff --git a/mobile/lib/features/transactions/presentation/pages/transactions.dart b/mobile/lib/features/transactions/presentation/pages/transactions.dart index bfab6cc..83abefd 100644 --- a/mobile/lib/features/transactions/presentation/pages/transactions.dart +++ b/mobile/lib/features/transactions/presentation/pages/transactions.dart @@ -7,9 +7,35 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:twoaxis_finance/core/util/money_format.dart'; -class TransactionsPage extends StatelessWidget { +class TransactionsPage extends StatefulWidget { const TransactionsPage({super.key}); + @override + State createState() => _TransactionsPageState(); +} + +class _TransactionsPageState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { + context.read().add(LoadMoreTransactionsEvent()); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -58,125 +84,139 @@ class TransactionsPage extends StatelessWidget { var sortedKeys = grouped.keys.toList() ..sort((a, b) => b.compareTo(a)); - return ListView.separated( - itemCount: sortedKeys.length, - itemBuilder: (BuildContext context, int index) { - var dateKey = sortedKeys.elementAt(index); - var txList = grouped[dateKey]!; + return Column( + children: [ + Expanded( + child: ListView.separated( + controller: _scrollController, + itemCount: sortedKeys.length, + itemBuilder: (BuildContext context, int index) { + var dateKey = sortedKeys.elementAt(index); + var txList = grouped[dateKey]!; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - DateFormat('MMM d, y').format(dateKey), - style: TextStyle( - fontSize: 18, fontWeight: FontWeight.bold), - ), - SizedBox( - height: 10, - ), - ...txList.map((tx) { - return GestureDetector( - onLongPress: () { - showModalBottomSheet( - context: context, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(16)), - ), - builder: (BuildContext context) { - return Container( - height: 180, - padding: EdgeInsets.all(20), - width: MediaQuery.of(context).size.width, - child: Column( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat('MMM d, y').format(dateKey), + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold), + ), + SizedBox( + height: 10, + ), + ...txList.map((tx) { + return GestureDetector( + onLongPress: () { + showModalBottomSheet( + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(16)), + ), + builder: (BuildContext context) { + return Container( + height: 180, + padding: EdgeInsets.all(20), + width: MediaQuery.of(context).size.width, + child: Column( + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: + BorderRadius.circular(2), + ), + ), + SizedBox( + height: 20, + ), + ListTile( + onTap: () { + context.read().add(DeleteTransactionEvent(tx.id)); + Navigator.pop(context); + }, + title: Text("Delete"), + ), + ], + ), + ); + }, + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + spacing: 20, children: [ Container( - width: 40, - height: 4, + padding: EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: - BorderRadius.circular(2), + color: Theme.of(context).colorScheme.surfaceBright, + borderRadius: BorderRadius.circular(10)), + child: Icon( + getCategoryIcon( + tx.category, + defaultIcon: tx.type == TransactionType.expense + ? Icons.payments_rounded + : Icons.file_download_rounded, + ), + size: 25, ), ), - SizedBox( - height: 20, - ), - ListTile( - onTap: () { - context.read().add(DeleteTransactionEvent(tx.id)); - Navigator.pop(context); - }, - title: Text("Delete"), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text(tx.name), + Text( + DateFormat('MMM d, y. hh:mm a') + .format(tx.date), + style: TextStyle( + fontSize: 12, color: Colors.grey)) + ], + ), ), - ], - ), - ); - }, - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Row( - spacing: 20, - children: [ - Container( - padding: EdgeInsets.all(10), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceBright, - borderRadius: BorderRadius.circular(10)), - child: Icon( - getCategoryIcon( - tx.category, - defaultIcon: tx.type == TransactionType.expense - ? Icons.payments_rounded - : Icons.file_download_rounded, - ), - size: 25, - ), - ), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text(tx.name), - Text( - DateFormat('MMM d, y. hh:mm a') - .format(tx.date), + if (tx.type == TransactionType.expense) + Text( + "-${formatMoneyWithContext(context, tx.amount)}", + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 17, + fontWeight: FontWeight.bold), + ) + else if (tx.type == TransactionType.income) + Text( + "+${formatMoneyWithContext(context, tx.amount)}", style: TextStyle( - fontSize: 12, color: Colors.grey)) + color: Colors.green, + fontSize: 17, + fontWeight: FontWeight.bold), + ) ], ), ), - if (tx.type == TransactionType.expense) - Text( - "-${formatMoneyWithContext(context, tx.amount)}", - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontSize: 17, - fontWeight: FontWeight.bold), - ) - else if (tx.type == TransactionType.income) - Text( - "+${formatMoneyWithContext(context, tx.amount)}", - style: TextStyle( - color: Colors.green, - fontSize: 17, - fontWeight: FontWeight.bold), - ) - ], - ), - ), + ); + }) + ], ); - }) - ], - ); - }, - separatorBuilder: (BuildContext context, int index) { - return Divider(color: Theme.of(context).colorScheme.surfaceBright, height: 30); - }, + }, + separatorBuilder: (BuildContext context, int index) { + return Divider(color: Theme.of(context).colorScheme.surfaceBright, height: 30); + }, + ), + ), + if (!state.hasReachedMax) + const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Center( + child: CircularProgressIndicator(), + ), + ), + ], ); } return const SizedBox(); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 38ac295..ddf9afa 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -23,7 +23,7 @@ void main() async { await notificationService.initialize(); var token = await notificationService.getToken(); - print("FCM Token: $token"); + debugPrint("FCM Token: $token"); runApp(const App()); } diff --git a/web/src/hooks/useAnalyticsTransactions.ts b/web/src/hooks/useAnalyticsTransactions.ts new file mode 100644 index 0000000..46d5969 --- /dev/null +++ b/web/src/hooks/useAnalyticsTransactions.ts @@ -0,0 +1,59 @@ +import { useState, useEffect } from 'react'; +import { collection, query, orderBy, onSnapshot, where } from 'firebase/firestore'; +import { db } from '../firebase'; +import { useAuth } from '../contexts/AuthContext'; +import type { Transaction } from '../types'; + +export function useAnalyticsTransactions() { + const { currentUser } = useAuth(); + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!currentUser) { + setTransactions([]); + setLoading(false); + return; + } + + const txRef = collection(db, 'users', currentUser.uid, 'transactions'); + + // We need 7 days of data. Let's get the start of 7 days ago. + const sevenDaysAgo = new Date(); + sevenDaysAgo.setHours(0, 0, 0, 0); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6); // 6 days ago + today = 7 days + + const q = query( + txRef, + where('date', '>=', sevenDaysAgo), + orderBy('date', 'desc') + ); + + const unsubscribe = onSnapshot(q, (snapshot) => { + const txs: Transaction[] = []; + snapshot.forEach((doc) => { + const data = doc.data(); + txs.push({ + id: doc.id, + name: data.name, + amount: data.amount, + type: data.type, + date: data.date.toDate(), + category: data.category, + source: data.source + }); + }); + setTransactions(txs); + setLoading(false); + }, (err) => { + console.error("Error fetching analytics transactions:", err); + setError(err.message); + setLoading(false); + }); + + return () => unsubscribe(); + }, [currentUser]); + + return { transactions, loading, error }; +} diff --git a/web/src/hooks/useTransactions.ts b/web/src/hooks/useTransactions.ts index b92903f..067cecd 100644 --- a/web/src/hooks/useTransactions.ts +++ b/web/src/hooks/useTransactions.ts @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react'; -import { collection, query, orderBy, onSnapshot, addDoc, deleteDoc, doc } from 'firebase/firestore'; +import { useState, useEffect, useCallback } from 'react'; +import { collection, query, orderBy, onSnapshot, addDoc, deleteDoc, doc, limit } from 'firebase/firestore'; import { db } from '../firebase'; import { useAuth } from '../contexts/AuthContext'; import type { Transaction } from '../types'; @@ -9,6 +9,9 @@ export function useTransactions() { const [transactions, setTransactions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + + const [currentLimit, setCurrentLimit] = useState(30); + const [hasMore, setHasMore] = useState(true); useEffect(() => { if (!currentUser) { @@ -18,7 +21,7 @@ export function useTransactions() { } const txRef = collection(db, 'users', currentUser.uid, 'transactions'); - const q = query(txRef, orderBy('date', 'desc')); + const q = query(txRef, orderBy('date', 'desc'), limit(currentLimit)); const unsubscribe = onSnapshot(q, (snapshot) => { const txs: Transaction[] = []; @@ -34,7 +37,9 @@ export function useTransactions() { source: data.source }); }); + setTransactions(txs); + setHasMore(txs.length >= currentLimit); setLoading(false); }, (err) => { console.error("Error fetching transactions:", err); @@ -43,7 +48,11 @@ export function useTransactions() { }); return () => unsubscribe(); - }, [currentUser]); + }, [currentUser, currentLimit]); + + const loadMore = useCallback(() => { + setCurrentLimit(prev => prev + 30); + }, []); const addTransaction = async (tx: Omit) => { if (!currentUser) return null; @@ -71,5 +80,5 @@ export function useTransactions() { } }; - return { transactions, loading, error, addTransaction, deleteTransaction }; + return { transactions, loading, error, addTransaction, deleteTransaction, loadMore, hasMore }; } diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index 345af65..69c8f07 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -1,5 +1,5 @@ import { useUserData } from '../contexts/UserDataContext'; -import { useTransactions } from '../hooks/useTransactions'; +import { useAnalyticsTransactions } from '../hooks/useAnalyticsTransactions'; import { formatMoney } from '../utils/moneyFormat'; import { Chart as ChartJS, @@ -26,7 +26,7 @@ ChartJS.register( export function AnalyticsPage() { const { userData } = useUserData(); - const { transactions } = useTransactions(); + const { transactions } = useAnalyticsTransactions(); // Calculations const assetsSum = userData?.assets.reduce((sum, item) => sum + item.value, 0) || 0; diff --git a/web/src/pages/TransactionsPage.tsx b/web/src/pages/TransactionsPage.tsx index 0150db0..5c5a3fd 100644 --- a/web/src/pages/TransactionsPage.tsx +++ b/web/src/pages/TransactionsPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef, useCallback } from 'react'; import { useTransactions } from '../hooks/useTransactions'; import { useUserData } from '../contexts/UserDataContext'; import { formatMoney } from '../utils/moneyFormat'; @@ -8,11 +8,23 @@ import { Modal } from '../components/Modal'; import { PrimaryButton } from '../components/PrimaryButton'; export function TransactionsPage() { - const { transactions, deleteTransaction } = useTransactions(); + const { transactions, deleteTransaction, loadMore, hasMore, loading } = useTransactions(); const { userData } = useUserData(); const [deleteId, setDeleteId] = useState(null); + const observer = useRef(null); + const lastTransactionRef = useCallback((node: HTMLDivElement | null) => { + if (loading) return; + if (observer.current) observer.current.disconnect(); + observer.current = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && hasMore) { + loadMore(); + } + }); + if (node) observer.current.observe(node); + }, [loading, hasMore, loadMore]); + const handleDelete = async () => { if (!deleteId) return; try { @@ -32,8 +44,14 @@ export function TransactionsPage() { ) : (
- {transactions.map((tx) => ( -
+ {transactions.map((tx, index) => { + const isLast = index === transactions.length - 1; + return ( +
@@ -65,7 +83,12 @@ export function TransactionsPage() { delete
- ))} + )})} +
+ )} + {loading && transactions.length > 0 && ( +
+ progress_activity
)}