From 269697254c7f3e80cacdf8268c8adb493d373795 Mon Sep 17 00:00:00 2001 From: Aleksandar Ilic Date: Wed, 17 Jan 2024 00:19:05 +0100 Subject: [PATCH] Implement transaction details screen --- README.md | 2 + .../15.json | 1172 +++++++++++++++++ .../core/compose/feed/note/FeedNoteCard.kt | 10 +- .../core/compose/feed/note/NoteHeader.kt | 25 +- .../core/compose/feed/note/NoteStatsRow.kt | 47 +- .../net/primal/android/db/PrimalDatabase.kt | 2 +- .../android/editor/NoteEditorViewModel.kt | 10 +- .../android/feed/repository/FeedRepository.kt | 34 +- .../android/navigation/PrimalAppNavigation.kt | 6 +- .../android/navigation/WalletNavigation.kt | 9 + .../android/nostr/ext/PrimalWalletEvents.kt | 6 + .../content/ContentWalletTransaction.kt | 5 + .../primal/android/thread/ThreadViewModel.kt | 14 +- .../wallet/dashboard/WalletDashboardScreen.kt | 4 +- .../dashboard/WalletDashboardViewModel.kt | 4 +- ...{WalletBalanceText.kt => BtcAmountText.kt} | 8 +- .../wallet/dashboard/ui/WalletDashboard.kt | 2 +- .../dashboard/ui/WalletDashboardLite.kt | 2 +- .../android/wallet/db/WalletTransactionDao.kt | 4 + .../wallet/db/WalletTransactionData.kt | 6 + .../wallet/repository/WalletRepository.kt | 2 + .../details/TransactionDetailDataUi.kt | 27 + .../details/TransactionDetailsContract.kt | 5 +- .../details/TransactionDetailsScreen.kt | 477 ++++++- .../details/TransactionDetailsViewModel.kt | 77 +- .../transactions/list/TransactionIcon.kt | 30 + .../transactions/list/TransactionListItem.kt | 20 - .../receive/ReceivePaymentScreen.kt | 6 +- .../send/create/CreateTransactionScreen.kt | 4 +- app/src/main/res/values/strings.xml | 25 +- 30 files changed, 1934 insertions(+), 111 deletions(-) create mode 100644 app/schemas/net.primal.android.db.PrimalDatabase/15.json rename app/src/main/kotlin/net/primal/android/wallet/dashboard/ui/{WalletBalanceText.kt => BtcAmountText.kt} (87%) create mode 100644 app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailDataUi.kt create mode 100644 app/src/main/kotlin/net/primal/android/wallet/transactions/list/TransactionIcon.kt diff --git a/README.md b/README.md index 1cdb47da9..0cb257b32 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ + +
diff --git a/app/schemas/net.primal.android.db.PrimalDatabase/15.json b/app/schemas/net.primal.android.db.PrimalDatabase/15.json new file mode 100644 index 000000000..dbbfc6611 --- /dev/null +++ b/app/schemas/net.primal.android.db.PrimalDatabase/15.json @@ -0,0 +1,1172 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "13dcdb83e37a441d942b1bc40bf9c1aa", + "entities": [ + { + "tableName": "PostData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`postId` TEXT NOT NULL, `authorId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `uris` TEXT NOT NULL, `hashtags` TEXT NOT NULL, `sig` TEXT NOT NULL, `raw` TEXT NOT NULL, `authorMetadataId` TEXT, PRIMARY KEY(`postId`))", + "fields": [ + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uris", + "columnName": "uris", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hashtags", + "columnName": "hashtags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "raw", + "columnName": "raw", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorMetadataId", + "columnName": "authorMetadataId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "postId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ProfileData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ownerId` TEXT NOT NULL, `eventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `raw` TEXT NOT NULL, `handle` TEXT, `displayName` TEXT, `internetIdentifier` TEXT, `lightningAddress` TEXT, `lnUrlDecoded` TEXT, `avatarCdnImage` TEXT, `bannerCdnImage` TEXT, `website` TEXT, `about` TEXT, PRIMARY KEY(`ownerId`))", + "fields": [ + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw", + "columnName": "raw", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "handle", + "columnName": "handle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "internetIdentifier", + "columnName": "internetIdentifier", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lightningAddress", + "columnName": "lightningAddress", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lnUrlDecoded", + "columnName": "lnUrlDecoded", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarCdnImage", + "columnName": "avatarCdnImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerCdnImage", + "columnName": "bannerCdnImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ownerId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RepostData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostId` TEXT NOT NULL, `authorId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `tags` TEXT NOT NULL, `postId` TEXT NOT NULL, `postAuthorId` TEXT NOT NULL, `sig` TEXT NOT NULL, PRIMARY KEY(`repostId`))", + "fields": [ + { + "fieldPath": "repostId", + "columnName": "repostId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postAuthorId", + "columnName": "postAuthorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repostId" + ] + }, + "indices": [ + { + "name": "index_RepostData_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RepostData_postId` ON `${TABLE_NAME}` (`postId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PostStats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`postId` TEXT NOT NULL, `likes` INTEGER NOT NULL, `replies` INTEGER NOT NULL, `mentions` INTEGER NOT NULL, `reposts` INTEGER NOT NULL, `zaps` INTEGER NOT NULL, `satsZapped` INTEGER NOT NULL, `score` INTEGER NOT NULL, `score24h` INTEGER NOT NULL, PRIMARY KEY(`postId`))", + "fields": [ + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "likes", + "columnName": "likes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replies", + "columnName": "replies", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reposts", + "columnName": "reposts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "zaps", + "columnName": "zaps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "satsZapped", + "columnName": "satsZapped", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "score24h", + "columnName": "score24h", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "postId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NoteNostrUri", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`postId` TEXT NOT NULL, `uri` TEXT NOT NULL, `refPost_postId` TEXT, `refPost_createdAt` INTEGER, `refPost_content` TEXT, `refPost_authorId` TEXT, `refPost_authorName` TEXT, `refPost_authorAvatarCdnImage` TEXT, `refPost_authorInternetIdentifier` TEXT, `refPost_authorLightningAddress` TEXT, `refPost_attachments` TEXT, `refPost_nostrUris` TEXT, `refUser_userId` TEXT, `refUser_handle` TEXT, PRIMARY KEY(`postId`, `uri`))", + "fields": [ + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referencedPost.postId", + "columnName": "refPost_postId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedPost.createdAt", + "columnName": "refPost_createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "referencedPost.content", + "columnName": "refPost_content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedPost.authorId", + "columnName": "refPost_authorId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedPost.authorName", + "columnName": "refPost_authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedPost.authorAvatarCdnImage", + "columnName": "refPost_authorAvatarCdnImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedPost.authorInternetIdentifier", + "columnName": "refPost_authorInternetIdentifier", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedPost.authorLightningAddress", + "columnName": "refPost_authorLightningAddress", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedPost.attachments", + "columnName": "refPost_attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedPost.nostrUris", + "columnName": "refPost_nostrUris", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedUser.userId", + "columnName": "refUser_userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedUser.handle", + "columnName": "refUser_handle", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "postId", + "uri" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NoteAttachment", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `mimeType` TEXT, `variants` TEXT, `title` TEXT, `description` TEXT, `thumbnail` TEXT, `authorAvatarUrl` TEXT, PRIMARY KEY(`eventId`, `url`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variants", + "columnName": "variants", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorAvatarUrl", + "columnName": "authorAvatarUrl", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "eventId", + "url" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`directive` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`directive`))", + "fields": [ + { + "fieldPath": "directive", + "columnName": "directive", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "directive" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeedPostDataCrossRef", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`feedDirective` TEXT NOT NULL, `eventId` TEXT NOT NULL, PRIMARY KEY(`feedDirective`, `eventId`))", + "fields": [ + { + "fieldPath": "feedDirective", + "columnName": "feedDirective", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "feedDirective", + "eventId" + ] + }, + "indices": [ + { + "name": "index_FeedPostDataCrossRef_feedDirective", + "unique": false, + "columnNames": [ + "feedDirective" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeedPostDataCrossRef_feedDirective` ON `${TABLE_NAME}` (`feedDirective`)" + }, + { + "name": "index_FeedPostDataCrossRef_eventId", + "unique": false, + "columnNames": [ + "eventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeedPostDataCrossRef_eventId` ON `${TABLE_NAME}` (`eventId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "FeedPostRemoteKey", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `directive` TEXT NOT NULL, `sinceId` INTEGER NOT NULL, `untilId` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, PRIMARY KEY(`eventId`, `directive`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directive", + "columnName": "directive", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sinceId", + "columnName": "sinceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "untilId", + "columnName": "untilId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "eventId", + "directive" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeedPostSync", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `feedDirective` TEXT NOT NULL, `count` INTEGER NOT NULL, `postIds` TEXT NOT NULL, PRIMARY KEY(`timestamp`, `feedDirective`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "feedDirective", + "columnName": "feedDirective", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postIds", + "columnName": "postIds", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp", + "feedDirective" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ThreadConversationCrossRef", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`postId` TEXT NOT NULL, `replyPostId` TEXT NOT NULL, PRIMARY KEY(`postId`, `replyPostId`))", + "fields": [ + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyPostId", + "columnName": "replyPostId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "postId", + "replyPostId" + ] + }, + "indices": [ + { + "name": "index_ThreadConversationCrossRef_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ThreadConversationCrossRef_postId` ON `${TABLE_NAME}` (`postId`)" + }, + { + "name": "index_ThreadConversationCrossRef_replyPostId", + "unique": false, + "columnNames": [ + "replyPostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ThreadConversationCrossRef_replyPostId` ON `${TABLE_NAME}` (`replyPostId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PostUserStats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`postId` TEXT NOT NULL, `userId` TEXT NOT NULL, `replied` INTEGER NOT NULL, `liked` INTEGER NOT NULL, `reposted` INTEGER NOT NULL, `zapped` INTEGER NOT NULL, PRIMARY KEY(`postId`, `userId`))", + "fields": [ + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replied", + "columnName": "replied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reposted", + "columnName": "reposted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "zapped", + "columnName": "zapped", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "postId", + "userId" + ] + }, + "indices": [ + { + "name": "index_PostUserStats_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PostUserStats_postId` ON `${TABLE_NAME}` (`postId`)" + }, + { + "name": "index_PostUserStats_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PostUserStats_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ProfileStats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `following` INTEGER NOT NULL, `followers` INTEGER NOT NULL, `notes` INTEGER NOT NULL, PRIMARY KEY(`profileId`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "following", + "columnName": "following", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "followers", + "columnName": "followers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TrendingHashtag", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hashtag` TEXT NOT NULL, `score` REAL NOT NULL, PRIMARY KEY(`hashtag`))", + "fields": [ + { + "fieldPath": "hashtag", + "columnName": "hashtag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "hashtag" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ownerId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `type` TEXT NOT NULL, `seenGloballyAt` INTEGER, `actionUserId` TEXT, `actionPostId` TEXT, `satsZapped` INTEGER, PRIMARY KEY(`ownerId`, `createdAt`, `type`))", + "fields": [ + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "seenGloballyAt", + "columnName": "seenGloballyAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "actionUserId", + "columnName": "actionUserId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "actionPostId", + "columnName": "actionPostId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "satsZapped", + "columnName": "satsZapped", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ownerId", + "createdAt", + "type" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MutedUserData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `userMetadataEventId` TEXT, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userMetadataEventId", + "columnName": "userMetadataEventId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DirectMessageData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `senderId` TEXT NOT NULL, `receiverId` TEXT NOT NULL, `participantId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `content` TEXT NOT NULL, `uris` TEXT NOT NULL, `hashtags` TEXT NOT NULL, PRIMARY KEY(`messageId`))", + "fields": [ + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderId", + "columnName": "senderId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverId", + "columnName": "receiverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantId", + "columnName": "participantId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uris", + "columnName": "uris", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hashtags", + "columnName": "hashtags", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MessageConversationData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`participantId` TEXT NOT NULL, `lastMessageId` TEXT NOT NULL, `lastMessageAt` INTEGER NOT NULL, `unreadMessagesCount` INTEGER NOT NULL, `relation` TEXT NOT NULL, `participantMetadataId` TEXT, PRIMARY KEY(`participantId`))", + "fields": [ + { + "fieldPath": "participantId", + "columnName": "participantId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "lastMessageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastMessageAt", + "columnName": "lastMessageAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMessagesCount", + "columnName": "unreadMessagesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relation", + "columnName": "relation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantMetadataId", + "columnName": "participantMetadataId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "participantId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "WalletTransactionData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `walletLightningAddress` TEXT NOT NULL, `type` TEXT NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `completedAt` INTEGER, `amountInBtc` TEXT NOT NULL, `amountInUsd` TEXT, `isZap` INTEGER NOT NULL, `isStorePurchase` INTEGER NOT NULL, `userId` TEXT NOT NULL, `userSubWallet` TEXT NOT NULL, `userLightningAddress` TEXT, `otherUserId` TEXT, `otherLightningAddress` TEXT, `note` TEXT, `invoice` TEXT, `totalFeeInBtc` TEXT, `exchangeRate` TEXT, `onChainAddress` TEXT, `zapNoteId` TEXT, `zapNoteAuthorId` TEXT, `zappedByUserId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletLightningAddress", + "columnName": "walletLightningAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "amountInBtc", + "columnName": "amountInBtc", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountInUsd", + "columnName": "amountInUsd", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isZap", + "columnName": "isZap", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStorePurchase", + "columnName": "isStorePurchase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userSubWallet", + "columnName": "userSubWallet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userLightningAddress", + "columnName": "userLightningAddress", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "otherUserId", + "columnName": "otherUserId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "otherLightningAddress", + "columnName": "otherLightningAddress", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "invoice", + "columnName": "invoice", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "totalFeeInBtc", + "columnName": "totalFeeInBtc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "exchangeRate", + "columnName": "exchangeRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "onChainAddress", + "columnName": "onChainAddress", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "zapNoteId", + "columnName": "zapNoteId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "zapNoteAuthorId", + "columnName": "zapNoteAuthorId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "zappedByUserId", + "columnName": "zappedByUserId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '13dcdb83e37a441d942b1bc40bf9c1aa')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/net/primal/android/core/compose/feed/note/FeedNoteCard.kt b/app/src/main/kotlin/net/primal/android/core/compose/feed/note/FeedNoteCard.kt index a2b371893..1ceb2b974 100644 --- a/app/src/main/kotlin/net/primal/android/core/compose/feed/note/FeedNoteCard.kt +++ b/app/src/main/kotlin/net/primal/android/core/compose/feed/note/FeedNoteCard.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.CardColors import androidx.compose.material3.CardDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -44,7 +45,9 @@ import net.primal.android.theme.domain.PrimalTheme @Composable fun FeedNoteCard( data: FeedPostUi, + modifier: Modifier = Modifier, shape: Shape = CardDefaults.shape, + colors: CardColors = CardDefaults.cardColors(), cardPadding: PaddingValues = PaddingValues(all = 0.dp), fullWidthNote: Boolean = false, headerSingleLine: Boolean = true, @@ -55,8 +58,8 @@ fun FeedNoteCard( expanded: Boolean = false, onPostClick: (String) -> Unit, onProfileClick: (String) -> Unit, - onPostAction: (FeedPostAction) -> Unit, - onPostLongClickAction: (FeedPostAction) -> Unit, + onPostAction: ((FeedPostAction) -> Unit)? = null, + onPostLongClickAction: ((FeedPostAction) -> Unit)? = null, onHashtagClick: (String) -> Unit, onMediaClick: (String, String) -> Unit, onMuteUserClick: () -> Unit, @@ -71,7 +74,7 @@ fun FeedNoteCard( val overflowIconSizeDp = 40.dp NoteSurfaceCard( - modifier = Modifier + modifier = modifier .wrapContentHeight() .padding(cardPadding) .clickable( @@ -80,6 +83,7 @@ fun FeedNoteCard( onClick = { onPostClick(data.postId) }, ), shape = shape, + colors = colors, drawLineAboveAvatar = drawLineAboveAvatar, drawLineBelowAvatar = drawLineBelowAvatar, lineOffsetX = (avatarSizeDp / 2) + avatarPaddingDp + notePaddingDp, diff --git a/app/src/main/kotlin/net/primal/android/core/compose/feed/note/NoteHeader.kt b/app/src/main/kotlin/net/primal/android/core/compose/feed/note/NoteHeader.kt index 03e756794..a69796f94 100644 --- a/app/src/main/kotlin/net/primal/android/core/compose/feed/note/NoteHeader.kt +++ b/app/src/main/kotlin/net/primal/android/core/compose/feed/note/NoteHeader.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -40,6 +41,8 @@ fun FeedNoteHeader( authorAvatarVisible: Boolean = true, authorAvatarCdnImage: CdnImage? = null, authorInternetIdentifier: String? = null, + label: String? = authorInternetIdentifier, + labelStyle: TextStyle? = null, onAuthorAvatarClick: (() -> Unit)? = null, ) { Row( @@ -84,22 +87,24 @@ fun FeedNoteHeader( ) }, suffixFixedContent = { - Text( - text = " • ${postTimestamp?.asBeforeNowFormat().orEmpty()}", - textAlign = TextAlign.Center, - maxLines = 1, - style = AppTheme.typography.bodySmall, - color = AppTheme.extraColorScheme.onSurfaceVariantAlt2, - ) + if (postTimestamp != null) { + Text( + text = " • ${postTimestamp.asBeforeNowFormat()}", + textAlign = TextAlign.Center, + maxLines = 1, + style = AppTheme.typography.bodySmall, + color = AppTheme.extraColorScheme.onSurfaceVariantAlt2, + ) + } }, ) - if (!authorInternetIdentifier.isNullOrEmpty() && !singleLine) { + if (!label.isNullOrEmpty() && !singleLine) { Text( - text = authorInternetIdentifier, + text = label, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = AppTheme.typography.bodySmall, + style = labelStyle ?: AppTheme.typography.bodySmall, color = AppTheme.extraColorScheme.onSurfaceVariantAlt2, ) } diff --git a/app/src/main/kotlin/net/primal/android/core/compose/feed/note/NoteStatsRow.kt b/app/src/main/kotlin/net/primal/android/core/compose/feed/note/NoteStatsRow.kt index c3ebdc39c..03acc0c64 100644 --- a/app/src/main/kotlin/net/primal/android/core/compose/feed/note/NoteStatsRow.kt +++ b/app/src/main/kotlin/net/primal/android/core/compose/feed/note/NoteStatsRow.kt @@ -41,8 +41,8 @@ import net.primal.android.theme.AppTheme fun FeedNoteStatsRow( modifier: Modifier, postStats: FeedPostStatsUi, - onPostAction: (FeedPostAction) -> Unit, - onPostLongPressAction: (FeedPostAction) -> Unit, + onPostAction: ((FeedPostAction) -> Unit)? = null, + onPostLongPressAction: ((FeedPostAction) -> Unit)? = null, ) { Row( modifier = modifier, @@ -54,8 +54,12 @@ fun FeedNoteStatsRow( iconVector = PrimalIcons.FeedReplies, iconVectorHighlight = PrimalIcons.FeedRepliesFilled, colorHighlight = AppTheme.extraColorScheme.replied, - onClick = { onPostAction(FeedPostAction.Reply) }, - onLongClick = { onPostLongPressAction(FeedPostAction.Reply) }, + onClick = onPostAction?.let { + { onPostAction(FeedPostAction.Reply) } + }, + onLongClick = onPostLongPressAction?.let { + { onPostLongPressAction(FeedPostAction.Reply) } + }, ) SinglePostStat( @@ -64,8 +68,12 @@ fun FeedNoteStatsRow( iconVector = PrimalIcons.FeedZaps, iconVectorHighlight = PrimalIcons.FeedZapsFilled, colorHighlight = AppTheme.extraColorScheme.zapped, - onClick = { onPostAction(FeedPostAction.Zap) }, - onLongClick = { onPostLongPressAction(FeedPostAction.Zap) }, + onClick = onPostAction?.let { + { onPostAction(FeedPostAction.Zap) } + }, + onLongClick = onPostLongPressAction?.let { + { onPostLongPressAction(FeedPostAction.Zap) } + }, ) SinglePostStat( @@ -74,12 +82,14 @@ fun FeedNoteStatsRow( iconVector = PrimalIcons.FeedLikes, iconVectorHighlight = PrimalIcons.FeedLikesFilled, colorHighlight = AppTheme.extraColorScheme.liked, - onClick = { - if (!postStats.userLiked) { - onPostAction(FeedPostAction.Like) - } + onClick = if (!postStats.userLiked && onPostAction != null) { + { onPostAction(FeedPostAction.Like) } + } else { + null + }, + onLongClick = onPostLongPressAction?.let { + { onPostLongPressAction(FeedPostAction.Like) } }, - onLongClick = { onPostLongPressAction(FeedPostAction.Like) }, ) SinglePostStat( @@ -88,8 +98,12 @@ fun FeedNoteStatsRow( iconVector = PrimalIcons.FeedReposts, iconVectorHighlight = PrimalIcons.FeedRepostsFilled, colorHighlight = AppTheme.extraColorScheme.reposted, - onClick = { onPostAction(FeedPostAction.Repost) }, - onLongClick = { onPostLongPressAction(FeedPostAction.Repost) }, + onClick = onPostAction?.let { + { onPostAction(FeedPostAction.Repost) } + }, + onLongClick = onPostLongPressAction?.let { + { onPostLongPressAction(FeedPostAction.Repost) } + }, ) } } @@ -102,8 +116,8 @@ private fun SinglePostStat( iconVector: ImageVector, iconVectorHighlight: ImageVector, colorHighlight: Color, - onClick: () -> Unit, - onLongClick: () -> Unit, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, ) { val titleText = buildAnnotatedString { appendInlineContent("icon", "[icon]") @@ -139,7 +153,8 @@ private fun SinglePostStat( .clip(CircleShape) .animateContentSize() .combinedClickable( - onClick = onClick, + enabled = onClick != null || onLongClick != null, + onClick = { onClick?.invoke() }, onLongClick = onLongClick, ), text = titleText, diff --git a/app/src/main/kotlin/net/primal/android/db/PrimalDatabase.kt b/app/src/main/kotlin/net/primal/android/db/PrimalDatabase.kt index e117fd22a..c86cd17fd 100644 --- a/app/src/main/kotlin/net/primal/android/db/PrimalDatabase.kt +++ b/app/src/main/kotlin/net/primal/android/db/PrimalDatabase.kt @@ -69,7 +69,7 @@ import net.primal.android.wallet.db.WalletTransactionData MessageConversationData::class, WalletTransactionData::class, ], - version = 14, + version = 15, exportSchema = true, ) @TypeConverters( diff --git a/app/src/main/kotlin/net/primal/android/editor/NoteEditorViewModel.kt b/app/src/main/kotlin/net/primal/android/editor/NoteEditorViewModel.kt index 2a9756832..6b340cdcd 100644 --- a/app/src/main/kotlin/net/primal/android/editor/NoteEditorViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/editor/NoteEditorViewModel.kt @@ -19,7 +19,9 @@ import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.primal.android.core.compose.feed.model.asFeedPostUi +import net.primal.android.core.coroutines.CoroutineDispatcherProvider import net.primal.android.core.files.FileAnalyser import net.primal.android.core.files.error.UnsuccessfulFileUpload import net.primal.android.editor.NoteEditorContract.SideEffect @@ -36,10 +38,12 @@ import net.primal.android.networking.relays.errors.NostrPublishException import net.primal.android.networking.sockets.errors.WssException import net.primal.android.user.accounts.active.ActiveAccountStore import net.primal.android.user.accounts.active.ActiveUserAccountState +import timber.log.Timber @HiltViewModel class NoteEditorViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val dispatcherProvider: CoroutineDispatcherProvider, private val fileAnalyser: FileAnalyser, private val activeAccountStore: ActiveAccountStore, private val feedRepository: FeedRepository, @@ -119,9 +123,11 @@ class NoteEditorViewModel @Inject constructor( private fun fetchRepliesFromNetwork(replyToNoteId: String) = viewModelScope.launch { try { - feedRepository.fetchReplies(postId = replyToNoteId) + withContext(dispatcherProvider.io()) { + feedRepository.fetchReplies(postId = replyToNoteId) + } } catch (error: WssException) { - // Ignore + Timber.e(error) } } diff --git a/app/src/main/kotlin/net/primal/android/feed/repository/FeedRepository.kt b/app/src/main/kotlin/net/primal/android/feed/repository/FeedRepository.kt index 0b67da289..097e6f0e6 100644 --- a/app/src/main/kotlin/net/primal/android/feed/repository/FeedRepository.kt +++ b/app/src/main/kotlin/net/primal/android/feed/repository/FeedRepository.kt @@ -6,10 +6,8 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.PagingSource import javax.inject.Inject -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.withContext import net.primal.android.core.ext.isLatestFeed import net.primal.android.db.PrimalDatabase import net.primal.android.feed.api.FeedApi @@ -63,23 +61,22 @@ class FeedRepository @Inject constructor( userId = activeAccountStore.activeUserId(), ) - suspend fun fetchReplies(postId: String) = - withContext(Dispatchers.IO) { - val userId = activeAccountStore.activeUserId() - val response = feedApi.getThread( - ThreadRequestBody(postId = postId, userPubKey = userId, limit = 100), - ) + suspend fun fetchReplies(postId: String) { + val userId = activeAccountStore.activeUserId() + val response = feedApi.getThread( + ThreadRequestBody(postId = postId, userPubKey = userId, limit = 100), + ) - response.persistToDatabaseAsTransaction(userId = userId, database = database) - database.conversationConnections().connect( - data = response.posts.map { - ThreadConversationCrossRef( - postId = postId, - replyPostId = it.id, - ) - }, - ) - } + response.persistToDatabaseAsTransaction(userId = userId, database = database) + database.conversationConnections().connect( + data = response.posts.map { + ThreadConversationCrossRef( + postId = postId, + replyPostId = it.id, + ) + }, + ) + } @OptIn(ExperimentalPagingApi::class) private fun createPager(feedDirective: String, pagingSourceFactory: () -> PagingSource) = @@ -105,6 +102,7 @@ class FeedRepository @Inject constructor( feedDirective = feedDirective, userPubkey = activeAccountStore.activeUserId(), ) + else -> ExploreFeedQueryBuilder( feedDirective = feedDirective, userPubkey = activeAccountStore.activeUserId(), diff --git a/app/src/main/kotlin/net/primal/android/navigation/PrimalAppNavigation.kt b/app/src/main/kotlin/net/primal/android/navigation/PrimalAppNavigation.kt index c008a066f..a464f0827 100644 --- a/app/src/main/kotlin/net/primal/android/navigation/PrimalAppNavigation.kt +++ b/app/src/main/kotlin/net/primal/android/navigation/PrimalAppNavigation.kt @@ -162,12 +162,12 @@ private fun NavController.navigateToWalletSettings(nwcUrl: String? = null) = else -> navigate(route = "wallet_settings") } -private fun NavController.navigateToThread(noteId: String) = navigate(route = "thread/$noteId") +fun NavController.navigateToThread(noteId: String) = navigate(route = "thread/$noteId") -private fun NavController.navigateToMediaGallery(noteId: String, mediaUrl: String) = +fun NavController.navigateToMediaGallery(noteId: String, mediaUrl: String) = navigate(route = "media/$noteId?$MEDIA_URL=$mediaUrl") -private fun NavController.navigateToExploreFeed(query: String) = navigate(route = "explore/$query") +fun NavController.navigateToExploreFeed(query: String) = navigate(route = "explore/$query") @OptIn(ExperimentalMaterialNavigationApi::class) @Composable diff --git a/app/src/main/kotlin/net/primal/android/navigation/WalletNavigation.kt b/app/src/main/kotlin/net/primal/android/navigation/WalletNavigation.kt index b7740fe6e..8d38f991e 100644 --- a/app/src/main/kotlin/net/primal/android/navigation/WalletNavigation.kt +++ b/app/src/main/kotlin/net/primal/android/navigation/WalletNavigation.kt @@ -189,5 +189,14 @@ private fun NavGraphBuilder.transactionDetails( TransactionDetailsScreen( viewModel = viewModel, onClose = { navController.navigateUp() }, + onPostClick = { noteId -> navController.navigateToThread(noteId) }, + onProfileClick = { profileId -> navController.navigateToProfile(profileId) }, + onHashtagClick = { hashtag -> navController.navigateToExploreFeed(query = hashtag) }, + onMediaClick = { noteId, mediaUrl -> + navController.navigateToMediaGallery( + noteId = noteId, + mediaUrl = mediaUrl, + ) + }, ) } diff --git a/app/src/main/kotlin/net/primal/android/nostr/ext/PrimalWalletEvents.kt b/app/src/main/kotlin/net/primal/android/nostr/ext/PrimalWalletEvents.kt index eca4e86bc..19479a263 100644 --- a/app/src/main/kotlin/net/primal/android/nostr/ext/PrimalWalletEvents.kt +++ b/app/src/main/kotlin/net/primal/android/nostr/ext/PrimalWalletEvents.kt @@ -24,8 +24,10 @@ fun ContentWalletTransaction.asWalletTransactionPO(walletAddress: String): Walle type = this.type, state = this.state, createdAt = this.createdAt, + updatedAt = this.updatedAt, completedAt = this.completedAt, amountInBtc = this.amountInBtc, + amountInUsd = this.amountInUsd, userId = this.selfPubkey, userSubWallet = this.selfSubWallet, userLightningAddress = this.selfLud16, @@ -37,5 +39,9 @@ fun ContentWalletTransaction.asWalletTransactionPO(walletAddress: String): Walle zapNoteId = zapEvent?.tags?.findFirstEventId(), zapNoteAuthorId = zapEvent?.tags?.findFirstProfileId(), zappedByUserId = zapEvent?.pubKey, + invoice = this.invoice, + totalFeeInBtc = this.totalFeeInBtc, + exchangeRate = this.exchangeRate, + onChainAddress = this.onChainAddress, ) } diff --git a/app/src/main/kotlin/net/primal/android/nostr/model/primal/content/ContentWalletTransaction.kt b/app/src/main/kotlin/net/primal/android/nostr/model/primal/content/ContentWalletTransaction.kt index 0f42aed99..e109471de 100644 --- a/app/src/main/kotlin/net/primal/android/nostr/model/primal/content/ContentWalletTransaction.kt +++ b/app/src/main/kotlin/net/primal/android/nostr/model/primal/content/ContentWalletTransaction.kt @@ -12,6 +12,7 @@ data class ContentWalletTransaction( val type: TxType, val state: TxState, @SerialName("created_at") val createdAt: Long, + @SerialName("updated_at") val updatedAt: Long, @SerialName("completed_at") val completedAt: Long?, @SerialName("amount_btc") val amountInBtc: String, @SerialName("amount_usd") val amountInUsd: String?, @@ -25,4 +26,8 @@ data class ContentWalletTransaction( @SerialName("is_zap") val isZap: Boolean, @SerialName("zap_request") val zapRequestRawJson: String?, @SerialName("is_in_app_purchase") val isInAppPurchase: Boolean, + val invoice: String?, + @SerialName("total_fee_btc") val totalFeeInBtc: String?, + @SerialName("exchange_rate") val exchangeRate: String?, + @SerialName("onchainAddress") val onChainAddress: String?, ) diff --git a/app/src/main/kotlin/net/primal/android/thread/ThreadViewModel.kt b/app/src/main/kotlin/net/primal/android/thread/ThreadViewModel.kt index 6b3af1e02..c889cc8ab 100644 --- a/app/src/main/kotlin/net/primal/android/thread/ThreadViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/thread/ThreadViewModel.kt @@ -7,7 +7,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -19,6 +18,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.primal.android.core.compose.feed.model.asFeedPostUi +import net.primal.android.core.coroutines.CoroutineDispatcherProvider import net.primal.android.feed.repository.FeedRepository import net.primal.android.feed.repository.PostRepository import net.primal.android.navigation.noteIdOrThrow @@ -36,10 +36,12 @@ import net.primal.android.wallet.zaps.InvalidZapRequestException import net.primal.android.wallet.zaps.ZapFailureException import net.primal.android.wallet.zaps.ZapHandler import net.primal.android.wallet.zaps.hasWallet +import timber.log.Timber @HiltViewModel class ThreadViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val dispatcherProvider: CoroutineDispatcherProvider, private val activeAccountStore: ActiveAccountStore, private val feedRepository: FeedRepository, private val postRepository: PostRepository, @@ -110,7 +112,7 @@ class ThreadViewModel @Inject constructor( } private suspend fun loadHighlightedPost() { - val rootPost = withContext(Dispatchers.IO) { feedRepository.findPostById(postId = postId) } + val rootPost = withContext(dispatcherProvider.io()) { feedRepository.findPostById(postId = postId) } if (rootPost != null) { setState { copy( @@ -144,9 +146,11 @@ class ThreadViewModel @Inject constructor( private fun fetchRepliesFromNetwork() = viewModelScope.launch { try { - feedRepository.fetchReplies(postId = postId) + withContext(dispatcherProvider.io()) { + feedRepository.fetchReplies(postId = postId) + } } catch (error: WssException) { - // Ignore + Timber.e(error) } } @@ -181,7 +185,7 @@ class ThreadViewModel @Inject constructor( private fun zapPost(zapAction: UiEvent.ZapAction) = viewModelScope.launch { - val postAuthorProfileData = withContext(Dispatchers.IO) { + val postAuthorProfileData = withContext(dispatcherProvider.io()) { profileRepository.findProfileData(profileId = zapAction.postAuthorId) } diff --git a/app/src/main/kotlin/net/primal/android/wallet/dashboard/WalletDashboardScreen.kt b/app/src/main/kotlin/net/primal/android/wallet/dashboard/WalletDashboardScreen.kt index 06e2457b4..6fa896527 100644 --- a/app/src/main/kotlin/net/primal/android/wallet/dashboard/WalletDashboardScreen.kt +++ b/app/src/main/kotlin/net/primal/android/wallet/dashboard/WalletDashboardScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -153,7 +154,8 @@ fun WalletDashboardScreen( } } - val dashboardExpanded by remember(listState) { derivedStateOf { listState.firstVisibleItemScrollOffset == 0 } } + val isScrolledToTop by remember(listState) { derivedStateOf { listState.firstVisibleItemScrollOffset == 0 } } + val dashboardExpanded by rememberSaveable(isScrolledToTop) { mutableStateOf(isScrolledToTop) } var topBarHeight by remember { mutableIntStateOf(0) } diff --git a/app/src/main/kotlin/net/primal/android/wallet/dashboard/WalletDashboardViewModel.kt b/app/src/main/kotlin/net/primal/android/wallet/dashboard/WalletDashboardViewModel.kt index d8a8e0884..38cd9ea79 100644 --- a/app/src/main/kotlin/net/primal/android/wallet/dashboard/WalletDashboardViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/wallet/dashboard/WalletDashboardViewModel.kt @@ -123,9 +123,9 @@ class WalletDashboardViewModel @Inject constructor( } private fun Flow>.mapAsPagingDataOfTransactionUi() = - map { pagingData -> pagingData.map { it.mapAsTransactionUi() } } + map { pagingData -> pagingData.map { it.mapAsTransactionDataUi() } } - private fun WalletTransaction.mapAsTransactionUi() = + private fun WalletTransaction.mapAsTransactionDataUi() = TransactionDataUi( txId = this.data.id, txType = this.data.type, diff --git a/app/src/main/kotlin/net/primal/android/wallet/dashboard/ui/WalletBalanceText.kt b/app/src/main/kotlin/net/primal/android/wallet/dashboard/ui/BtcAmountText.kt similarity index 87% rename from app/src/main/kotlin/net/primal/android/wallet/dashboard/ui/WalletBalanceText.kt rename to app/src/main/kotlin/net/primal/android/wallet/dashboard/ui/BtcAmountText.kt index 8baae1460..cfad2f8a0 100644 --- a/app/src/main/kotlin/net/primal/android/wallet/dashboard/ui/WalletBalanceText.kt +++ b/app/src/main/kotlin/net/primal/android/wallet/dashboard/ui/BtcAmountText.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.TextUnit @@ -20,10 +21,12 @@ import net.primal.android.theme.AppTheme import net.primal.android.wallet.utils.CurrencyConversionUtils.toSats @Composable -fun AmountText( +fun BtcAmountText( modifier: Modifier, amountInBtc: BigDecimal?, textSize: TextUnit = 42.sp, + amountColor: Color = Color.Unspecified, + currencyColor: Color = AppTheme.extraColorScheme.onSurfaceVariantAlt3, ) { val numberFormat = remember { NumberFormat.getNumberInstance() } @@ -37,6 +40,7 @@ fun AmountText( textAlign = TextAlign.Center, style = AppTheme.typography.displayMedium, fontSize = textSize, + color = amountColor, ) if (amountInBtc != null) { @@ -46,7 +50,7 @@ fun AmountText( textAlign = TextAlign.Center, maxLines = 1, style = AppTheme.typography.bodyMedium, - color = AppTheme.extraColorScheme.onSurfaceVariantAlt3, + color = currencyColor, ) } } diff --git a/app/src/main/kotlin/net/primal/android/wallet/dashboard/ui/WalletDashboard.kt b/app/src/main/kotlin/net/primal/android/wallet/dashboard/ui/WalletDashboard.kt index f2310cc4c..ded2b56cb 100644 --- a/app/src/main/kotlin/net/primal/android/wallet/dashboard/ui/WalletDashboard.kt +++ b/app/src/main/kotlin/net/primal/android/wallet/dashboard/ui/WalletDashboard.kt @@ -25,7 +25,7 @@ fun WalletDashboard( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - AmountText( + BtcAmountText( modifier = Modifier.wrapContentWidth() .padding(start = if (walletBalance != null) 32.dp else 0.dp) .padding(bottom = 32.dp), diff --git a/app/src/main/kotlin/net/primal/android/wallet/dashboard/ui/WalletDashboardLite.kt b/app/src/main/kotlin/net/primal/android/wallet/dashboard/ui/WalletDashboardLite.kt index b0dc77080..ab9461c9d 100644 --- a/app/src/main/kotlin/net/primal/android/wallet/dashboard/ui/WalletDashboardLite.kt +++ b/app/src/main/kotlin/net/primal/android/wallet/dashboard/ui/WalletDashboardLite.kt @@ -22,7 +22,7 @@ fun WalletDashboardLite( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom, ) { - AmountText( + BtcAmountText( modifier = Modifier.graphicsLayer { clip = false translationY = 4.dp.toPx() diff --git a/app/src/main/kotlin/net/primal/android/wallet/db/WalletTransactionDao.kt b/app/src/main/kotlin/net/primal/android/wallet/db/WalletTransactionDao.kt index 3e57f7161..bc317e0ef 100644 --- a/app/src/main/kotlin/net/primal/android/wallet/db/WalletTransactionDao.kt +++ b/app/src/main/kotlin/net/primal/android/wallet/db/WalletTransactionDao.kt @@ -22,4 +22,8 @@ interface WalletTransactionDao { @Query("SELECT * FROM WalletTransactionData ORDER BY createdAt ASC LIMIT 1") fun last(): WalletTransactionData? + + @Transaction + @Query("SELECT * FROM WalletTransactionData WHERE id IS :txId") + fun findTransactionById(txId: String): WalletTransaction? } diff --git a/app/src/main/kotlin/net/primal/android/wallet/db/WalletTransactionData.kt b/app/src/main/kotlin/net/primal/android/wallet/db/WalletTransactionData.kt index 904236f86..4f60d1bc5 100644 --- a/app/src/main/kotlin/net/primal/android/wallet/db/WalletTransactionData.kt +++ b/app/src/main/kotlin/net/primal/android/wallet/db/WalletTransactionData.kt @@ -14,8 +14,10 @@ data class WalletTransactionData( val type: TxType, val state: TxState, val createdAt: Long, + val updatedAt: Long, val completedAt: Long?, val amountInBtc: String, + val amountInUsd: String?, val isZap: Boolean, val isStorePurchase: Boolean, val userId: String, @@ -24,6 +26,10 @@ data class WalletTransactionData( val otherUserId: String?, val otherLightningAddress: String?, val note: String?, + val invoice: String?, + val totalFeeInBtc: String?, + val exchangeRate: String?, + val onChainAddress: String?, val zapNoteId: String?, val zapNoteAuthorId: String?, val zappedByUserId: String?, diff --git a/app/src/main/kotlin/net/primal/android/wallet/repository/WalletRepository.kt b/app/src/main/kotlin/net/primal/android/wallet/repository/WalletRepository.kt index 26c3f3daf..987346055 100644 --- a/app/src/main/kotlin/net/primal/android/wallet/repository/WalletRepository.kt +++ b/app/src/main/kotlin/net/primal/android/wallet/repository/WalletRepository.kt @@ -37,6 +37,8 @@ class WalletRepository @Inject constructor( database.walletTransactions().latestTransactionsPaged() }.flow + fun findTransactionById(txId: String) = database.walletTransactions().findTransactionById(txId = txId) + suspend fun fetchUserWalletInfoAndUpdateUserAccount(userId: String) { withContext(dispatcherProvider.io()) { val response = walletApi.getWalletUserInfo(userId) diff --git a/app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailDataUi.kt b/app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailDataUi.kt new file mode 100644 index 000000000..273980760 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailDataUi.kt @@ -0,0 +1,27 @@ +package net.primal.android.wallet.transactions.details + +import java.time.Instant +import net.primal.android.attachments.domain.CdnImage +import net.primal.android.wallet.domain.TxState +import net.primal.android.wallet.domain.TxType + +data class TransactionDetailDataUi( + val txId: String, + val txType: TxType, + val txState: TxState, + val txInstant: Instant, + val txAmountInSats: ULong, + val txAmountInUsd: String?, + val txNote: String?, + val isZap: Boolean, + val isStorePurchase: Boolean, + val invoice: String?, + val totalFeeInSats: ULong?, + val exchangeRate: String?, + val onChainAddress: String?, + val otherUserId: String? = null, + val otherUserAvatarCdnImage: CdnImage? = null, + val otherUserInternetIdentifier: String? = null, + val otherUserDisplayName: String? = null, + val otherUserLightningAddress: String? = null, +) diff --git a/app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailsContract.kt b/app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailsContract.kt index 235dd6f64..0bf583416 100644 --- a/app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailsContract.kt +++ b/app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailsContract.kt @@ -1,8 +1,11 @@ package net.primal.android.wallet.transactions.details +import net.primal.android.core.compose.feed.model.FeedPostUi + interface TransactionDetailsContract { data class UiState( val loading: Boolean = false, + val txData: TransactionDetailDataUi? = null, + val feedPost: FeedPostUi? = null, ) - sealed class UiEvent } diff --git a/app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailsScreen.kt b/app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailsScreen.kt index bf94ff26f..593ba75a7 100644 --- a/app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailsScreen.kt +++ b/app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailsScreen.kt @@ -1,75 +1,530 @@ package net.primal.android.wallet.transactions.details +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring.StiffnessMediumLow +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.text.NumberFormat +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.* +import net.primal.android.LocalPrimalTheme +import net.primal.android.R +import net.primal.android.core.compose.IconText +import net.primal.android.core.compose.PrimalDivider import net.primal.android.core.compose.PrimalTopAppBar +import net.primal.android.core.compose.feed.note.FeedNoteCard +import net.primal.android.core.compose.feed.note.FeedNoteHeader import net.primal.android.core.compose.icons.PrimalIcons import net.primal.android.core.compose.icons.primaliconpack.ArrowBack +import net.primal.android.core.compose.icons.primaliconpack.Copy +import net.primal.android.core.compose.icons.primaliconpack.WalletLnPayment +import net.primal.android.core.utils.ellipsizeMiddle +import net.primal.android.theme.AppTheme import net.primal.android.theme.PrimalTheme +import net.primal.android.wallet.dashboard.ui.BtcAmountText +import net.primal.android.wallet.domain.TxState +import net.primal.android.wallet.domain.TxType import net.primal.android.wallet.transactions.details.TransactionDetailsContract.UiState -import net.primal.android.wallet.transactions.list.TransactionDataUi +import net.primal.android.wallet.transactions.list.TransactionIcon +import net.primal.android.wallet.utils.CurrencyConversionUtils.toBtc +import net.primal.android.wallet.walletDepositColor +import net.primal.android.wallet.walletWithdrawColor +import timber.log.Timber @Composable -fun TransactionDetailsScreen(viewModel: TransactionDetailsViewModel, onClose: () -> Unit) { +fun TransactionDetailsScreen( + viewModel: TransactionDetailsViewModel, + onClose: () -> Unit, + onPostClick: (String) -> Unit, + onProfileClick: (String) -> Unit, + onHashtagClick: (String) -> Unit, + onMediaClick: (String, String) -> Unit, +) { val uiState = viewModel.state.collectAsState() TransactionDetailsScreen( state = uiState.value, onClose = onClose, + onPostClick = onPostClick, + onProfileClick = onProfileClick, + onHashtagClick = onHashtagClick, + onMediaClick = onMediaClick, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TransactionDetailsScreen(state: UiState, onClose: () -> Unit) { +fun TransactionDetailsScreen( + state: UiState, + onClose: () -> Unit, + onPostClick: (String) -> Unit, + onProfileClick: (String) -> Unit, + onHashtagClick: (String) -> Unit, + onMediaClick: (String, String) -> Unit, +) { val scrollState = rememberScrollState() + val showTopBarDivider by remember { derivedStateOf { scrollState.value > 0 } } Scaffold( topBar = { PrimalTopAppBar( - title = "Calculating...", + title = state.txData.resolveTitle(), navigationIcon = PrimalIcons.ArrowBack, - showDivider = false, + showDivider = showTopBarDivider, onNavigationIconClick = onClose, ) }, content = { paddingValues -> Column( modifier = Modifier + .fillMaxSize() .padding(paddingValues) - .verticalScroll(state = scrollState), + .verticalScroll(state = scrollState) + .navigationBarsPadding(), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, ) { + state.txData?.let { txData -> + val color = when (state.txData.txType) { + TxType.DEPOSIT -> walletDepositColor + TxType.WITHDRAW -> walletWithdrawColor + } + + BtcAmountText( + modifier = Modifier + .wrapContentWidth() + .padding(vertical = 32.dp) + .padding(start = 32.dp), + amountInBtc = txData.txAmountInSats.toBtc().toBigDecimal(), + textSize = 48.sp, + amountColor = color, + currencyColor = color, + ) + + val text = when (txData.txType) { + TxType.DEPOSIT -> stringResource(id = R.string.wallet_transaction_details_received_from) + TxType.WITHDRAW -> stringResource(id = R.string.wallet_transaction_details_sent_to) + } + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + text = text.uppercase(), + textAlign = TextAlign.Start, + style = AppTheme.typography.bodyMedium, + color = AppTheme.extraColorScheme.onSurfaceVariantAlt2, + ) + + TransactionCard( + txData = txData, + onProfileClick = onProfileClick, + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + state.feedPost?.let { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + text = stringResource(id = R.string.wallet_transaction_details_zapped_note).uppercase(), + textAlign = TextAlign.Start, + style = AppTheme.typography.bodyMedium, + color = AppTheme.extraColorScheme.onSurfaceVariantAlt2, + ) + + FeedNoteCard( + data = it, + modifier = Modifier.padding(horizontal = 12.dp), + colors = transactionCardColors(), + onPostClick = onPostClick, + onProfileClick = onProfileClick, + onHashtagClick = onHashtagClick, + onMediaClick = onMediaClick, + onMuteUserClick = {}, + ) + + Spacer(modifier = Modifier.height(32.dp)) + } } }, ) } -class TransactionParameterProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf() +@Composable +private fun TransactionDetailDataUi?.resolveTitle(): String { + return when (this?.txType) { + TxType.DEPOSIT -> if (isZap) { + stringResource(id = R.string.wallet_transaction_details_title_zap_received) + } else { + stringResource(id = R.string.wallet_transaction_details_title_payment_received) + } + + TxType.WITHDRAW -> if (isZap) { + stringResource(id = R.string.wallet_transaction_details_title_zap_sent) + } else { + stringResource(id = R.string.wallet_transaction_details_title_payment_sent) + } + + else -> "" + } +} + +@Composable +private fun transactionCardColors(): CardColors { + return if (LocalPrimalTheme.current.isDarkTheme) { + CardDefaults.cardColors( + containerColor = AppTheme.extraColorScheme.surfaceVariantAlt2, + contentColor = AppTheme.extraColorScheme.onSurfaceVariantAlt2, + ) + } else { + CardDefaults.cardColors() + } +} + +@Composable +private fun TransactionDetailDataUi.typeToReadableString(): String { + return when { + isZap -> stringResource(id = R.string.wallet_transaction_details_type_nostr_zap) + isStorePurchase -> stringResource(id = R.string.wallet_transaction_details_type_in_app_purchase) + onChainAddress != null -> stringResource( + id = R.string.wallet_transaction_details_type_on_chain_payment, + ) + + else -> stringResource(id = R.string.wallet_transaction_details_type_lightning_payment) + } +} + +@Composable +private fun TransactionCard(txData: TransactionDetailDataUi, onProfileClick: (String) -> Unit) { + val numberFormat = remember { NumberFormat.getNumberInstance().apply { maximumFractionDigits = 2 } } + + val isExpandable = txData.isZap && ( + txData.txAmountInUsd != null || txData.exchangeRate != null || + txData.totalFeeInSats != null || txData.invoice != null + ) + + var expanded by remember { mutableStateOf(!txData.isZap) } + + Card( + modifier = Modifier + .padding(horizontal = 12.dp) + .animateContentSize( + animationSpec = spring(stiffness = StiffnessMediumLow), + ), + colors = transactionCardColors(), + ) { + if (!txData.otherUserDisplayName.isNullOrEmpty()) { + FeedNoteHeader( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 12.dp) + .fillMaxWidth() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + enabled = txData.otherUserId != null, + onClick = { txData.otherUserId?.let(onProfileClick) }, + ), + authorAvatarSize = 42.dp, + authorDisplayName = txData.otherUserDisplayName, + authorAvatarCdnImage = txData.otherUserAvatarCdnImage, + authorInternetIdentifier = txData.otherUserInternetIdentifier, + onAuthorAvatarClick = { txData.otherUserId?.let(onProfileClick) }, + label = txData.otherUserLightningAddress, + labelStyle = AppTheme.typography.bodyMedium, + ) + } else { + Row( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 12.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + TransactionIcon( + background = Color(0xFF222222), + ) { + Image( + imageVector = PrimalIcons.WalletLnPayment, + contentDescription = null, + colorFilter = ColorFilter.tint(color = AppTheme.extraColorScheme.zapped), + ) + } + + Column( + modifier = Modifier.padding(horizontal = 10.dp), + ) { + Text( + text = txData.typeToReadableString(), + style = AppTheme.typography.bodyMedium.copy( + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ), + ) + + txData.otherUserLightningAddress?.let { lud16Receiver -> + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = lud16Receiver, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = AppTheme.typography.bodyMedium, + color = AppTheme.extraColorScheme.onSurfaceVariantAlt2, + ) + } + } + } + } + + txData.txNote?.ifEmpty { null }?.let { note -> + Text( + text = note, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 12.dp), + color = AppTheme.colorScheme.onPrimary, + style = AppTheme.typography.bodyMedium.copy(fontSize = 16.sp), + ) + } + + PrimalDivider() + TransactionDetailListItem( + section = stringResource(id = R.string.wallet_transaction_details_date_item), + value = txData.txInstant.formatToDefaultFormat(FormatStyle.MEDIUM), + ) + + PrimalDivider() + TransactionDetailListItem( + section = stringResource(id = R.string.wallet_transaction_details_status_item), + value = txData.txState.toReadableString(), + ) + + PrimalDivider() + TransactionDetailListItem( + section = stringResource(id = R.string.wallet_transaction_details_type_item), + value = txData.typeToReadableString(), + ) + + if (expanded) { + if (txData.txAmountInUsd != null || txData.exchangeRate != null) { + val usdAmount = txData.txAmountInUsd?.toDouble() ?: txData.exchangeRate?.let { rate -> + txData.txAmountInSats.toBtc() / rate.toDouble() + } + + val formattedUsdAmount = try { + numberFormat.format(usdAmount) + } catch (error: IllegalArgumentException) { + Timber.e(error) + null + } + + if (formattedUsdAmount != null) { + PrimalDivider() + TransactionDetailListItem( + section = stringResource(id = R.string.wallet_transaction_details_original_usd_item), + value = "$$formattedUsdAmount", + ) + } + } + + txData.totalFeeInSats?.let { feeAmount -> + PrimalDivider() + TransactionDetailListItem( + section = stringResource(id = R.string.wallet_transaction_details_fee_item), + value = "${ + numberFormat.format( + feeAmount.toLong(), + ) + } ${stringResource(id = R.string.wallet_sats_suffix)}", + ) + } + + txData.invoice?.let { invoice -> + val clipboardManager = LocalClipboardManager.current + + PrimalDivider() + TransactionDetailListItem( + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { + clipboardManager.setText(AnnotatedString(text = invoice)) + }, + ), + section = stringResource(id = R.string.wallet_transaction_details_invoice_item), + value = invoice.ellipsizeMiddle(size = 10), + trailingIcon = PrimalIcons.Copy, + ) + } + } + + if (isExpandable) { + PrimalDivider() + IconText( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { expanded = !expanded }, + ), + text = stringResource(id = R.string.wallet_transaction_details_expand_collapse_hint), + textAlign = TextAlign.Center, + style = AppTheme.typography.bodySmall, + color = AppTheme.extraColorScheme.onSurfaceVariantAlt3, + trailingIcon = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + trailingIconTintColor = AppTheme.extraColorScheme.onSurfaceVariantAlt3, + ) + } + } +} + +@Composable +private fun TxState.toReadableString(): String { + return when (this) { + TxState.CREATED -> stringResource(id = R.string.wallet_transaction_details_status_created) + TxState.PROCESSING -> stringResource(id = R.string.wallet_transaction_details_status_processing) + TxState.SUCCEEDED -> stringResource(id = R.string.wallet_transaction_details_status_succeeded) + TxState.FAILED -> stringResource(id = R.string.wallet_transaction_details_status_failed) + TxState.CANCELED -> stringResource(id = R.string.wallet_transaction_details_status_canceled) + } +} + +@Composable +fun Instant.formatToDefaultFormat(dateTimeStyle: FormatStyle): String { + val zoneId: ZoneId = ZoneId.systemDefault() + val locale: Locale = Locale.getDefault() + + val formatter: DateTimeFormatter = DateTimeFormatter + .ofLocalizedDateTime(dateTimeStyle) + .withLocale(locale) + + return formatter.format(this.atZone(zoneId)) +} + +@Composable +private fun TransactionDetailListItem( + section: String, + value: String, + modifier: Modifier = Modifier, + trailingIcon: ImageVector? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = 12.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = section, + style = AppTheme.typography.bodyMedium, + color = AppTheme.extraColorScheme.onSurfaceVariantAlt2, + ) + + IconText( + text = value, + style = AppTheme.typography.bodyMedium, + color = AppTheme.extraColorScheme.onSurfaceVariantAlt2, + trailingIcon = trailingIcon, + trailingIconTintColor = AppTheme.extraColorScheme.onSurfaceVariantAlt2, + ) + } +} + +class TransactionParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TransactionDetailDataUi( + txId = "123", + txType = TxType.DEPOSIT, + txAmountInSats = 9999.toULong(), + txNote = "Bought sats from Primal", + txInstant = Instant.now(), + otherUserId = "storeId", + otherUserAvatarCdnImage = null, + isZap = false, + isStorePurchase = true, + exchangeRate = null, + txAmountInUsd = null, + txState = TxState.SUCCEEDED, + invoice = "", + onChainAddress = null, + totalFeeInSats = null, + ), + ) } @Preview @Composable fun PreviewTransactionDetail( @PreviewParameter(provider = TransactionParameterProvider::class) - parameter: TransactionDataUi, + txDataParam: TransactionDetailDataUi, ) { PrimalTheme(primalTheme = net.primal.android.theme.domain.PrimalTheme.Sunset) { Surface { TransactionDetailsScreen( - state = UiState(), + state = UiState( + txData = txDataParam, + ), onClose = {}, + onProfileClick = {}, + onPostClick = {}, + onHashtagClick = {}, + onMediaClick = { _, _ -> }, ) } } diff --git a/app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailsViewModel.kt b/app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailsViewModel.kt index f7453890f..6074dea2a 100644 --- a/app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailsViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/wallet/transactions/details/TransactionDetailsViewModel.kt @@ -4,21 +4,33 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Instant import javax.inject.Inject -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.primal.android.core.compose.feed.model.asFeedPostUi +import net.primal.android.core.coroutines.CoroutineDispatcherProvider +import net.primal.android.core.utils.authorNameUiFriendly +import net.primal.android.feed.repository.FeedRepository import net.primal.android.navigation.transactionIdOrThrow +import net.primal.android.networking.sockets.errors.WssException +import net.primal.android.wallet.db.WalletTransaction import net.primal.android.wallet.repository.WalletRepository -import net.primal.android.wallet.transactions.details.TransactionDetailsContract.UiEvent import net.primal.android.wallet.transactions.details.TransactionDetailsContract.UiState +import net.primal.android.wallet.utils.CurrencyConversionUtils.toSats +import timber.log.Timber @HiltViewModel class TransactionDetailsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val dispatcherProvider: CoroutineDispatcherProvider, private val walletRepository: WalletRepository, + private val feedRepository: FeedRepository, ) : ViewModel() { private val transactionId = savedStateHandle.transactionIdOrThrow @@ -28,16 +40,65 @@ class TransactionDetailsViewModel @Inject constructor( private fun setState(reducer: UiState.() -> UiState) = viewModelScope.launch { _state.getAndUpdate { it.reducer() } } - private val events = MutableSharedFlow() - fun setEvent(event: UiEvent) = viewModelScope.launch { events.emit(event) } - init { - observeEvents() + loadTransaction() } - private fun observeEvents() = + private fun loadTransaction() = + viewModelScope.launch { + val tx = withContext(dispatcherProvider.io()) { + walletRepository.findTransactionById(txId = transactionId) + } + setState { copy(loading = false, txData = tx?.mapAsTransactionDataUi()) } + tx?.data?.zapNoteId?.let { + observeZappedNote(it) + fetchZappedNote(it) + } + } + + private fun observeZappedNote(noteId: String) = viewModelScope.launch { - events.collect { + feedRepository.observeConversation(postId = noteId) + .filter { it.isNotEmpty() } + .mapNotNull { conversation -> conversation.first { it.data.postId == noteId } } + .collect { + setState { copy(feedPost = it.asFeedPostUi()) } + } + } + + private fun fetchZappedNote(noteId: String) = + viewModelScope.launch { + setState { copy(loading = true) } + try { + withContext(dispatcherProvider.io()) { + feedRepository.fetchReplies(postId = noteId) + } + } catch (error: WssException) { + Timber.e(error) + } finally { + setState { copy(loading = false) } } } + + private fun WalletTransaction.mapAsTransactionDataUi() = + TransactionDetailDataUi( + txId = this.data.id, + txType = this.data.type, + txState = this.data.state, + txAmountInSats = this.data.amountInBtc.toBigDecimal().abs().toSats(), + txAmountInUsd = this.data.amountInUsd, + txInstant = Instant.ofEpochSecond(this.data.completedAt ?: this.data.createdAt), + txNote = this.data.note, + invoice = this.data.invoice, + totalFeeInSats = this.data.totalFeeInBtc?.toBigDecimal()?.abs()?.toSats(), + exchangeRate = this.data.exchangeRate, + onChainAddress = this.data.onChainAddress, + otherUserId = this.data.otherUserId, + otherUserAvatarCdnImage = this.otherProfileData?.avatarCdnImage, + otherUserDisplayName = this.otherProfileData?.authorNameUiFriendly(), + otherUserInternetIdentifier = this.otherProfileData?.internetIdentifier, + otherUserLightningAddress = this.data.otherLightningAddress, + isZap = this.data.isZap, + isStorePurchase = this.data.isStorePurchase, + ) } diff --git a/app/src/main/kotlin/net/primal/android/wallet/transactions/list/TransactionIcon.kt b/app/src/main/kotlin/net/primal/android/wallet/transactions/list/TransactionIcon.kt new file mode 100644 index 000000000..523d12bab --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/wallet/transactions/list/TransactionIcon.kt @@ -0,0 +1,30 @@ +package net.primal.android.wallet.transactions.list + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import net.primal.android.theme.AppTheme + +@Composable +fun TransactionIcon( + background: Color = AppTheme.extraColorScheme.surfaceVariantAlt1, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .background( + color = background, + shape = CircleShape, + ) + .size(48.dp), + contentAlignment = Alignment.Center, + ) { + content() + } +} diff --git a/app/src/main/kotlin/net/primal/android/wallet/transactions/list/TransactionListItem.kt b/app/src/main/kotlin/net/primal/android/wallet/transactions/list/TransactionListItem.kt index 51fe7befa..4c3c14848 100644 --- a/app/src/main/kotlin/net/primal/android/wallet/transactions/list/TransactionListItem.kt +++ b/app/src/main/kotlin/net/primal/android/wallet/transactions/list/TransactionListItem.kt @@ -2,12 +2,8 @@ package net.primal.android.wallet.transactions.list import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.Surface @@ -23,7 +19,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import java.text.NumberFormat import java.time.Instant @@ -203,21 +198,6 @@ private fun TransactionTrailingContent(txAmountInSats: String, txType: TxType) { } } -@Composable -private fun TransactionIcon(content: @Composable () -> Unit) { - Box( - modifier = Modifier - .background( - color = AppTheme.extraColorScheme.surfaceVariantAlt1, - shape = CircleShape, - ) - .size(48.dp), - contentAlignment = Alignment.Center, - ) { - content() - } -} - private fun Instant.formatAsTime(): String { val formatter = DateTimeFormatter.ofPattern("HH:mm") return formatter.format(this.atZone(ZoneId.systemDefault())) diff --git a/app/src/main/kotlin/net/primal/android/wallet/transactions/receive/ReceivePaymentScreen.kt b/app/src/main/kotlin/net/primal/android/wallet/transactions/receive/ReceivePaymentScreen.kt index b0d19ac28..ccff08858 100644 --- a/app/src/main/kotlin/net/primal/android/wallet/transactions/receive/ReceivePaymentScreen.kt +++ b/app/src/main/kotlin/net/primal/android/wallet/transactions/receive/ReceivePaymentScreen.kt @@ -80,7 +80,7 @@ import net.primal.android.core.compose.numericpad.PrimalNumericPad import net.primal.android.crypto.urlToLnUrlHrp import net.primal.android.theme.AppTheme import net.primal.android.wallet.api.parseAsLNUrlOrNull -import net.primal.android.wallet.dashboard.ui.AmountText +import net.primal.android.wallet.dashboard.ui.BtcAmountText import net.primal.android.wallet.transactions.receive.ReceivePaymentContract.UiState import net.primal.android.wallet.utils.CurrencyConversionUtils.formatAsString import net.primal.android.wallet.utils.CurrencyConversionUtils.toBtc @@ -221,7 +221,7 @@ private fun ReceivePaymentViewer( if (paymentDetails.amountInBtc != null) { Spacer(modifier = Modifier.height(16.dp)) - AmountText( + BtcAmountText( modifier = Modifier .padding(start = 32.dp) .height(72.dp), @@ -372,7 +372,7 @@ private fun ReceivePaymentEditor( ) { Spacer(modifier = Modifier.height(32.dp)) - AmountText( + BtcAmountText( modifier = Modifier .padding(start = 32.dp) .height(72.dp), diff --git a/app/src/main/kotlin/net/primal/android/wallet/transactions/send/create/CreateTransactionScreen.kt b/app/src/main/kotlin/net/primal/android/wallet/transactions/send/create/CreateTransactionScreen.kt index 6bfb54de7..f77535118 100644 --- a/app/src/main/kotlin/net/primal/android/wallet/transactions/send/create/CreateTransactionScreen.kt +++ b/app/src/main/kotlin/net/primal/android/wallet/transactions/send/create/CreateTransactionScreen.kt @@ -67,7 +67,7 @@ import net.primal.android.core.compose.icons.primaliconpack.WalletError import net.primal.android.core.compose.icons.primaliconpack.WalletSuccess import net.primal.android.core.compose.numericpad.PrimalNumericPad import net.primal.android.theme.AppTheme -import net.primal.android.wallet.dashboard.ui.AmountText +import net.primal.android.wallet.dashboard.ui.BtcAmountText import net.primal.android.wallet.numericPadContentTransformAnimation import net.primal.android.wallet.transactions.send.create.CreateTransactionContract.UiEvent.SendTransaction import net.primal.android.wallet.utils.CurrencyConversionUtils.toBtc @@ -216,7 +216,7 @@ private fun TransactionEditor( Spacer(modifier = Modifier.height(32.dp)) - AmountText( + BtcAmountText( modifier = Modifier .padding(start = 32.dp) .height(72.dp) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 94e58296c..1893b8f46 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -391,7 +391,30 @@ Apply Unable to add details. Please try again in few moments. - Something + Zap Received + Zap Sent + Payment Received + Payment Sent + Sent To + Received From + Zapped Note + Date + Status + Transaction Type + Current USD value + Original USD value + Transaction Fee + Invoice + Transaction details + Nostr Zap + In-app Purchase + On-chain Payment + Lightning Payment + Draft + Pending + Paid + Failed + Canceled Permission required Primal needs access to your camera to scan QR codes.