diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 7ed95d199..a2227dc18 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -444,8 +444,10 @@ "transactionBuy": "Kauf", "transactionHistory": "Transaktionshistorie", "transactionPending": "In Bearbeitung", + "transactionReceived": "Empfangen", "transactions": "Transaktionen", "transactionSell": "Verkauf", + "transactionSent": "Gesendet", "transactionWaitingForPayment": "Warte auf Zahlung", "twoFa": "2-Faktor Authentifizierung", "twoFaCodeRequired": "Code ist erforderlich", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 44db6efe9..b89ba9b10 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -444,8 +444,10 @@ "transactionBuy": "Buy", "transactionHistory": "Transaction history", "transactionPending": "Processing", + "transactionReceived": "Received", "transactions": "Transactions", "transactionSell": "Sell", + "transactionSent": "Sent", "transactionWaitingForPayment": "Waiting for payment", "twoFa": "Two-factor authentication", "twoFaCodeRequired": "Code is required", diff --git a/lib/models/dfx_transaction.dart b/lib/models/dfx_transaction.dart index 58e582c23..db5f81fbf 100644 --- a/lib/models/dfx_transaction.dart +++ b/lib/models/dfx_transaction.dart @@ -20,6 +20,7 @@ class DfxTransaction extends Transaction { required super.amount, required super.asset, required super.type, + super.category, required super.note, required super.data, required super.timestamp, diff --git a/lib/models/transaction.dart b/lib/models/transaction.dart index 0b5b247ae..97f74a34b 100644 --- a/lib/models/transaction.dart +++ b/lib/models/transaction.dart @@ -3,6 +3,28 @@ import 'package:realunit_wallet/packages/service/transaction_history_service.dar enum TransactionTypes { transfer, genericContractCall, tokenTransfer, savingsAdd, savingsRemove } +/// Business classification of a REALU transfer, resolved by the API (decision authority): +/// the Brokerbot as counterparty marks a share purchase or sale, everything else is a plain +/// token movement. Unknown or missing values stay null, so the UI falls back to the +/// direction-based labels used before the category existed. +enum TransferCategory { + purchase('purchase'), + sale('sale'), + transferIn('transferIn'), + transferOut('transferOut'); + + final String value; + const TransferCategory(this.value); + + static TransferCategory? fromValue(String? value) { + if (value == null) return null; + return TransferCategory.values.cast().firstWhere( + (e) => e?.value == value, + orElse: () => null, + ); + } +} + /// Transaction with on-chain metadata class Transaction { final int height; @@ -13,6 +35,7 @@ class Transaction { final BigInt amount; final Asset asset; final TransactionTypes type; + final TransferCategory? category; final String? note; final String? data; final DateTime timestamp; @@ -26,6 +49,7 @@ class Transaction { required this.amount, required this.asset, required this.type, + this.category, required this.note, required this.data, required this.timestamp, diff --git a/lib/packages/repository/transaction_repository.dart b/lib/packages/repository/transaction_repository.dart index 2d9335b30..1c0f83d02 100644 --- a/lib/packages/repository/transaction_repository.dart +++ b/lib/packages/repository/transaction_repository.dart @@ -28,6 +28,7 @@ class TransactionRepository { transaction.amount.toRadixString(16), transaction.asset.id, transaction.type.index, + transaction.category?.value ?? '', transaction.note ?? '', transaction.data ?? '', transaction.timestamp, @@ -42,6 +43,7 @@ class TransactionRepository { amount: transaction.amount.toRadixString(16), asset: transaction.asset.id, type: transaction.type.index, + category: transaction.category?.value ?? '', note: transaction.note ?? '', data: transaction.data ?? '', timeStamp: transaction.timestamp, @@ -110,6 +112,7 @@ class TransactionRepository { amount: BigInt.parse(txData.amount, radix: 16), asset: asset, type: txType, + category: TransferCategory.fromValue(txData.category), note: txData.note, data: txData.data, timestamp: txData.timeStamp, @@ -125,6 +128,7 @@ class TransactionRepository { amount: BigInt.parse(txData.amount, radix: 16), asset: asset, type: txType, + category: TransferCategory.fromValue(txData.category), note: txData.note, data: txData.data, timestamp: txData.timeStamp, @@ -203,6 +207,7 @@ class TransactionRepository { amount: BigInt.parse(transactionData.amount, radix: 16), asset: asset, type: txType, + category: TransferCategory.fromValue(transactionData.category), note: transactionData.note, data: transactionData.data, timestamp: transactionData.timeStamp, @@ -219,6 +224,7 @@ class TransactionRepository { amount: BigInt.parse(transactionData.amount, radix: 16), asset: asset, type: txType, + category: TransferCategory.fromValue(transactionData.category), note: transactionData.note, data: transactionData.data, timestamp: transactionData.timeStamp, diff --git a/lib/packages/service/dfx/models/history/dto/account_history_dto.dart b/lib/packages/service/dfx/models/history/dto/account_history_dto.dart index e4c3d905e..cadd091b9 100644 --- a/lib/packages/service/dfx/models/history/dto/account_history_dto.dart +++ b/lib/packages/service/dfx/models/history/dto/account_history_dto.dart @@ -24,11 +24,13 @@ class HistoryEventDto { final DateTime timestamp; final String txHash; final TransferDto? transfer; + final String? category; const HistoryEventDto({ required this.timestamp, required this.txHash, this.transfer, + this.category, }); factory HistoryEventDto.fromJson(Map json) { @@ -38,6 +40,7 @@ class HistoryEventDto { transfer: json['transfer'] != null ? TransferDto.fromJson(json['transfer'] as Map) : null, + category: json['category'] as String?, ); } } diff --git a/lib/packages/service/transaction_history_service.dart b/lib/packages/service/transaction_history_service.dart index 2212fa5d9..3d5fd1d89 100644 --- a/lib/packages/service/transaction_history_service.dart +++ b/lib/packages/service/transaction_history_service.dart @@ -53,6 +53,7 @@ class TransactionHistoryService extends DFXAuthService { amount: BigInt.parse(transfer.value), asset: appStore.apiConfig.asset, type: TransactionTypes.tokenTransfer, + category: TransferCategory.fromValue(entry.category), note: '', data: null, timestamp: entry.timestamp, @@ -73,6 +74,7 @@ class TransactionHistoryService extends DFXAuthService { amount: BigInt.parse(transfer.value), asset: appStore.apiConfig.asset, type: TransactionTypes.tokenTransfer, + category: TransferCategory.fromValue(entry.category), note: '', data: null, timestamp: entry.timestamp, diff --git a/lib/packages/storage/database.dart b/lib/packages/storage/database.dart index 7247fcdbf..45c1aca7a 100644 --- a/lib/packages/storage/database.dart +++ b/lib/packages/storage/database.dart @@ -70,7 +70,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.executor); @override - int get schemaVersion => 2; + int get schemaVersion => 3; @override MigrationStrategy get migration => MigrationStrategy( @@ -81,6 +81,9 @@ class AppDatabase extends _$AppDatabase { if (from < 2) { await m.createTable(dfxTransactionDetails); } + if (from < 3) { + await m.addColumn(transactions, transactions.category); + } }, ); diff --git a/lib/packages/storage/transaction_storage.dart b/lib/packages/storage/transaction_storage.dart index adc53e5d6..b83f13b90 100644 --- a/lib/packages/storage/transaction_storage.dart +++ b/lib/packages/storage/transaction_storage.dart @@ -12,6 +12,7 @@ extension TransactionStorage on AppDatabase { String amount, int asset, int type, + String category, String note, String data, DateTime timeStamp, @@ -25,6 +26,7 @@ extension TransactionStorage on AppDatabase { amount: amount, asset: asset, type: type, + category: Value(category), note: note, data: data, timeStamp: timeStamp, @@ -40,6 +42,7 @@ extension TransactionStorage on AppDatabase { String? amount, int? asset, int? type, + String? category, String? note, String? data, DateTime? timeStamp, @@ -52,6 +55,7 @@ extension TransactionStorage on AppDatabase { amount: Value.absentIfNull(amount), asset: Value.absentIfNull(asset), type: Value.absentIfNull(type), + category: Value.absentIfNull(category), note: Value.absentIfNull(note), data: Value.absentIfNull(data), timeStamp: Value.absentIfNull(timeStamp), @@ -156,6 +160,10 @@ class Transactions extends Table { IntColumn get type => integer()(); // coverage:ignore-line + // Business category of the transfer as delivered by the API ('' = uncategorized, legacy + // rows and events without a category). Added in schema v3. + TextColumn get category => text().withDefault(const Constant(''))(); // coverage:ignore-line + TextColumn get note => text()(); // coverage:ignore-line TextColumn get data => text()(); // coverage:ignore-line diff --git a/lib/screens/dashboard/widgets/transaction_row.dart b/lib/screens/dashboard/widgets/transaction_row.dart index cb2b412c4..b558d86f8 100644 --- a/lib/screens/dashboard/widgets/transaction_row.dart +++ b/lib/screens/dashboard/widgets/transaction_row.dart @@ -5,6 +5,7 @@ import 'package:realunit_wallet/models/transaction.dart'; import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/styles/icons.dart'; import 'package:realunit_wallet/widgets/hide_amount_text.dart'; +import 'package:realunit_wallet/widgets/transaction_title_label.dart'; class TransactionRow extends StatelessWidget { final Transaction transaction; @@ -77,11 +78,8 @@ class TransactionRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _isOutbound - ? S.of(context).transactionSell - : S.of(context).transactionBuy, - style: const TextStyle( - fontSize: 16, + transactionTitleLabel(context, transaction, isOutbound: _isOutbound), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w600, height: 20 / 16, ), diff --git a/lib/screens/transaction_history/widgets/transaction_history_row.dart b/lib/screens/transaction_history/widgets/transaction_history_row.dart index da3382ea6..33d4e9877 100644 --- a/lib/screens/transaction_history/widgets/transaction_history_row.dart +++ b/lib/screens/transaction_history/widgets/transaction_history_row.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:open_file/open_file.dart'; -import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/models/transaction.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart'; import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; @@ -10,6 +9,7 @@ import 'package:realunit_wallet/screens/transaction_history/cubits/receipt/trans import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/hide_amount_text.dart'; +import 'package:realunit_wallet/widgets/transaction_title_label.dart'; class TransactionHistoryRow extends StatelessWidget { final Transaction transaction; @@ -101,9 +101,8 @@ class TransactionHistoryRowView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - isOutbound ? S.of(context).transactionSell : S.of(context).transactionBuy, - style: const TextStyle( - fontSize: 16, + transactionTitleLabel(context, transaction, isOutbound: isOutbound), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w600, height: 20 / 16, ), diff --git a/lib/widgets/transaction_title_label.dart b/lib/widgets/transaction_title_label.dart new file mode 100644 index 000000000..897f63815 --- /dev/null +++ b/lib/widgets/transaction_title_label.dart @@ -0,0 +1,32 @@ +import 'package:flutter/widgets.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/models/dfx_transaction.dart'; +import 'package:realunit_wallet/models/transaction.dart'; + +/// Resolves the list title of a transaction. +/// +/// DFX-backed transactions (bank buys and sells) keep the direction-based Buy/Sell labels. +/// For pure on-chain transfers the API-provided [TransferCategory] decides: Brokerbot +/// counterparty means a share purchase or sale, everything else is a plain received/sent +/// token movement. Without a category (legacy rows, older API) the previous direction-based +/// labels remain, so nothing breaks while backend and app roll out independently. +String transactionTitleLabel(BuildContext context, Transaction transaction, {required bool isOutbound}) { + final s = S.of(context); + + if (transaction is! DfxTransaction) { + switch (transaction.category) { + case TransferCategory.purchase: + return s.transactionBuy; + case TransferCategory.sale: + return s.transactionSell; + case TransferCategory.transferIn: + return s.transactionReceived; + case TransferCategory.transferOut: + return s.transactionSent; + case null: + break; + } + } + + return isOutbound ? s.transactionSell : s.transactionBuy; +} diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png index 214c26bc5..edc1c2256 100644 Binary files a/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png and b/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png index 006cbc517..bd44cd284 100644 Binary files a/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png and b/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/handbook_persona_dca.png b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_dca.png index ee1c22e4b..2ad2def13 100644 Binary files a/test/goldens/screens/dashboard/goldens/macos/handbook_persona_dca.png and b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_dca.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/handbook_persona_lump.png b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_lump.png index 0f089fe28..3d1c649e8 100644 Binary files a/test/goldens/screens/dashboard/goldens/macos/handbook_persona_lump.png and b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_lump.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/handbook_persona_mix.png b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_mix.png index 91b0e4e33..9498ad2eb 100644 Binary files a/test/goldens/screens/dashboard/goldens/macos/handbook_persona_mix.png and b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_mix.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/handbook_persona_scale.png b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_scale.png index 6aa7b2e2d..08d8f1787 100644 Binary files a/test/goldens/screens/dashboard/goldens/macos/handbook_persona_scale.png and b/test/goldens/screens/dashboard/goldens/macos/handbook_persona_scale.png differ diff --git a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_page_list.png b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_page_list.png index 727e916b9..0e11855fa 100644 Binary files a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_page_list.png and b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_page_list.png differ diff --git a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_failure.png b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_failure.png index 30277a55e..97306966e 100644 Binary files a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_failure.png and b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_failure.png differ diff --git a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_loading.png b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_loading.png index 369aae906..c40a717e3 100644 Binary files a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_loading.png and b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_loading.png differ diff --git a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_transfer_labels.png b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_transfer_labels.png index d5ff10cbd..37dca3983 100644 Binary files a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_transfer_labels.png and b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_transfer_labels.png differ diff --git a/test/goldens/screens/transaction_history/transaction_history_transfer_labels_golden_test.dart b/test/goldens/screens/transaction_history/transaction_history_transfer_labels_golden_test.dart index 4f2f92a94..c34715721 100644 --- a/test/goldens/screens/transaction_history/transaction_history_transfer_labels_golden_test.dart +++ b/test/goldens/screens/transaction_history/transaction_history_transfer_labels_golden_test.dart @@ -17,9 +17,10 @@ import 'package:realunit_wallet/screens/transaction_history/transaction_history_ import '../../../helper/helper.dart'; -// Pins direction-only labels (inbound → Kauf, outbound → Verkauf) so a -// follow-up PR can recategorise the same four rows and replace the same -// `transaction_history_transfer_labels` PNG for a pixel before/after. +// Recategorises the #967 baseline under the same fileName +// `transaction_history_transfer_labels`: the four rows stay in place, but +// inbound 10 is now Empfangen (transferIn) instead of Kauf, so GitHub's +// pixel-diff shows Kauf/Empfangen/Gesendet/Verkauf. class _MockTransactionHistoryFilterCubit extends MockCubit @@ -39,8 +40,12 @@ void main() { final transactionRepository = _MockTransactionRepository(); // decimals of realUnitAsset is 0 → amounts are plain share counts. - // Direction alone drives the title today; no TransferCategory on this branch. - Transaction inbound(String txId, int shares, DateTime timestamp) => + Transaction inbound( + String txId, + int shares, + DateTime timestamp, { + TransferCategory? category, + }) => Transaction( height: 200, txId: txId, @@ -50,12 +55,18 @@ void main() { amount: BigInt.from(shares), asset: realUnitAsset, type: TransactionTypes.tokenTransfer, + category: category, note: null, data: null, timestamp: timestamp, ); - Transaction outbound(String txId, int shares, DateTime timestamp) => + Transaction outbound( + String txId, + int shares, + DateTime timestamp, { + TransferCategory? category, + }) => Transaction( height: 199, txId: txId, @@ -65,21 +76,37 @@ void main() { amount: BigInt.from(shares), asset: realUnitAsset, type: TransactionTypes.tokenTransfer, + category: category, note: null, data: null, timestamp: timestamp, ); - // Same four rows the fix PR will recategorise (order + timestamps fixed). final transactions = [ - // looks like Kauf; later PR: purchase - inbound('0xtx1', 100, DateTime.utc(2026, 5, 20, 10, 30)), - // THE BUG (Bojan); later PR: transferIn / Empfangen - inbound('0xtx2', 10, DateTime.utc(2026, 5, 19, 12)), - // looks like Verkauf; later PR: transferOut / Gesendet - outbound('0xtx3', 10, DateTime.utc(2026, 5, 18, 14)), - // looks like Verkauf; later PR: sale - outbound('0xtx4', 20, DateTime.utc(2026, 5, 15, 9, 15)), + inbound( + '0xtx1', + 100, + DateTime.utc(2026, 5, 20, 10, 30), + category: TransferCategory.purchase, + ), + inbound( + '0xtx2', + 10, + DateTime.utc(2026, 5, 19, 12), + category: TransferCategory.transferIn, + ), + outbound( + '0xtx3', + 10, + DateTime.utc(2026, 5, 18, 14), + category: TransferCategory.transferOut, + ), + outbound( + '0xtx4', + 20, + DateTime.utc(2026, 5, 15, 9, 15), + category: TransferCategory.sale, + ), ]; final pinnedClock = Clock.fixed(DateTime.utc(2026, 5, 23)); @@ -124,7 +151,7 @@ void main() { ); goldenTest( - 'inbound from an external wallet labelled Kauf', + 'API category labels purchase, received, sent, sale', fileName: 'transaction_history_transfer_labels', constraints: phoneConstraints, builder: () { diff --git a/test/packages/service/dfx/models/history/account_history_dto_test.dart b/test/packages/service/dfx/models/history/account_history_dto_test.dart index 963471624..70f7b4bca 100644 --- a/test/packages/service/dfx/models/history/account_history_dto_test.dart +++ b/test/packages/service/dfx/models/history/account_history_dto_test.dart @@ -44,6 +44,16 @@ void main() { expect(dto.transfer, isNull); }); + + test('parses a string category', () { + final dto = HistoryEventDto.fromJson({ + 'timestamp': '2026-05-15T10:00:00Z', + 'txHash': '0xabc', + 'category': 'transferIn', + }); + + expect(dto.category, 'transferIn'); + }); }); group('$AccountHistoryDto.fromJson', () { diff --git a/test/packages/service/transaction_history_service_sync_test.dart b/test/packages/service/transaction_history_service_sync_test.dart index 5f7c4751d..a08634672 100644 --- a/test/packages/service/transaction_history_service_sync_test.dart +++ b/test/packages/service/transaction_history_service_sync_test.dart @@ -36,6 +36,7 @@ Map _historyEntry({ String to = _wallet, String value = '1000000', String timestamp = '2026-01-01T00:00:00Z', + String? category, }) => { 'timestamp': timestamp, @@ -45,6 +46,7 @@ Map _historyEntry({ 'to': to, 'value': value, }, + if (category != null) 'category': category, }; Map _accountHistory(List> events) => { @@ -170,9 +172,48 @@ void main() { ).captured.single as Transaction; expect(captured.txId, '0xabc'); expect(captured.amount, BigInt.from(1000000)); + expect(captured.category, isNull); verifyNever(() => txRepo.insertDfxTransaction(any())); }); + test('carries the API transfer category into the stored transaction', () async { + final client = MockClient((request) async { + if (request.url.path.endsWith('/history')) { + return http.Response( + jsonEncode(_accountHistory([_historyEntry(txHash: '0xabc', category: 'transferIn')])), + 200, + ); + } + return http.Response('[]', 200); + }); + + await build(client).apiBasedSync(); + + final captured = verify( + () => txRepo.insertTransaction(captureAny()), + ).captured.single as Transaction; + expect(captured.category, TransferCategory.transferIn); + }); + + test('ignores an unknown category value instead of failing the sync', () async { + final client = MockClient((request) async { + if (request.url.path.endsWith('/history')) { + return http.Response( + jsonEncode(_accountHistory([_historyEntry(txHash: '0xabc', category: 'somethingNew')])), + 200, + ); + } + return http.Response('[]', 200); + }); + + await build(client).apiBasedSync(); + + final captured = verify( + () => txRepo.insertTransaction(captureAny()), + ).captured.single as Transaction; + expect(captured.category, isNull); + }); + test('inserts a DFX-enriched transaction when /v1/transaction has a matching id', () async { final client = MockClient((request) async { if (request.url.path.endsWith('/history')) { diff --git a/test/packages/storage/database_migration_test.dart b/test/packages/storage/database_migration_test.dart index 6409031be..01c1d1c51 100644 --- a/test/packages/storage/database_migration_test.dart +++ b/test/packages/storage/database_migration_test.dart @@ -18,8 +18,8 @@ void main() { }); group('AppDatabase schema', () { - test('schema version is 2', () { - expect(db.schemaVersion, 2); + test('schema version is 3', () { + expect(db.schemaVersion, 3); }); test('creates all expected tables on fresh database', () async { @@ -69,6 +69,7 @@ void main() { 0, '', '', + '', DateTime.now(), ); @@ -94,17 +95,17 @@ void main() { expect(tables, isNotEmpty); }); - test('onUpgrade from v1 → v2 creates the dfx_transaction_details table', () async { - // Simulate a pre-v2 database: drop the dfx_transaction_details - // table (added in v2) and then drive the migration manually via - // the strategy exposed by `AppDatabase.migration`. After - // onUpgrade(1, 2) the table must exist again, exercising the - // `from < 2` branch in the migration callback. + test('onUpgrade from v1 → v3 creates the dfx table and the category column', () async { + // Simulate a pre-v2 database: drop the dfx_transaction_details table (added in v2) + // and the transactions.category column (added in v3), then drive the migration + // manually via the strategy exposed by `AppDatabase.migration`. After + // onUpgrade(1, 3) both must exist again, exercising both `from <` branches. await db.customStatement('DROP TABLE dfx_transaction_details'); + await db.customStatement('ALTER TABLE transactions DROP COLUMN category'); final strategy = db.migration; final migrator = Migrator(db); - await strategy.onUpgrade(migrator, 1, 2); + await strategy.onUpgrade(migrator, 1, 3); final rows = await db .customSelect( @@ -112,19 +113,32 @@ void main() { ) .get(); expect(rows, hasLength(1)); + + final columns = await db.customSelect("PRAGMA table_info('transactions')").get(); + expect(columns.map((r) => r.read('name')), contains('category')); }); - test('onUpgrade does nothing when starting at v2 or later', () async { - // The `if (from < 2)` guard means an upgrade from v2 → v3 (a - // future version) must NOT try to recreate the table. We assert - // that by leaving the table in place and running the callback, - // which would throw "table already exists" if the guard - // regressed. + test('onUpgrade from v2 → v3 adds the category column with its default', () async { + await db.customStatement('ALTER TABLE transactions DROP COLUMN category'); + final strategy = db.migration; final migrator = Migrator(db); - // from == 2 → guard short-circuits, no SQL executed. await strategy.onUpgrade(migrator, 2, 3); + final columns = await db.customSelect("PRAGMA table_info('transactions')").get(); + expect(columns.map((r) => r.read('name')), contains('category')); + }); + + test('onUpgrade does nothing when starting at v3 or later', () async { + // The `if (from <` guards mean an upgrade from v3 → v4 (a future + // version) must NOT try to recreate the table or the column. We + // assert that by leaving both in place and running the callback, + // which would throw "already exists" if a guard regressed. + final strategy = db.migration; + final migrator = Migrator(db); + // from == 3 → guards short-circuit, no SQL executed. + await strategy.onUpgrade(migrator, 3, 4); + final rows = await db .customSelect( "SELECT name FROM sqlite_master WHERE type='table' AND name='dfx_transaction_details'", diff --git a/test/packages/storage/dfx_transaction_storage_test.dart b/test/packages/storage/dfx_transaction_storage_test.dart index db1bd5370..6fa9da91c 100644 --- a/test/packages/storage/dfx_transaction_storage_test.dart +++ b/test/packages/storage/dfx_transaction_storage_test.dart @@ -18,6 +18,7 @@ void main() { 0, '', '', + '', DateTime.utc(2025, 1, 1), ); diff --git a/test/packages/storage/transaction_storage_test.dart b/test/packages/storage/transaction_storage_test.dart index a0cba132b..283d41e5a 100644 --- a/test/packages/storage/transaction_storage_test.dart +++ b/test/packages/storage/transaction_storage_test.dart @@ -40,6 +40,7 @@ void main() { 0, '', '', + '', DateTime.utc(2025, 1, 1), ); // One row for a different asset → must be excluded. @@ -54,6 +55,7 @@ void main() { 0, '', '', + '', DateTime.utc(2025, 1, 1), ); @@ -99,6 +101,7 @@ void main() { type, '', '', + '', DateTime.utc(2025, 1, 1), ); @@ -110,6 +113,30 @@ void main() { expect(rows.map((r) => r.txId), ['tx-sender']); }); + test('persists the transfer category and defaults it to empty', () async { + await db.insertTransactions( + 1, + 'tx-cat', + chainId, + checksummed, + other, + '0xff', + assetId, + 2, + 'transferIn', + '', + '', + DateTime.utc(2025, 1, 1), + ); + + final row = await db.getTransaction('tx-cat'); + expect(row!.category, 'transferIn'); + + await insert('tx-nocat', checksummed, other, 2); + final plain = await db.getTransaction('tx-nocat'); + expect(plain!.category, ''); + }); + test('watchTransfersOfAssets matches a checksummed receiver', () async { await insert('tx-receiver', other, checksummed, 2); diff --git a/test/widgets/transaction_title_label_test.dart b/test/widgets/transaction_title_label_test.dart new file mode 100644 index 000000000..5592db143 --- /dev/null +++ b/test/widgets/transaction_title_label_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/models/asset.dart'; +import 'package:realunit_wallet/models/dfx_transaction.dart'; +import 'package:realunit_wallet/models/transaction.dart'; +import 'package:realunit_wallet/widgets/transaction_title_label.dart'; + +import '../helper/helper.dart'; + +const _wallet = '0x000000000000000000000000000000000000bEEF'; +const _other = '0x0000000000000000000000000000000000001234'; + +const _asset = Asset(chainId: 1, address: '0xToken', name: 'RealUnit', symbol: 'REALU', decimals: 0); + +Transaction _tx({TransferCategory? category, String sender = _other, String receiver = _wallet}) => Transaction( + height: 1, + txId: '0xabc', + chainId: 1, + senderAddress: sender, + receiverAddress: receiver, + amount: BigInt.from(20), + asset: _asset, + type: TransactionTypes.tokenTransfer, + category: category, + note: '', + data: null, + timestamp: DateTime.utc(2026, 9, 1), + ); + +DfxTransaction _dfxTx({ + TransferCategory? category, + String sender = _wallet, + String receiver = _other, +}) => + DfxTransaction( + dfxId: 42, + height: 1, + txId: '0xabc', + chainId: 1, + senderAddress: sender, + receiverAddress: receiver, + amount: BigInt.from(20), + asset: _asset, + type: TransactionTypes.tokenTransfer, + category: category, + note: '', + data: null, + timestamp: DateTime.utc(2026, 9, 1), + ); + +void main() { + Future<(S, String)> label(WidgetTester tester, Transaction tx, {required bool isOutbound}) async { + late S s; + late String result; + await tester.pumpApp(Builder( + builder: (context) { + s = S.of(context); + result = transactionTitleLabel(context, tx, isOutbound: isOutbound); + return const SizedBox.shrink(); + }, + )); + return (s, result); + } + + group('transactionTitleLabel', () { + testWidgets('labels a Brokerbot purchase as a buy', (tester) async { + final (sBuy, buy) = await label(tester, _tx(category: TransferCategory.purchase), isOutbound: false); + final (sSale, sale) = await label( + tester, + _tx(category: TransferCategory.sale, sender: _wallet, receiver: _other), + isOutbound: true, + ); + expect(buy, sBuy.transactionBuy); + expect(sale, sSale.transactionSell); + }); + + testWidgets('labels plain transfers as received/sent, not as buy/sell', (tester) async { + final (s, received) = await label(tester, _tx(category: TransferCategory.transferIn), isOutbound: false); + final (_, sent) = await label( + tester, + _tx(category: TransferCategory.transferOut, sender: _wallet, receiver: _other), + isOutbound: true, + ); + expect(received, s.transactionReceived); + expect(sent, s.transactionSent); + expect(received, isNot(s.transactionBuy)); + expect(sent, isNot(s.transactionSell)); + }); + + testWidgets('falls back to direction labels without a category (legacy rows, older API)', + (tester) async { + final (s, inbound) = await label(tester, _tx(), isOutbound: false); + expect(inbound, s.transactionBuy); + }); + + testWidgets('keeps direction labels for DFX-backed transactions regardless of category', + (tester) async { + final (s, outbound) = await label( + tester, + _dfxTx(category: TransferCategory.purchase), + isOutbound: true, + ); + expect(outbound, s.transactionSell); + }); + }); +}