diff --git a/.cursorrules b/.cursorrules index 08ae0d894..201529c90 100644 --- a/.cursorrules +++ b/.cursorrules @@ -59,6 +59,39 @@ - **Use Input suffix for input models** - `CreateJointAccountInput` - **Split interface and implementation into separate files** +### Fragment/ViewModel State Management +- **Never create state flags in Fragments** - State is lost on recreation. Move to ViewModel +- **Logic depending on state goes in ViewModel** - Including deep link handling, initialization flags +- **ViewModel calls stay in ViewModel** - Don't route events through Fragment back to ViewModel + ```kotlin + // BAD: Fragment routes event back to ViewModel + override fun onEvent(event: Event) { + viewModel.handleEvent(event) + } + + // GOOD: Handle in Composable directly or use sealed ViewEvent + LaunchedEffect(event) { when(event) { ... } } + ``` + +### ViewState/ViewEvent Pattern +- **Use sealed interface ViewState** - Not data class with boolean flags +- **Use sealed interface ViewEvent** - For one-time navigation/UI events +- **Collect events in Fragment** - ViewEvents for navigation that requires Fragment context +- **Collect state in Composable** - ViewState for UI rendering + ```kotlin + sealed interface MyViewState { + data object Loading : MyViewState + data object Empty : MyViewState + data class Content(val items: List) : MyViewState + data class Error(val message: String) : MyViewState + } + + sealed interface MyViewEvent { + data class NavigateToDetail(val id: String) : MyViewEvent + data class ShowError(val message: String) : MyViewEvent + } + ``` + ### UseCase Pattern - Interface (fun interface) + `{UseCaseName}UseCase` implementation - Use `operator fun invoke(...)` for functional interfaces @@ -78,13 +111,44 @@ ### Cache vs Repository Naming - **In-memory caches are NOT repositories** - Use `InboxInMemoryCache`, not `InboxRepository` - **Use InMemoryCacheProvider** for in-memory caches, not custom `MutableStateFlow` implementations +- **Use PersistentCacheProvider** for persistent storage, not SharedPreferences local sources + ```kotlin + // BAD: Custom SharedPreferences class + class LastOpenedTimeLocalSource @Inject constructor(sharedPref: SharedPreferences) + + // GOOD: Use PersistentCacheProvider + @Provides + fun provideLastOpenedTimeCache(provider: PersistentCacheProvider): PersistentCache = + provider.getPersistentCache(String::class.java, "last_opened_time") + ``` +- **Don't create unnecessary cache wrapper classes** - Use provider directly - **Repository** = data access with external sources (API, database) -- **Cache** = in-memory storage only +- **Cache** = in-memory or persistent storage only ### DI Module Rules - **Use method references, not lambdas** - `RefreshCache(manager::refresh)` not `RefreshCache { manager.refresh() }` - **Don't use @Singleton if state is external** - If cache/state is passed in constructor, no need for @Singleton - **Keep providers in correct feature modules** - `InboxApiService` belongs in `InboxModule`, not `JointAccountModule` +- **Remove injection names when only one implementation** - `@Named` unnecessary if single implementation exists + +### Database/Data Access +- **Create targeted DB queries** - Don't fetch all records and filter in memory + ```kotlin + // BAD: Fetch all and filter + val allAccounts = getLocalAccounts() + val jointAccounts = allAccounts.filter { it is Joint } + + // GOOD: Targeted query + val jointAccounts = getJointAccounts() + ``` +- **Use mapNotNull for filtering + mapping** - Reduces iteration count + ```kotlin + // BAD: filter + map (two iterations) + items.filter { it.value != null }.map { it.value!! } + + // GOOD: mapNotNull (single iteration) + items.mapNotNull { it.value } + ``` ### Feature Ownership - **Methods belong to their feature** - `getInboxMessages` belongs in inbox feature, not joint account @@ -93,10 +157,38 @@ ## Code Quality +### Use Existing Utility Classes +- **TimeProvider** - For getting current time (testable) +- **RelativeTimeDifference** - For calculating time differences (minutes, hours, days ago) +- **Don't duplicate time formatting logic** - Check existing utilities first + ```kotlin + // BAD: Direct time calls + val now = System.currentTimeMillis() + + // GOOD: Use TimeProvider + val now = timeProvider.currentTimeMillis() + ``` + ### Functions - **Keep under 50 lines** - Split into smaller functions - **Names must match behavior** - `signTransactionReturnSignature` if returning signature only - **Self-documenting code** - Use descriptive names instead of comments +- **Companion objects at bottom** - After all functions +- **Private functions at end** - After public/internal functions +- **Prevent multiple clicks** - Update state before async operations to prevent double execution + ```kotlin + // BAD: No state protection + fun onSubmit() { + viewModelScope.launch { submitData() } + } + + // GOOD: Protect with state + fun onSubmit() { + if (_state.value is Loading) return + _state.value = Loading + viewModelScope.launch { submitData() } + } + ``` ### Formatting - **Lines under 150 characters** @@ -128,10 +220,32 @@ ### Unit Test Best Practices - **Mock ALL dependencies** - Don't test multiple classes at once + ```kotlin + // BAD: Using real mapper in test + val mapper = RealMapperImpl(anotherRealMapper) + + // GOOD: Mock all dependencies + val mockMapper = mockk() + every { mockMapper.map(any()) } returns expectedResult + ``` - **Inline initialization** - Use `@Before` only for dispatcher setup or resettable state -- **Companion object for constants** - `const val TEST_ADDRESS = "ADDRESS_123"` +- **Companion object for constants** - `const val TEST_ADDRESS = "ADDRESS_123"` in private companion object - **Use .copy() for variations** - Don't recreate objects for each test + ```kotlin + // BAD: Multiple similar test functions + @Test fun `test with null list`() { val dto = createDTO(list = null) } + @Test fun `test with empty list`() { val dto = createDTO(list = emptyList()) } + + // GOOD: Use copy for variations + private fun createTestDTO() = TestDTO(...) + @Test fun `test variations`() { + val nullCase = createTestDTO().copy(list = null) + val emptyCase = createTestDTO().copy(list = emptyList()) + // Assert both in single test if testing same behavior + } + ``` - **Setup mocks BEFORE ViewModel creation** - init block runs during construction +- **Test constants in private companion object** - Not as class-level properties ## UI & Compose @@ -150,11 +264,26 @@ - `Modifier` parameter first - Use `remember` for expensive computations - Provide content descriptions for accessibility +- **Pass composables, not booleans** - For flexible shared components + ```kotlin + // BAD: Boolean limits flexibility + @Composable + fun PeraCard(showTag: Boolean = false) + + // GOOD: Composable allows customization + @Composable + fun PeraCard(tag: @Composable (() -> Unit)? = null) + ``` +- **Use content slots for flexible layouts** - `centerContent`, `endContent` instead of specific text params ### Resource Rules - **Icons theme-aware** - Use `@color/text_main`, not hardcoded hex - **Format placeholders in strings** - `"Transfer to %1$s"` for localization + - Different languages have different word orders + - Always use numbered placeholders: `%1$s`, `%2$d` +- **Use string resources for all user-visible text** - Including relative times like "0m", "1h" - **Drawables**: `ic_` prefix for icons, `bg_` for backgrounds +- **Use existing plurals** - Check `plurals.xml` before adding new strings (e.g., `min_ago`, `hours_ago`) ## Preview Files - **Required for every new screen** - Place in `preview/` subdirectory @@ -230,3 +359,40 @@ - `StateDelegate` and `EventDelegate` for ViewModel state/events - `sealed interface ViewState { data object Idle; data class Content(...) }` - `collectAsStateWithLifecycle()` in Composables + +--- + +## Common PR Feedback Summary + +These rules were derived from PR review feedback on PRs #510-519. Check before submitting: + +### Architecture +- [ ] Data models in common-sdk are `internal` +- [ ] Domain models don't use `DTO` suffix +- [ ] UseCases that just call repository are provided via DI lambda +- [ ] In-memory caches use `InMemoryCacheProvider` +- [ ] Persistent storage uses `PersistentCacheProvider`, not SharedPreferences +- [ ] Methods are in correct feature modules + +### ViewModel/Fragment +- [ ] No state flags in Fragments (use ViewModel) +- [ ] ViewState/ViewEvent pattern instead of Preview with flags +- [ ] Multiple click prevention (update state before async) + +### Code Quality +- [ ] Using existing utilities (TimeProvider, RelativeTimeDifference) +- [ ] String resources for user-visible text (including relative times) +- [ ] Formatted strings with numbered placeholders (`%1$s`) +- [ ] Icons are theme-aware +- [ ] Targeted DB queries instead of fetch-all-and-filter +- [ ] mapNotNull instead of filter+map + +### Testing +- [ ] ALL dependencies mocked +- [ ] Test constants in private companion object +- [ ] Using .copy() for test variations + +### DI +- [ ] Method references, not lambdas +- [ ] No unnecessary @Singleton +- [ ] No @Named when single implementation exists diff --git a/.gitignore b/.gitignore index d3723ddd7..6b6fbaa3c 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ render.experimental.xml # Claude .claude/ + +# Backend Feedback +BACKEND_FEEDBACK.md diff --git a/app/src/main/kotlin/com/algorand/android/modules/accountcore/ui/usecase/GetAccountOriginalStateIconDrawablePreviewUseCase.kt b/app/src/main/kotlin/com/algorand/android/modules/accountcore/ui/usecase/GetAccountOriginalStateIconDrawablePreviewUseCase.kt index 09d3d7e85..c81562cd0 100644 --- a/app/src/main/kotlin/com/algorand/android/modules/accountcore/ui/usecase/GetAccountOriginalStateIconDrawablePreviewUseCase.kt +++ b/app/src/main/kotlin/com/algorand/android/modules/accountcore/ui/usecase/GetAccountOriginalStateIconDrawablePreviewUseCase.kt @@ -57,9 +57,6 @@ internal class GetAccountOriginalStateIconDrawablePreviewUseCase @Inject constru R.color.layer_gray_lighter } } - - // TODO: Handle Joint Account properly - AccountType.Joint -> STANDARD.backgroundColorResId } } @@ -86,9 +83,6 @@ internal class GetAccountOriginalStateIconDrawablePreviewUseCase @Inject constru AccountType.RekeyedAuth, AccountType.Rekeyed, null -> { if (accountType?.canSignTransaction() == true) STANDARD.iconResId else R.drawable.ic_question } - - // TODO: Handle Joint Account properly - AccountType.Joint -> STANDARD.iconResId } } } diff --git a/app/src/main/kotlin/com/algorand/android/modules/accounts/ui/viewmodel/AccountsPreviewUseCase.kt b/app/src/main/kotlin/com/algorand/android/modules/accounts/ui/viewmodel/AccountsPreviewUseCase.kt index fea5a4044..e7c7ef8fa 100644 --- a/app/src/main/kotlin/com/algorand/android/modules/accounts/ui/viewmodel/AccountsPreviewUseCase.kt +++ b/app/src/main/kotlin/com/algorand/android/modules/accounts/ui/viewmodel/AccountsPreviewUseCase.kt @@ -21,11 +21,15 @@ import com.algorand.android.modules.accounts.lite.domain.model.AccountLiteCacheS import com.algorand.android.modules.accounts.lite.domain.model.AccountLiteCacheStatus.Loading import com.algorand.android.modules.accounts.lite.domain.usecase.GetAccountLiteCacheFlow import com.algorand.android.modules.accounts.ui.model.AccountPreview +import com.algorand.android.modules.inbox.allaccounts.ui.usecase.InboxPreviewUseCase import com.algorand.android.modules.parity.domain.model.SelectedCurrencyDetail import com.algorand.android.modules.peraconnectivitymanager.ui.PeraConnectivityManager import com.algorand.android.utils.CacheResult import com.algorand.wallet.banner.domain.usecase.GetBannerFlow +import com.algorand.wallet.inbox.asset.domain.usecase.GetAssetInboxRequestCountFlow import com.algorand.wallet.privacy.domain.usecase.GetPrivacyModeFlow +import com.algorand.wallet.remoteconfig.domain.model.FeatureToggle +import com.algorand.wallet.remoteconfig.domain.usecase.IsFeatureToggleEnabled import com.algorand.wallet.spotbanner.domain.model.SpotBannerFlowData import com.algorand.wallet.spotbanner.domain.usecase.GetSpotBannersFlow import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -42,10 +46,13 @@ class AccountsPreviewUseCase @Inject constructor( private val portfolioValueItemMapper: PortfolioValueItemMapper, private val peraConnectivityManager: PeraConnectivityManager, private val accountPreviewProcessor: AccountPreviewProcessor, + private val getAssetInboxRequestCountFlow: GetAssetInboxRequestCountFlow, private val getAccountLiteCacheFlow: GetAccountLiteCacheFlow, private val getPrivacyModeFlow: GetPrivacyModeFlow, private val getBannerFlow: GetBannerFlow, private val getSpotBannersFlow: GetSpotBannersFlow, + private val inboxPreviewUseCase: InboxPreviewUseCase, + private val isFeatureToggleEnabled: IsFeatureToggleEnabled ) { suspend fun getInitialAccountPreview(): AccountPreview { @@ -81,19 +88,34 @@ class AccountsPreviewUseCase @Inject constructor( return combine( getBannerFlow(), getSpotBannersFlow(getSpotBannerFlowData(accountLiteCacheData)), + getTotalInboxCountFlow(), getPrivacyModeFlow() - ) { banner, spotBanners, privacyMode -> + ) { banner, spotBanners, totalInboxCount, privacyMode -> accountPreviewProcessor.prepareAccountPreview( accountLiteCacheData.localAccounts, accountLiteCacheData.accountLites, banner, - 0, + totalInboxCount, privacyMode, spotBanners ) } } + private fun getTotalInboxCountFlow(): Flow { + val isJointAccountEnabled = isFeatureToggleEnabled(FeatureToggle.JOINT_ACCOUNT.key) + return if (isJointAccountEnabled) { + combine( + getAssetInboxRequestCountFlow(), + inboxPreviewUseCase.getJointAccountInboxCountFlow() + ) { asaInboxCount, jointAccountInboxCount -> + asaInboxCount + jointAccountInboxCount + } + } else { + getAssetInboxRequestCountFlow() + } + } + private fun getSpotBannerFlowData(accountLiteCacheData: Data): List { return accountLiteCacheData.accountLites.values.map { lite -> with(lite) { diff --git a/app/src/main/kotlin/com/algorand/android/modules/assetinbox/detail/receivedetail/ui/Arc59ReceiveDetailFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/assetinbox/detail/receivedetail/ui/Arc59ReceiveDetailFragment.kt index 5854d72e2..7774a0672 100644 --- a/app/src/main/kotlin/com/algorand/android/modules/assetinbox/detail/receivedetail/ui/Arc59ReceiveDetailFragment.kt +++ b/app/src/main/kotlin/com/algorand/android/modules/assetinbox/detail/receivedetail/ui/Arc59ReceiveDetailFragment.kt @@ -97,10 +97,10 @@ class Arc59ReceiveDetailFragment : BaseFragment(R.layout.fragment_arc59_receive_ initObservers() viewModel.initializePreview() arc59ClaimRejectTransactionSignManager.setup(viewLifecycleOwner.lifecycle) + initSavedStateListener() } - override fun onResume() { - super.onResume() + private fun initSavedStateListener() { startSavedStateListener(R.id.arc59ReceiveDetailFragment) { useSavedStateValue(RESULT_KEY) { if (it.isAccepted) viewModel.rejectTransaction() diff --git a/app/src/main/kotlin/com/algorand/android/modules/assetinbox/info/ui/AssetInboxInfoBottomSheet.kt b/app/src/main/kotlin/com/algorand/android/modules/assetinbox/info/ui/AssetInboxInfoBottomSheet.kt index d43bd9017..cd0fb0d0e 100644 --- a/app/src/main/kotlin/com/algorand/android/modules/assetinbox/info/ui/AssetInboxInfoBottomSheet.kt +++ b/app/src/main/kotlin/com/algorand/android/modules/assetinbox/info/ui/AssetInboxInfoBottomSheet.kt @@ -20,7 +20,7 @@ import com.google.android.material.button.MaterialButton class AssetInboxInfoBottomSheet : BaseInformationBottomSheet() { override fun initTitleTextView(titleTextView: TextView) { - titleTextView.setText(R.string.asset_transfer_requests) + titleTextView.setText(R.string.inbox) } override fun initDescriptionTextView(descriptionTextView: TextView) { diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/di/InboxRepositoryModule.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/di/InboxRepositoryModule.kt new file mode 100644 index 000000000..8dc43f30e --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/di/InboxRepositoryModule.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.allaccounts.di + +import com.algorand.android.modules.inbox.allaccounts.ui.mapper.InboxPreviewMapper +import com.algorand.android.modules.inbox.allaccounts.ui.mapper.InboxPreviewMapperImpl +import com.algorand.android.modules.inbox.data.local.InboxLastOpenedTimeLocalSource +import com.algorand.wallet.foundation.cache.PersistentCacheProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object InboxRepositoryModule { + + private const val INBOX_LAST_OPENED_TIME_KEY = "inbox_last_opened_time" + + @Provides + fun provideInboxPreviewMapper( + inboxPreviewMapperImpl: InboxPreviewMapperImpl + ): InboxPreviewMapper = inboxPreviewMapperImpl + + @Provides + fun provideInboxLastOpenedTimeLocalSource( + persistentCacheProvider: PersistentCacheProvider + ): InboxLastOpenedTimeLocalSource { + return InboxLastOpenedTimeLocalSource( + persistentCacheProvider.getPersistentCache(String::class.java, INBOX_LAST_OPENED_TIME_KEY) + ) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/domain/model/InboxWithAccount.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/domain/model/InboxWithAccount.kt new file mode 100644 index 000000000..c3611da04 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/domain/model/InboxWithAccount.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.allaccounts.domain.model + +import android.os.Parcelable +import com.algorand.android.models.RecyclerListItem +import com.algorand.android.modules.accountcore.ui.model.AccountDisplayName +import com.algorand.android.modules.accounticon.ui.model.AccountIconDrawablePreview +import kotlinx.parcelize.Parcelize + +@Parcelize +data class InboxWithAccount( + val address: String, + val requestCount: Int, + val accountDisplayName: AccountDisplayName, + val accountAddress: String, + val accountIconDrawablePreview: AccountIconDrawablePreview +) : Parcelable, RecyclerListItem { + override fun areItemsTheSame(other: RecyclerListItem): Boolean { + return other is InboxWithAccount && accountAddress == other.accountAddress + } + + override fun areContentsTheSame(other: RecyclerListItem): Boolean { + return other is InboxWithAccount && this == other + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/domain/model/SignatureRequestInboxItem.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/domain/model/SignatureRequestInboxItem.kt new file mode 100644 index 000000000..fca13237c --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/domain/model/SignatureRequestInboxItem.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.allaccounts.domain.model + +import android.os.Parcelable +import com.algorand.android.models.RecyclerListItem +import com.algorand.android.modules.accounticon.ui.model.AccountIconDrawablePreview +import kotlinx.parcelize.Parcelize + +@Parcelize +data class SignatureRequestInboxItem( + val signRequestId: String, + val jointAccountAddress: String, + val jointAccountAddressShortened: String, + val accountIconDrawablePreview: AccountIconDrawablePreview, + val description: String, + val timeAgo: String, + val signedCount: Int, + val totalCount: Int, + val timeLeft: String?, + val isRead: Boolean = true, + val isExpired: Boolean = false, + val canUserSign: Boolean = true +) : Parcelable, RecyclerListItem { + override fun areItemsTheSame(other: RecyclerListItem): Boolean { + return other is SignatureRequestInboxItem && signRequestId == other.signRequestId + } + + override fun areContentsTheSame(other: RecyclerListItem): Boolean { + return other is SignatureRequestInboxItem && this == other + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/InboxFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/InboxFragment.kt new file mode 100644 index 000000000..0da0e4944 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/InboxFragment.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + * + */ + +package com.algorand.android.modules.inbox.allaccounts.ui + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.navArgs +import com.algorand.android.HomeNavigationDirections +import com.algorand.android.R +import com.algorand.android.core.transaction.TransactionSignBaseFragment +import com.algorand.android.customviews.toolbar.buttoncontainer.model.IconButton +import com.algorand.android.models.FragmentConfiguration +import com.algorand.android.models.ToolbarConfiguration +import com.algorand.android.modules.addaccount.joint.transaction.ui.PendingSignaturesDialogFragment +import com.algorand.android.modules.assetinbox.assetinboxoneaccount.ui.model.AssetInboxOneAccountNavArgs +import com.algorand.android.modules.inbox.allaccounts.ui.model.InboxViewEvent +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.model.JointAccountInvitationInboxItem +import com.algorand.android.ui.compose.extensions.createComposeView +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.utils.extensions.collectLatestOnLifecycle +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class InboxFragment : TransactionSignBaseFragment(0), InboxScreenListener { + + private val infoButton by lazy { IconButton(R.drawable.ic_info, onClick = ::onInfoClick) } + + private val toolbarConfiguration = ToolbarConfiguration( + titleResId = R.string.inbox, + startIconClick = ::navBack, + startIconResId = R.drawable.ic_left_arrow + ) + + override val fragmentConfiguration: FragmentConfiguration = + FragmentConfiguration(toolbarConfiguration = toolbarConfiguration) + + private val inboxViewModel: InboxViewModel by viewModels() + + private val args: InboxFragmentArgs by navArgs() + + override fun onCreateView( + inflater: android.view.LayoutInflater, + container: android.view.ViewGroup?, + savedInstanceState: Bundle? + ): View { + return createComposeView { + PeraTheme { + InboxScreen( + viewModel = inboxViewModel, + listener = this@InboxFragment + ) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupToolbar() + initObservers() + inboxViewModel.initializePreview(args.jointAccountAddressToOpen) + } + + private fun setupToolbar() { + getAppToolbar()?.run { + setEndButton(button = infoButton) + } + } + + private fun initObservers() { + viewLifecycleOwner.collectLatestOnLifecycle( + flow = inboxViewModel.viewEvent, + collection = ::handleViewEvent + ) + } + + private fun handleViewEvent(event: InboxViewEvent) { + when (event) { + is InboxViewEvent.NavigateToJointAccountInvitation -> { + navToJointAccountInvitationDetail(event.invitation) + } + is InboxViewEvent.NavigateToJointAccountDetail -> { + nav( + HomeNavigationDirections.actionGlobalToJointAccountDetailFragment( + accountAddress = event.accountAddress + ) + ) + } + is InboxViewEvent.ShowError -> { + showGlobalError(event.message, tag = baseActivityTag) + } + } + } + + override fun onAccountClick(accountAddress: String) { + navToAssetInboxOneAccountNavigation(AssetInboxOneAccountNavArgs(accountAddress)) + } + + override fun onInfoClick() { + navToAssetInboxInfoNavigation() + } + + private fun navToAssetInboxInfoNavigation() { + nav( + InboxFragmentDirections + .actionInboxFragmentToAssetInboxInfoNavigation() + ) + } + + private fun navToAssetInboxOneAccountNavigation(assetInboxOneAccountNavArgs: AssetInboxOneAccountNavArgs) { + nav( + InboxFragmentDirections + .actionInboxFragmentToAssetInboxOneAccountNavigation( + assetInboxOneAccountNavArgs + ) + ) + } + + override fun onSignatureRequestClick(signRequestId: String, canUserSign: Boolean) { + if (canUserSign) { + nav(HomeNavigationDirections.actionGlobalToJointAccountSignRequestFragment(signRequestId)) + } else { + PendingSignaturesDialogFragment.newInstance(signRequestId) + .show(childFragmentManager, PendingSignaturesDialogFragment.TAG) + } + } + + override fun onJointAccountInvitationClick(invitation: JointAccountInvitationInboxItem) { + navToJointAccountInvitationDetail(invitation) + } + + private fun navToJointAccountInvitationDetail(invitation: JointAccountInvitationInboxItem) { + nav( + HomeNavigationDirections.actionGlobalToJointAccountDetailFragment( + accountAddress = invitation.accountAddress, + threshold = invitation.threshold, + participantAddresses = invitation.participantAddresses.toTypedArray() + ) + ) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/InboxScreen.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/InboxScreen.kt new file mode 100644 index 000000000..5d44550cc --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/InboxScreen.kt @@ -0,0 +1,629 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.allaccounts.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.runtime.remember +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.algorand.android.R +import com.algorand.android.models.AccountIconResource +import com.algorand.android.modules.accounticon.ui.model.AccountIconDrawablePreview +import com.algorand.android.modules.inbox.allaccounts.domain.model.InboxWithAccount +import com.algorand.android.modules.inbox.allaccounts.domain.model.SignatureRequestInboxItem +import com.algorand.android.modules.inbox.allaccounts.ui.model.InboxPreview +import com.algorand.android.modules.inbox.allaccounts.ui.model.InboxViewState +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.model.JointAccountInvitationInboxItem +import com.algorand.android.ui.compose.theme.ColorPalette +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.ui.compose.widget.AccountIcon +import com.algorand.android.utils.getRelativeTimeDifference +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun InboxScreen( + modifier: Modifier = Modifier, + viewModel: InboxViewModel, + listener: InboxScreenListener +) { + val viewState by viewModel.state.collectAsStateWithLifecycle() + + Box(modifier = modifier.fillMaxSize()) { + when (viewState) { + is InboxViewState.Loading -> { + LoadingState() + } + + is InboxViewState.Empty -> { + EmptyState() + } + + is InboxViewState.Content -> { + val content = viewState as InboxViewState.Content + ContentState( + accounts = content.inboxWithAccountList, + signatureRequests = content.signatureRequestList, + jointAccountInvitations = content.jointAccountInvitationList, + onAccountClick = listener::onAccountClick, + onSignatureRequestClick = listener::onSignatureRequestClick, + onJointAccountInvitationClick = listener::onJointAccountInvitationClick + ) + } + + is InboxViewState.Error -> { + EmptyState() + } + } + } +} + +@Composable +fun InboxScreen( + modifier: Modifier = Modifier, + viewStateFlow: StateFlow, + listener: InboxScreenListener +) { + val preview by viewStateFlow.collectAsStateWithLifecycle() + + Box(modifier = modifier.fillMaxSize()) { + when { + preview.isLoading -> { + LoadingState() + } + + preview.isEmptyStateVisible -> { + EmptyState() + } + + else -> { + ContentState( + accounts = preview.inboxWithAccountList, + signatureRequests = preview.signatureRequestList, + jointAccountInvitations = preview.jointAccountInvitationList, + onAccountClick = listener::onAccountClick, + onSignatureRequestClick = listener::onSignatureRequestClick, + onJointAccountInvitationClick = listener::onJointAccountInvitationClick + ) + } + } + } +} + +@Composable +private fun LoadingState() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun EmptyState() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.no_pending_asset_transfer), + style = PeraTheme.typography.body.regular.sansMedium, + color = PeraTheme.colors.text.main + ) + Text( + modifier = Modifier.padding(top = 12.dp), + text = stringResource(R.string.when_you_have_an_asset), + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.gray + ) + } +} + +@Composable +private fun ContentState( + accounts: List, + signatureRequests: List, + jointAccountInvitations: List, + onAccountClick: (String) -> Unit, + onSignatureRequestClick: (String, Boolean) -> Unit, // signRequestId, canUserSign + onJointAccountInvitationClick: (JointAccountInvitationInboxItem) -> Unit +) { + LazyColumn( + contentPadding = PaddingValues(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items( + items = signatureRequests, + key = { it.signRequestId } + ) { signatureRequest -> + SignatureRequestInboxItem( + signatureRequest = signatureRequest, + onClick = { onSignatureRequestClick(signatureRequest.signRequestId, signatureRequest.canUserSign) } + ) + } + items( + items = jointAccountInvitations, + key = { it.id } + ) { invitation -> + JointAccountInvitationInboxItem( + invitation = invitation, + onClick = { onJointAccountInvitationClick(invitation) } + ) + } + items( + items = accounts, + key = { it.accountAddress } + ) { account -> + AccountInboxItem( + account = account, + onAccountClick = { onAccountClick(account.accountAddress) } + ) + } + } +} + +@Composable +private fun AccountInboxItem( + account: InboxWithAccount, + onAccountClick: () -> Unit +) { + val resources = LocalResources.current + val incomingAssetCountText = resources.getQuantityString( + R.plurals.incoming_assets, + account.requestCount, + account.requestCount + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onAccountClick() } + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AccountIcon( + modifier = Modifier.size(40.dp), + iconDrawablePreview = account.accountIconDrawablePreview + ) + Spacer(modifier = Modifier.width(16.dp)) + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = incomingAssetCountText, + style = PeraTheme.typography.body.regular.sansMedium, + color = PeraTheme.colors.text.main, + maxLines = 1 + ) + Text( + text = account.accountDisplayName.primaryDisplayName, + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun SignatureRequestInboxItem( + signatureRequest: SignatureRequestInboxItem, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(PeraTheme.colors.background.primary) + .clickable { onClick() } + .padding(start = 12.dp, top = 16.dp, end = 12.dp, bottom = 16.dp), + verticalAlignment = Alignment.Top + ) { + UnreadIndicator(isRead = signatureRequest.isRead) + Spacer(modifier = Modifier.width(12.dp)) + AccountIcon( + modifier = Modifier.size(40.dp), + iconDrawablePreview = signatureRequest.accountIconDrawablePreview + ) + Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = buildSignatureRequestTitle(signatureRequest.jointAccountAddressShortened), + style = PeraTheme.typography.body.regular.sans, + color = PeraTheme.colors.text.main, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(8.dp)) + StatusLine( + timeAgo = signatureRequest.timeAgo + ) + Spacer(modifier = Modifier.height(12.dp)) + StatusPillsRow( + signedCount = signatureRequest.signedCount, + totalCount = signatureRequest.totalCount, + timeLeft = signatureRequest.timeLeft + ) + } + } +} + +@Composable +private fun buildSignatureRequestTitle(addressShortened: String): AnnotatedString { + val signatureRequestText = stringResource(R.string.signature_request) + val toSignForText = stringResource(R.string.to_sign_for_format, addressShortened) + val boldWeight = PeraTheme.typography.body.regular.sansMedium.fontWeight + val regularStyle = SpanStyle( + color = PeraTheme.colors.text.main, + fontStyle = PeraTheme.typography.body.regular.sans.fontStyle + ) + val boldStyle = SpanStyle( + color = PeraTheme.colors.text.main, + fontWeight = boldWeight, + fontStyle = PeraTheme.typography.body.regular.sansMedium.fontStyle + ) + + return buildAnnotatedString { + withStyle(style = boldStyle) { + append(signatureRequestText) + } + withStyle(style = regularStyle) { + append(toSignForText) + } + } +} + +@Composable +private fun StatusLine(timeAgo: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(R.drawable.ic_pending), + contentDescription = stringResource(R.string.pending_transaction), + tint = ColorPalette.Yellow.V600 + ) + Text( + text = stringResource(R.string.pending_transaction), + style = PeraTheme.typography.footnote.sansMedium, + color = ColorPalette.Yellow.V600 + ) + } + + Box( + modifier = Modifier + .size(2.dp) + .background( + color = PeraTheme.colors.layer.grayLighter, + shape = CircleShape + ) + ) + + Text( + text = timeAgo, + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.grayLighter, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun StatusPillsRow( + signedCount: Int, + totalCount: Int, + timeLeft: String? +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SignedCountPill( + signedCount = signedCount, + totalCount = totalCount + ) + timeLeft?.let { + TimeLeftPill(timeLeft = it) + } + } +} + +@Composable +private fun SignedCountPill( + signedCount: Int, + totalCount: Int +) { + Row( + modifier = Modifier + .background( + color = PeraTheme.colors.layer.grayLighter, + shape = RoundedCornerShape(16.dp) + ) + .padding(start = 8.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(AccountIconResource.CONTACT.iconResId), + contentDescription = stringResource(R.string.pending_signatures), + tint = PeraTheme.colors.text.main + ) + Text( + text = stringResource(R.string.of_signed, signedCount, totalCount), + style = PeraTheme.typography.footnote.sansMedium, + color = PeraTheme.colors.text.main + ) + } +} + +@Composable +private fun TimeLeftPill(timeLeft: String) { + Row( + modifier = Modifier + .background( + color = PeraTheme.colors.layer.grayLighter, + shape = RoundedCornerShape(16.dp) + ) + .padding(start = 8.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_clock), + contentDescription = stringResource(R.string.time_left, timeLeft), + tint = PeraTheme.colors.text.main + ) + Text( + text = stringResource(R.string.time_left, timeLeft), + style = PeraTheme.typography.footnote.sansMedium, + color = PeraTheme.colors.text.main + ) + } +} + +@Composable +private fun JointAccountInvitationInboxItem( + invitation: JointAccountInvitationInboxItem, + onClick: () -> Unit +) { + val resources = LocalResources.current + val timeAgoText = remember(invitation.creationDateTime, invitation.timeDifference) { + getRelativeTimeDifference( + resources, + invitation.creationDateTime, + invitation.timeDifference + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, top = 16.dp, end = 12.dp, bottom = 16.dp), + verticalAlignment = Alignment.Top + ) { + UnreadIndicator(isRead = invitation.isRead) + Spacer(modifier = Modifier.width(8.dp)) + AccountIconSection() + InvitationContentSection( + modifier = Modifier.weight(1f), + invitation = invitation, + timeAgoText = timeAgoText, + onClick = onClick + ) + } +} + +@Composable +private fun UnreadIndicator(isRead: Boolean) { + val unreadDescription = stringResource(R.string.unread) + Box( + modifier = Modifier + .height(40.dp) + .width(4.dp) + .semantics { + if (!isRead) { + contentDescription = unreadDescription + } + }, + contentAlignment = Alignment.Center + ) { + if (!isRead) { + Box( + modifier = Modifier + .size(4.dp) + .background( + color = PeraTheme.colors.link.icon, + shape = CircleShape + ) + ) + } + } +} + +@Composable +private fun AccountIconSection() { + AccountIcon( + modifier = Modifier.size(40.dp), + iconDrawablePreview = AccountIconDrawablePreview( + backgroundColorResId = AccountIconResource.JOINT.backgroundColorResId, + iconTintResId = AccountIconResource.JOINT.iconTintResId, + iconResId = AccountIconResource.JOINT.iconResId + ) + ) +} + +@Composable +private fun InvitationContentSection( + modifier: Modifier = Modifier, + invitation: JointAccountInvitationInboxItem, + timeAgoText: String, + onClick: () -> Unit +) { + Column( + modifier = modifier.padding(horizontal = 12.dp) + ) { + Text( + text = buildInvitationText(invitation.accountAddressShortened), + style = PeraTheme.typography.body.regular.sans, + color = PeraTheme.colors.text.main, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(12.dp)) + + ViewInvitationDetailsButton(onClick = onClick) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = timeAgoText, + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.grayLighter, + maxLines = 1 + ) + } +} + +@Composable +private fun ViewInvitationDetailsButton(onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + modifier = Modifier.height(40.dp), + shape = RoundedCornerShape(32.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = PeraTheme.colors.layer.grayLighter, + contentColor = PeraTheme.colors.text.main + ), + border = BorderStroke(0.dp, Color.Transparent), + contentPadding = PaddingValues( + horizontal = 16.dp, + vertical = 0.dp + ) + ) { + Text( + text = stringResource(R.string.view_invitation_details), + style = PeraTheme.typography.body.regular.sansMedium, + color = PeraTheme.colors.text.main + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_right_arrow), + contentDescription = null, // Decorative, button text already describes action + modifier = Modifier.size(16.dp), + tint = PeraTheme.colors.text.main + ) + } +} + +@Composable +private fun buildInvitationText(accountAddressShortened: String): AnnotatedString { + val fullText = stringResource( + R.string.you_ve_been_invited_to_join_joint_account, + accountAddressShortened + ) + val boldPart = stringResource(R.string.you_ve_been_invited) + + return buildAnnotatedString { + val boldStartIndex = fullText.indexOf(boldPart) + val boldEndIndex = boldStartIndex + boldPart.length + + if (boldStartIndex >= 0) { + // Text before bold part + if (boldStartIndex > 0) { + append(fullText.substring(0, boldStartIndex)) + } + + // Bold part + withStyle( + style = SpanStyle( + fontWeight = FontWeight.Medium + ) + ) { + append(boldPart) + } + + // Text after bold part + if (boldEndIndex < fullText.length) { + append(fullText.substring(boldEndIndex)) + } + } else { + // Fallback if pattern not found + append(fullText) + } + } +} + +interface InboxScreenListener { + fun onAccountClick(accountAddress: String) + fun onSignatureRequestClick(signRequestId: String, canUserSign: Boolean) + fun onJointAccountInvitationClick(invitation: JointAccountInvitationInboxItem) + fun onInfoClick() +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/InboxViewModel.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/InboxViewModel.kt new file mode 100644 index 000000000..69f8fd17d --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/InboxViewModel.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + * + */ + +package com.algorand.android.modules.inbox.allaccounts.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.algorand.android.modules.inbox.allaccounts.ui.model.InboxViewEvent +import com.algorand.android.modules.inbox.allaccounts.ui.model.InboxViewState +import com.algorand.android.modules.inbox.allaccounts.ui.usecase.InboxPreviewUseCase +import com.algorand.android.utils.launchIO +import com.algorand.wallet.remoteconfig.domain.model.FeatureToggle +import com.algorand.wallet.remoteconfig.domain.usecase.IsFeatureToggleEnabled +import com.algorand.wallet.viewmodel.EventDelegate +import com.algorand.wallet.viewmodel.EventViewModel +import com.algorand.wallet.viewmodel.StateDelegate +import com.algorand.wallet.viewmodel.StateViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import java.time.ZonedDateTime +import javax.inject.Inject + +@HiltViewModel +class InboxViewModel @Inject constructor( + private val inboxPreviewUseCase: InboxPreviewUseCase, + private val isFeatureToggleEnabled: IsFeatureToggleEnabled, + private val stateDelegate: StateDelegate, + private val eventDelegate: EventDelegate, + savedStateHandle: SavedStateHandle +) : ViewModel(), + StateViewModel by stateDelegate, + EventViewModel by eventDelegate { + + private val filterAccountAddress: String? = savedStateHandle[FILTER_ACCOUNT_ADDRESS_KEY] + + private var refreshJob: Job? = null + private var jointAccountAddressToOpen: String? = null + private var isJointAccountImportHandled = false + + init { + stateDelegate.setDefaultState(InboxViewState.Loading) + } + + fun initializePreview(jointAccountAddress: String? = null) { + jointAccountAddressToOpen = jointAccountAddress + refreshJob?.cancel() + refreshJob = viewModelScope.launchIO { + inboxPreviewUseCase.setLastOpenedTime(ZonedDateTime.now()) + inboxPreviewUseCase.refreshInbox() + fetchInboxPreview() + } + } + + private suspend fun fetchInboxPreview() { + inboxPreviewUseCase.getInboxViewState(filterAccountAddress) + .map { viewState -> + val isJointAccountEnabled = isFeatureToggleEnabled(FeatureToggle.JOINT_ACCOUNT.key) + if (isJointAccountEnabled) { + viewState + } else { + when (viewState) { + is InboxViewState.Content -> viewState.copy( + signatureRequestList = emptyList(), + jointAccountInvitationList = emptyList() + ) + else -> viewState + } + } + } + .distinctUntilChanged() + .collectLatest { viewState -> + stateDelegate.updateState { viewState } + handleJointAccountDeepLinkIfNeeded(viewState) + } + } + + private suspend fun handleJointAccountDeepLinkIfNeeded(viewState: InboxViewState) { + if (isJointAccountImportHandled) return + + val addressToOpen = jointAccountAddressToOpen ?: return + if (viewState !is InboxViewState.Content) return + + isJointAccountImportHandled = true + + val invitation = viewState.jointAccountInvitationList.firstOrNull { + it.accountAddress == addressToOpen + } + + if (invitation != null) { + eventDelegate.sendEvent(InboxViewEvent.NavigateToJointAccountInvitation(invitation)) + } else { + eventDelegate.sendEvent(InboxViewEvent.NavigateToJointAccountDetail(addressToOpen)) + } + } + + private companion object { + const val FILTER_ACCOUNT_ADDRESS_KEY = "filterAccountAddress" + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/mapper/InboxPreviewMapper.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/mapper/InboxPreviewMapper.kt new file mode 100644 index 000000000..3296c50a1 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/mapper/InboxPreviewMapper.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.allaccounts.ui.mapper + +import com.algorand.android.modules.inbox.allaccounts.ui.model.InboxPreview +import com.algorand.android.utils.ErrorResource +import com.algorand.android.utils.Event +import com.algorand.wallet.inbox.asset.domain.model.AssetInboxRequest +import com.algorand.wallet.inbox.domain.model.InboxMessages +import java.time.ZonedDateTime + +data class InboxPreviewParams( + val assetInboxList: List, + val addresses: List, + val inboxMessages: InboxMessages?, + val isLoading: Boolean, + val isEmptyStateVisible: Boolean, + val showError: Event? = null, + val onNavBack: Event? = null, + val lastOpenedTime: ZonedDateTime? = null, + val filterAccountAddress: String? = null, + val localAccountAddresses: List = emptyList() +) + +interface InboxPreviewMapper { + suspend operator fun invoke(params: InboxPreviewParams): InboxPreview + fun getInitialPreview(): InboxPreview +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/mapper/InboxPreviewMapperImpl.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/mapper/InboxPreviewMapperImpl.kt new file mode 100644 index 000000000..710eb7db1 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/mapper/InboxPreviewMapperImpl.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.allaccounts.ui.mapper + +import android.content.Context +import com.algorand.android.modules.accountcore.ui.usecase.GetAccountDisplayName +import com.algorand.android.modules.accountcore.ui.usecase.GetAccountIconDrawablePreview +import com.algorand.android.modules.inbox.allaccounts.domain.model.InboxWithAccount +import com.algorand.android.modules.inbox.allaccounts.domain.model.SignatureRequestInboxItem +import com.algorand.android.modules.inbox.allaccounts.ui.model.InboxPreview +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.model.JointAccountInvitationInboxItem +import com.algorand.wallet.inbox.asset.domain.model.AssetInboxRequest +import com.algorand.wallet.inbox.domain.model.InboxMessages +import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.ZonedDateTime +import javax.inject.Inject + +class InboxPreviewMapperImpl @Inject constructor( + private val getAccountDisplayName: GetAccountDisplayName, + private val getAccountIconDrawablePreview: GetAccountIconDrawablePreview, + private val signatureRequestInboxItemMapper: SignatureRequestInboxItemMapper, + private val jointAccountInvitationInboxItemMapper: JointAccountInvitationInboxItemMapper, + @ApplicationContext private val context: Context +) : InboxPreviewMapper { + + override suspend fun invoke(params: InboxPreviewParams): InboxPreview { + return InboxPreview( + isLoading = params.isLoading, + isEmptyStateVisible = params.isEmptyStateVisible, + showError = params.showError, + inboxWithAccountList = mapToInboxWithAccount(params.assetInboxList, params.addresses), + signatureRequestList = mapToSignatureRequestList( + params.inboxMessages, + params.lastOpenedTime, + params.localAccountAddresses + ), + jointAccountInvitationList = mapToJointAccountInvitationList( + params.inboxMessages, + params.lastOpenedTime + ), + filterAccountAddress = params.filterAccountAddress + ) + } + + override fun getInitialPreview(): InboxPreview = InboxPreview( + isLoading = true, + isEmptyStateVisible = false, + showError = null, + inboxWithAccountList = emptyList(), + signatureRequestList = emptyList(), + jointAccountInvitationList = emptyList() + ) + + private suspend fun mapToInboxWithAccount( + assetInboxList: List, + addresses: List + ): List = assetInboxList.mapNotNull { inbox -> + if (inbox.requestCount <= 0) return@mapNotNull null + val address = addresses.firstOrNull { it == inbox.address } ?: return@mapNotNull null + InboxWithAccount( + address = inbox.address, + requestCount = inbox.requestCount, + accountAddress = address, + accountDisplayName = getAccountDisplayName(address), + accountIconDrawablePreview = getAccountIconDrawablePreview(address) + ) + } + + private suspend fun mapToSignatureRequestList( + inboxMessages: InboxMessages?, + lastOpenedTime: ZonedDateTime?, + localAccountAddresses: List + ): List { + val signRequests = inboxMessages?.jointAccountSignRequests ?: return emptyList() + val currentBlockNumber = signatureRequestInboxItemMapper.getCurrentBlockNumber() + + return signRequests.mapNotNull { signRequest -> + signatureRequestInboxItemMapper.mapToSignatureRequestInboxItem( + signRequest, + context.resources, + lastOpenedTime, + currentBlockNumber, + localAccountAddresses + ) + } + } + + private suspend fun mapToJointAccountInvitationList( + inboxMessages: InboxMessages?, + lastOpenedTime: ZonedDateTime? + ): List { + val importRequests = inboxMessages?.jointAccountImportRequests ?: return emptyList() + return importRequests.mapNotNull { dto -> + jointAccountInvitationInboxItemMapper.mapToJointAccountInvitationInboxItem(dto, lastOpenedTime) + } + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/mapper/InboxViewStateMapper.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/mapper/InboxViewStateMapper.kt new file mode 100644 index 000000000..f3703d958 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/mapper/InboxViewStateMapper.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.allaccounts.ui.mapper + +import android.content.Context +import com.algorand.android.modules.accountcore.ui.usecase.GetAccountDisplayName +import com.algorand.android.modules.accountcore.ui.usecase.GetAccountIconDrawablePreview +import com.algorand.android.modules.inbox.allaccounts.domain.model.InboxWithAccount +import com.algorand.android.modules.inbox.allaccounts.domain.model.SignatureRequestInboxItem +import com.algorand.android.modules.inbox.allaccounts.ui.model.InboxViewState +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.model.JointAccountInvitationInboxItem +import com.algorand.wallet.inbox.asset.domain.model.AssetInboxRequest +import com.algorand.wallet.inbox.domain.model.InboxMessages +import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.ZonedDateTime +import javax.inject.Inject + +class InboxViewStateMapper @Inject constructor( + private val getAccountDisplayName: GetAccountDisplayName, + private val getAccountIconDrawablePreview: GetAccountIconDrawablePreview, + private val signatureRequestInboxItemMapper: SignatureRequestInboxItemMapper, + private val jointAccountInvitationInboxItemMapper: JointAccountInvitationInboxItemMapper, + @ApplicationContext private val context: Context +) { + + suspend fun mapToViewState( + assetInboxList: List, + addresses: List, + inboxMessages: InboxMessages?, + lastOpenedTime: ZonedDateTime?, + filterAccountAddress: String?, + localAccountAddresses: List + ): InboxViewState { + val inboxWithAccountList = mapToInboxWithAccount(assetInboxList, addresses) + val signatureRequestList = mapToSignatureRequestList(inboxMessages, lastOpenedTime, localAccountAddresses) + val jointAccountInvitationList = mapToJointAccountInvitationList(inboxMessages, lastOpenedTime) + + val isEmpty = inboxWithAccountList.isEmpty() && + signatureRequestList.isEmpty() && + jointAccountInvitationList.isEmpty() + + return if (isEmpty) { + InboxViewState.Empty + } else { + InboxViewState.Content( + inboxWithAccountList = inboxWithAccountList, + signatureRequestList = signatureRequestList, + jointAccountInvitationList = jointAccountInvitationList, + filterAccountAddress = filterAccountAddress + ) + } + } + + private suspend fun mapToInboxWithAccount( + assetInboxList: List, + addresses: List + ): List = assetInboxList.mapNotNull { inbox -> + if (inbox.requestCount <= 0) return@mapNotNull null + val address = addresses.firstOrNull { it == inbox.address } ?: return@mapNotNull null + InboxWithAccount( + address = inbox.address, + requestCount = inbox.requestCount, + accountAddress = address, + accountDisplayName = getAccountDisplayName(address), + accountIconDrawablePreview = getAccountIconDrawablePreview(address) + ) + } + + private suspend fun mapToSignatureRequestList( + inboxMessages: InboxMessages?, + lastOpenedTime: ZonedDateTime?, + localAccountAddresses: List + ): List { + val signRequests = inboxMessages?.jointAccountSignRequests ?: return emptyList() + val currentBlockNumber = signatureRequestInboxItemMapper.getCurrentBlockNumber() + + return signRequests.mapNotNull { signRequest -> + signatureRequestInboxItemMapper.mapToSignatureRequestInboxItem( + signRequest, + context.resources, + lastOpenedTime, + currentBlockNumber, + localAccountAddresses + ) + } + } + + private fun mapToJointAccountInvitationList( + inboxMessages: InboxMessages?, + lastOpenedTime: ZonedDateTime? + ): List { + val importRequests = inboxMessages?.jointAccountImportRequests ?: return emptyList() + return importRequests.mapNotNull { dto -> + jointAccountInvitationInboxItemMapper.mapToJointAccountInvitationInboxItem(dto, lastOpenedTime) + } + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/mapper/JointAccountInvitationInboxItemMapper.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/mapper/JointAccountInvitationInboxItemMapper.kt new file mode 100644 index 000000000..61828c0f2 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/mapper/JointAccountInvitationInboxItemMapper.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.allaccounts.ui.mapper + +import com.algorand.android.modules.addaccount.joint.creation.domain.exception.JointAccountValidationException +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.model.JointAccountInvitationInboxItem +import com.algorand.android.utils.getAlgorandMobileDateFormatter +import com.algorand.android.utils.parseFormattedDate +import com.algorand.android.utils.toShortenedAddress +import com.algorand.wallet.jointaccount.creation.domain.model.JointAccount +import com.algorand.wallet.utils.date.TimeProvider +import java.time.ZonedDateTime +import javax.inject.Inject + +class JointAccountInvitationInboxItemMapper @Inject constructor( + private val timeProvider: TimeProvider +) { + + fun mapToJointAccountInvitationInboxItem( + jointAccount: JointAccount, + lastOpenedTime: ZonedDateTime? + ): JointAccountInvitationInboxItem? { + val accountAddress = jointAccount.address ?: return null + val creationDatetimeString = jointAccount.creationDatetime ?: return null + + val dateFormatter = getAlgorandMobileDateFormatter() + val creationDateTime = creationDatetimeString.parseFormattedDate(dateFormatter) + ?: timeProvider.getZonedDateTimeNow() + + val now = timeProvider.getZonedDateTimeNow() + val nowInTimeMillis = now.toInstant().toEpochMilli() + val creationInTimeMillis = creationDateTime.toInstant().toEpochMilli() + val timeDifference = nowInTimeMillis - creationInTimeMillis + + val threshold = jointAccount.threshold ?: JointAccountValidationException.MIN_PARTICIPANTS + val participantAddresses = jointAccount.participantAddresses ?: emptyList() + + val isRead = isRead(creationDateTime, lastOpenedTime) + + return JointAccountInvitationInboxItem( + id = "${accountAddress}_$creationInTimeMillis", + accountAddress = accountAddress, + accountAddressShortened = accountAddress.toShortenedAddress(), + creationDateTime = creationDateTime, + timeDifference = timeDifference, + isRead = isRead, + threshold = threshold, + participantAddresses = participantAddresses + ) + } + + private fun isRead(creationDateTime: ZonedDateTime, lastOpenedTime: ZonedDateTime?): Boolean { + // If lastOpenedTime is null, mark as read (first time opening) + // Otherwise, mark as read if creation date is before or equal to last opened time + return lastOpenedTime == null || !creationDateTime.isAfter(lastOpenedTime) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/mapper/SignatureRequestInboxItemMapper.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/mapper/SignatureRequestInboxItemMapper.kt new file mode 100644 index 000000000..c3908b0b8 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/mapper/SignatureRequestInboxItemMapper.kt @@ -0,0 +1,250 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.allaccounts.ui.mapper + +import android.content.res.Resources +import android.text.format.DateUtils +import com.algorand.android.R +import com.algorand.android.models.Result +import com.algorand.android.modules.accountcore.ui.usecase.GetAccountIconDrawablePreview +import com.algorand.android.modules.inbox.allaccounts.domain.model.SignatureRequestInboxItem +import com.algorand.android.modules.transaction.domain.GetTransactionParams +import com.algorand.android.utils.getAlgorandMobileDateFormatter +import com.algorand.android.utils.parseFormattedDate +import com.algorand.android.utils.toShortenedAddress +import com.algorand.wallet.jointaccount.transaction.domain.model.JointSignRequest +import com.algorand.wallet.jointaccount.transaction.domain.model.SignRequestResponseType +import com.algorand.wallet.utils.date.RelativeTimeDifference +import com.algorand.wallet.utils.date.TimeProvider +import java.time.ZonedDateTime +import javax.inject.Inject + +class SignatureRequestInboxItemMapper @Inject constructor( + private val getAccountIconDrawablePreview: GetAccountIconDrawablePreview, + private val getTransactionParams: GetTransactionParams, + private val timeProvider: TimeProvider, + private val relativeTimeDifference: RelativeTimeDifference +) { + + suspend fun getCurrentBlockNumber(): Long? { + return (getTransactionParams() as? Result.Success)?.data?.lastRound + } + + suspend fun mapToSignatureRequestInboxItem( + jointSignRequestDTO: JointSignRequest, + resources: Resources, + lastOpenedTime: ZonedDateTime?, + currentBlockNumber: Long?, + localAccountAddresses: List + ): SignatureRequestInboxItem? { + val requiredData = extractRequiredData(jointSignRequestDTO) ?: return null + + val isExpired = isSignRequestExpired(jointSignRequestDTO) + val creationDateTime = getCreationDateTime(jointSignRequestDTO) + + return SignatureRequestInboxItem( + signRequestId = requiredData.signRequestId, + jointAccountAddress = requiredData.jointAccountAddress, + jointAccountAddressShortened = requiredData.jointAccountAddress.toShortenedAddress(), + accountIconDrawablePreview = getAccountIconDrawablePreview(requiredData.jointAccountAddress), + description = resources.getString( + R.string.signature_request_description, + requiredData.jointAccountAddress.toShortenedAddress() + ), + timeAgo = getTimeAgo(jointSignRequestDTO, resources, currentBlockNumber), + signedCount = getSignedCount(jointSignRequestDTO), + totalCount = requiredData.threshold, + timeLeft = if (isExpired) { + resources.getString(R.string.zero_minutes_short) + } else { + getTimeLeft(jointSignRequestDTO, resources) + }, + isRead = isRead(creationDateTime, lastOpenedTime), + isExpired = isExpired, + canUserSign = canUserSign( + jointSignRequestDTO, + requiredData.participantAddresses, + localAccountAddresses, + isExpired + ) + ) + } + + private fun extractRequiredData(dto: JointSignRequest): RequiredData? { + val jointAccount = dto.jointAccount + val signRequestId = dto.id + val address = jointAccount?.address + val threshold = jointAccount?.threshold + val participants = jointAccount?.participantAddresses + + return if (signRequestId != null && address != null && threshold != null && participants != null) { + RequiredData(signRequestId, address, threshold, participants) + } else { + null + } + } + + private fun canUserSign( + jointSignRequestDTO: JointSignRequest, + participantAddresses: List, + localAccountAddresses: List, + isExpired: Boolean + ): Boolean { + if (isExpired) return false + + val localParticipants = participantAddresses.filter { it in localAccountAddresses } + if (localParticipants.isEmpty()) return false + + val respondedAddresses = jointSignRequestDTO.transactionLists + ?.firstOrNull() + ?.responses + ?.filter { + it.response == SignRequestResponseType.SIGNED || + it.response == SignRequestResponseType.REJECTED + } + ?.mapNotNull { it.address } + .orEmpty() + + return localParticipants.any { it !in respondedAddresses } + } + + private fun isSignRequestExpired(dto: JointSignRequest): Boolean { + val expireDateTime = getExpireDateTime(dto) ?: return false + return timeProvider.getZonedDateTimeNow().isAfter(expireDateTime) + } + + private fun getSignedCount(dto: JointSignRequest): Int { + return dto.transactionLists + ?.flatMap { it.responses.orEmpty() } + ?.filter { it.response == SignRequestResponseType.SIGNED && !it.address.isNullOrBlank() } + ?.mapNotNull { it.address } + ?.toSet() + ?.size ?: 0 + } + + private fun getTimeAgo( + dto: JointSignRequest, + resources: Resources, + currentBlockNumber: Long? + ): String { + val transactionList = dto.transactionLists?.firstOrNull() + val firstValidBlock = transactionList?.firstValidBlock?.toLongOrNull() + + if (firstValidBlock != null && currentBlockNumber != null) { + val blocksSinceCreation = currentBlockNumber - firstValidBlock + val timeDifferenceMillis = blocksSinceCreation * BLOCK_TIME_MILLIS + + if (timeDifferenceMillis < 0) return resources.getString(R.string.just_now) + + val estimatedCreationDateTime = timeProvider.getZonedDateTimeNow() + .minusSeconds(timeDifferenceMillis / MILLIS_PER_SECOND) + return formatRelativeTime( + relativeTimeDifference.getRelativeTime(estimatedCreationDateTime, timeDifferenceMillis), + resources + ) + } + + val expireDateTime = getExpireDateTime(dto) ?: return "" + val estimatedCreationDateTime = expireDateTime.minusMinutes(VALIDITY_WINDOW_MINUTES) + val timeDifference = timeProvider.getCurrentTimeMillis() - + estimatedCreationDateTime.toInstant().toEpochMilli() + + if (timeDifference < 0) return resources.getString(R.string.just_now) + + return formatRelativeTime( + relativeTimeDifference.getRelativeTime(estimatedCreationDateTime, timeDifference), + resources + ) + } + + private fun formatRelativeTime( + relativeTime: RelativeTimeDifference.RelativeTime, + resources: Resources + ): String { + return when (relativeTime) { + is RelativeTimeDifference.RelativeTime.Now -> resources.getString(R.string.just_now) + is RelativeTimeDifference.RelativeTime.Minutes -> resources.getQuantityString( + R.plurals.min_ago, + relativeTime.value, + relativeTime.value.toString() + ) + is RelativeTimeDifference.RelativeTime.Hours -> resources.getQuantityString( + R.plurals.hours_ago, + relativeTime.value, + relativeTime.value.toString() + ) + is RelativeTimeDifference.RelativeTime.Days -> resources.getQuantityString( + R.plurals.days_ago, + relativeTime.value, + relativeTime.value.toString() + ) + is RelativeTimeDifference.RelativeTime.Date -> relativeTime.value + } + } + + private fun getTimeLeft(dto: JointSignRequest, resources: Resources): String? { + val expireDateTime = getExpireDateTime(dto) ?: return null + val timeDifferenceMillis = expireDateTime.toInstant().toEpochMilli() - + timeProvider.getCurrentTimeMillis() + return formatTimeLeft(timeDifferenceMillis, resources) + } + + private fun formatTimeLeft(millis: Long, resources: Resources): String { + if (millis <= THIRTY_SECONDS_MILLIS) return resources.getString(R.string.zero_minutes_short) + + return when { + millis < DateUtils.MINUTE_IN_MILLIS -> resources.getString(R.string.one_minute_short) + millis < DateUtils.HOUR_IN_MILLIS -> resources.getString( + R.string.minutes_short, + millis / DateUtils.MINUTE_IN_MILLIS + ) + millis < DateUtils.DAY_IN_MILLIS -> resources.getString( + R.string.hours_short, + millis / DateUtils.HOUR_IN_MILLIS + ) + else -> resources.getString(R.string.days_short, millis / DateUtils.DAY_IN_MILLIS) + } + } + + private fun getExpireDateTime(dto: JointSignRequest): ZonedDateTime? { + val expireDatetimeString = dto.expectedExpireDatetime + ?: dto.transactionLists?.firstOrNull()?.expectedExpireDatetime + ?: return null + return expireDatetimeString.parseFormattedDate(getAlgorandMobileDateFormatter()) + } + + private fun getCreationDateTime(dto: JointSignRequest): ZonedDateTime { + val creationDatetimeString = dto.jointAccount?.creationDatetime + ?: return timeProvider.getZonedDateTimeNow() + return creationDatetimeString.parseFormattedDate(getAlgorandMobileDateFormatter()) + ?: timeProvider.getZonedDateTimeNow() + } + + private fun isRead(creationDateTime: ZonedDateTime, lastOpenedTime: ZonedDateTime?): Boolean { + return lastOpenedTime == null || !creationDateTime.isAfter(lastOpenedTime) + } + + private data class RequiredData( + val signRequestId: String, + val jointAccountAddress: String, + val threshold: Int, + val participantAddresses: List + ) + + private companion object { + const val BLOCK_TIME_MILLIS = 3500L + const val MILLIS_PER_SECOND = 1000L + const val VALIDITY_WINDOW_MINUTES = 50L + const val THIRTY_SECONDS_MILLIS = 30_000L + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/model/InboxPreview.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/model/InboxPreview.kt new file mode 100644 index 000000000..5cc9e171d --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/model/InboxPreview.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.allaccounts.ui.model + +import com.algorand.android.modules.inbox.allaccounts.domain.model.InboxWithAccount +import com.algorand.android.modules.inbox.allaccounts.domain.model.SignatureRequestInboxItem +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.model.JointAccountInvitationInboxItem +import com.algorand.android.utils.ErrorResource +import com.algorand.android.utils.Event + +data class InboxPreview( + val isLoading: Boolean, + val isEmptyStateVisible: Boolean, + val showError: Event?, + val inboxWithAccountList: List, + val signatureRequestList: List = emptyList(), + val jointAccountInvitationList: List = emptyList(), + val filterAccountAddress: String? = null, + val jointAccountInvitationToOpen: Event? = null, + val jointAccountAddressToOpen: Event? = null +) diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/model/InboxViewEvent.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/model/InboxViewEvent.kt new file mode 100644 index 000000000..e81b86964 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/model/InboxViewEvent.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.allaccounts.ui.model + +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.model.JointAccountInvitationInboxItem + +sealed interface InboxViewEvent { + data class NavigateToJointAccountInvitation( + val invitation: JointAccountInvitationInboxItem + ) : InboxViewEvent + + data class NavigateToJointAccountDetail( + val accountAddress: String + ) : InboxViewEvent + + data class ShowError(val message: String) : InboxViewEvent +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/model/InboxViewState.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/model/InboxViewState.kt new file mode 100644 index 000000000..5a6c2bf4e --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/model/InboxViewState.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.allaccounts.ui.model + +import com.algorand.android.modules.inbox.allaccounts.domain.model.InboxWithAccount +import com.algorand.android.modules.inbox.allaccounts.domain.model.SignatureRequestInboxItem +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.model.JointAccountInvitationInboxItem + +sealed interface InboxViewState { + data object Loading : InboxViewState + + data object Empty : InboxViewState + + data class Content( + val inboxWithAccountList: List, + val signatureRequestList: List, + val jointAccountInvitationList: List, + val filterAccountAddress: String? = null + ) : InboxViewState + + data class Error(val message: String? = null) : InboxViewState +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/preview/InboxScreenPreview.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/preview/InboxScreenPreview.kt new file mode 100644 index 000000000..39b9b92a2 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/preview/InboxScreenPreview.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.allaccounts.ui.preview + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.algorand.android.modules.accountcore.ui.model.AccountDisplayName +import com.algorand.android.modules.accountcore.ui.usecase.AccountIconDrawablePreviews +import com.algorand.android.modules.inbox.allaccounts.domain.model.InboxWithAccount +import com.algorand.android.modules.inbox.allaccounts.domain.model.SignatureRequestInboxItem +import com.algorand.android.modules.inbox.allaccounts.ui.InboxScreen +import com.algorand.android.modules.inbox.allaccounts.ui.InboxScreenListener +import com.algorand.android.modules.inbox.allaccounts.ui.model.InboxPreview +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.model.JointAccountInvitationInboxItem +import com.algorand.android.ui.compose.preview.PeraPreviewLightDark +import com.algorand.android.ui.compose.theme.ColorPalette +import com.algorand.android.ui.compose.theme.PeraTheme +import kotlinx.coroutines.flow.MutableStateFlow + +@PeraPreviewLightDark +@Composable +fun InboxScreenPreview() { + PeraTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background( + if (isSystemInDarkTheme()) { + ColorPalette.Gray.V900 + } else { + ColorPalette.White.Default + } + ) + ) { + InboxScreen( + viewStateFlow = MutableStateFlow(getMockPreview()), + listener = object : InboxScreenListener { + override fun onAccountClick(accountAddress: String) = Unit + override fun onSignatureRequestClick(signRequestId: String, canUserSign: Boolean) = Unit + override fun onJointAccountInvitationClick(invitation: JointAccountInvitationInboxItem) = Unit + override fun onInfoClick() = Unit + } + ) + } + } +} + +private fun getMockPreview() = InboxPreview( + isLoading = false, + isEmptyStateVisible = false, + showError = null, + inboxWithAccountList = getMockAccounts(), + signatureRequestList = getMockSignatureRequests() +) + +private fun getMockAccounts(): List { + return listOf( + InboxWithAccount( + address = "QKZ6V2...2IHHJA", + requestCount = 3, + accountDisplayName = AccountDisplayName( + accountAddress = "QKZ6V2...2IHHJA", + primaryDisplayName = "QKZ6V2...2IHHJA", + secondaryDisplayName = null + ), + accountAddress = "QKZ6V2...2IHHJA", + accountIconDrawablePreview = AccountIconDrawablePreviews.getDefaultIconDrawablePreview() + ), + InboxWithAccount( + address = "DUA4...2ETI", + requestCount = 1, + accountDisplayName = AccountDisplayName( + accountAddress = "DUA4...2ETI", + primaryDisplayName = "Ledger Account", + secondaryDisplayName = "DUA4...2ETI" + ), + accountAddress = "DUA4...2ETI", + accountIconDrawablePreview = AccountIconDrawablePreviews.getLedgerBleDrawable() + ) + ) +} + +private fun getMockSignatureRequests(): List { + return listOf( + SignatureRequestInboxItem( + signRequestId = "mock-sign-request-id-1", + jointAccountAddress = "QKZ6V2...2IHHJA", + jointAccountAddressShortened = "QKZ6V2...2IHHJA", + accountIconDrawablePreview = AccountIconDrawablePreviews.getJointDrawable(), + description = "Signature request to sign for QKZ6V2...2IHHJA", + timeAgo = "2 hours ago", + signedCount = 1, + totalCount = 2, + timeLeft = "52m" + ) + ) +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/usecase/InboxPreviewUseCase.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/usecase/InboxPreviewUseCase.kt new file mode 100644 index 000000000..0cf5ada91 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/allaccounts/ui/usecase/InboxPreviewUseCase.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.allaccounts.ui.usecase + +import com.algorand.android.modules.inbox.allaccounts.ui.mapper.InboxPreviewMapper +import com.algorand.android.modules.inbox.allaccounts.ui.mapper.InboxPreviewParams +import com.algorand.android.modules.inbox.allaccounts.ui.mapper.InboxViewStateMapper +import com.algorand.android.modules.inbox.allaccounts.ui.model.InboxPreview +import com.algorand.android.modules.inbox.allaccounts.ui.model.InboxViewState +import com.algorand.android.modules.inbox.data.local.InboxLastOpenedTimeLocalSource +import com.algorand.android.utils.parseFormattedDate +import com.algorand.wallet.inbox.asset.domain.model.AssetInboxRequest +import com.algorand.wallet.inbox.domain.model.InboxMessages +import com.algorand.wallet.inbox.domain.usecase.GetInboxMessagesFlow +import com.algorand.wallet.inbox.domain.usecase.GetInboxValidAddresses +import com.algorand.wallet.inbox.domain.usecase.RefreshInboxCache +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +class InboxPreviewUseCase @Inject constructor( + private val inboxPreviewMapper: InboxPreviewMapper, + private val inboxViewStateMapper: InboxViewStateMapper, + private val getInboxValidAddresses: GetInboxValidAddresses, + private val getInboxMessagesFlow: GetInboxMessagesFlow, + private val refreshInboxCache: RefreshInboxCache, + private val inboxLastOpenedTimeLocalSource: InboxLastOpenedTimeLocalSource +) { + + fun getInitialPreview(): InboxPreview { + return inboxPreviewMapper.getInitialPreview() + } + + fun setLastOpenedTime(zonedDateTime: ZonedDateTime) { + val lastOpenedTimeAsString = zonedDateTime.format(DateTimeFormatter.ISO_DATE_TIME) + inboxLastOpenedTimeLocalSource.saveData(lastOpenedTimeAsString) + } + + fun getLastOpenedTime(): ZonedDateTime? { + val lastOpenedTimeAsString = inboxLastOpenedTimeLocalSource.getDataOrNull() + return lastOpenedTimeAsString?.parseFormattedDate(DateTimeFormatter.ISO_DATE_TIME) + } + + fun getInboxPreview(filterAccountAddress: String? = null): Flow { + return getInboxMessagesFlow().map { inboxMessages -> + val allAccountAddresses = getInboxValidAddresses() + val lastOpenedTime = getLastOpenedTime() + + if (allAccountAddresses.isEmpty()) { + return@map createInboxPreview( + emptyList(), allAccountAddresses, null, lastOpenedTime, + filterAccountAddress, allAccountAddresses + ) + } + + // Apply local filtering if filterAccountAddress is provided + val filteredInboxMessages = filterInboxMessages(inboxMessages, filterAccountAddress) + val assetInboxRequests = parseAssetInboxes(filteredInboxMessages) + + val displayAddresses = if (filterAccountAddress != null) { + listOf(filterAccountAddress) + } else { + allAccountAddresses + } + + createInboxPreview( + assetInboxRequests, displayAddresses, filteredInboxMessages, + lastOpenedTime, filterAccountAddress, allAccountAddresses + ) + } + } + + suspend fun refreshInbox() { + refreshInboxCache() + } + + private fun filterInboxMessages( + inboxMessages: InboxMessages?, + filterAccountAddress: String? + ): InboxMessages? { + if (filterAccountAddress == null || inboxMessages == null) return inboxMessages + + return InboxMessages( + jointAccountImportRequests = inboxMessages.jointAccountImportRequests?.filter { jointAccount -> + // Joint account invitations are sent to participants + jointAccount.participantAddresses?.contains(filterAccountAddress) == true + }, + jointAccountSignRequests = inboxMessages.jointAccountSignRequests?.filter { signRequest -> + // Sign requests should show: + // 1. On the joint account itself + // 2. On participant accounts (so they can see/sign the request) + val isJointAccount = signRequest.jointAccount?.address == filterAccountAddress + val isParticipant = + signRequest.jointAccount?.participantAddresses?.contains(filterAccountAddress) == true + isJointAccount || isParticipant + }, + assetInboxes = inboxMessages.assetInboxes?.filter { assetInbox -> + assetInbox.address == filterAccountAddress + } + ) + } + + private fun parseAssetInboxes( + inboxMessages: InboxMessages? + ): List { + return inboxMessages?.assetInboxes?.map { assetInbox -> + AssetInboxRequest( + address = assetInbox.address, + requestCount = assetInbox.requestCount + ) + } ?: emptyList() + } + + private suspend fun createInboxPreview( + assetInboxList: List, + addresses: List, + inboxMessages: InboxMessages?, + lastOpenedTime: ZonedDateTime?, + filterAccountAddress: String?, + localAccountAddresses: List + ): InboxPreview { + val hasAssetInboxRequests = assetInboxList.any { it.requestCount > 0 } + val hasSignatureRequests = inboxMessages?.jointAccountSignRequests?.isNotEmpty() == true + val hasJointAccountInvitations = inboxMessages?.jointAccountImportRequests?.isNotEmpty() == true + + return inboxPreviewMapper( + InboxPreviewParams( + assetInboxList = assetInboxList, + addresses = addresses, + inboxMessages = inboxMessages, + isLoading = false, + isEmptyStateVisible = !hasAssetInboxRequests && !hasSignatureRequests && !hasJointAccountInvitations, + lastOpenedTime = lastOpenedTime, + filterAccountAddress = filterAccountAddress, + localAccountAddresses = localAccountAddresses + ) + ) + } + + fun getInboxViewState(filterAccountAddress: String? = null): Flow { + return getInboxMessagesFlow().map { inboxMessages -> + val allAccountAddresses = getInboxValidAddresses() + val lastOpenedTime = getLastOpenedTime() + + if (allAccountAddresses.isEmpty()) { + return@map InboxViewState.Empty + } + + val filteredInboxMessages = filterInboxMessages(inboxMessages, filterAccountAddress) + val assetInboxRequests = parseAssetInboxes(filteredInboxMessages) + + val displayAddresses = if (filterAccountAddress != null) { + listOf(filterAccountAddress) + } else { + allAccountAddresses + } + + inboxViewStateMapper.mapToViewState( + assetInboxList = assetInboxRequests, + addresses = displayAddresses, + inboxMessages = filteredInboxMessages, + lastOpenedTime = lastOpenedTime, + filterAccountAddress = filterAccountAddress, + localAccountAddresses = allAccountAddresses + ) + } + } + + fun getJointAccountInboxCountFlow(): Flow { + return getInboxMessagesFlow().map { inboxMessages -> + val signRequestCount = inboxMessages?.jointAccountSignRequests?.size ?: 0 + val importRequestCount = inboxMessages?.jointAccountImportRequests?.size ?: 0 + signRequestCount + importRequestCount + } + } +} diff --git a/app/src/main/kotlin/com/algorand/android/sharedpref/InboxLastOpenedTimeLocalSource.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/data/local/InboxLastOpenedTimeLocalSource.kt similarity index 79% rename from app/src/main/kotlin/com/algorand/android/sharedpref/InboxLastOpenedTimeLocalSource.kt rename to app/src/main/kotlin/com/algorand/android/modules/inbox/data/local/InboxLastOpenedTimeLocalSource.kt index 28cb4377c..2a8de653c 100644 --- a/app/src/main/kotlin/com/algorand/android/sharedpref/InboxLastOpenedTimeLocalSource.kt +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/data/local/InboxLastOpenedTimeLocalSource.kt @@ -10,13 +10,12 @@ * limitations under the License */ -package com.algorand.android.sharedpref +package com.algorand.android.modules.inbox.data.local import com.algorand.wallet.foundation.cache.PersistentCache -import javax.inject.Inject // ISO-8601 ISO_DATE_TIME -class InboxLastOpenedTimeLocalSource @Inject constructor( +class InboxLastOpenedTimeLocalSource( private val cache: PersistentCache ) { @@ -28,7 +27,11 @@ class InboxLastOpenedTimeLocalSource @Inject constructor( return cache.get() } - fun saveData(data: String) { - cache.put(data) + fun saveData(data: String?) { + if (data != null) { + cache.put(data) + } else { + cache.clear() + } } } diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/JointAccountInvitationDetailFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/JointAccountInvitationDetailFragment.kt index 6a1bb2861..945b816b9 100644 --- a/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/JointAccountInvitationDetailFragment.kt +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/JointAccountInvitationDetailFragment.kt @@ -16,28 +16,95 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.material3.Text -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.navArgs +import com.algorand.android.HomeNavigationDirections +import com.algorand.android.R +import com.algorand.android.core.DaggerBaseFragment +import com.algorand.android.models.FragmentConfiguration +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.model.JointAccountInvitationDetailViewState +import com.algorand.android.ui.compose.extensions.createComposeView import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.ui.compose.widget.progress.PeraCircularProgressIndicator import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch -// TODO: Implement JointAccountInvitationDetailFragment @AndroidEntryPoint -class JointAccountInvitationDetailFragment : Fragment() { +class JointAccountInvitationDetailFragment : DaggerBaseFragment(0), + JointAccountInvitationDetailScreenListener { + + private val viewModel: JointAccountInvitationDetailViewModel by viewModels() + + private val args: JointAccountInvitationDetailFragmentArgs by navArgs() + + override val fragmentConfiguration = FragmentConfiguration() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - return ComposeView(requireContext()).apply { - setContent { - PeraTheme { - // TODO: Implement joint account invitation detail screen - Text("Joint Account Invitation Detail - TODO") + return createComposeView { + val viewState by viewModel.viewStateFlow.collectAsState() + + PeraTheme { + when (val state = viewState) { + is JointAccountInvitationDetailViewState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + PeraCircularProgressIndicator() + } + } + is JointAccountInvitationDetailViewState.Content -> { + JointAccountInvitationDetailScreen( + invitation = state.invitation, + accountDisplayNames = state.accountDisplayNames, + accountIcons = state.accountIcons, + listener = this@JointAccountInvitationDetailFragment + ) + } } } } } + + override fun onBackClick() { + navBack() + } + + override fun onAcceptClick() { + navToNameJointAccount(args.invitationNavArgs.threshold) + } + + override fun onRejectClick() { + viewLifecycleOwner.lifecycleScope.launch { + val success = viewModel.rejectInvitation() + if (!success) { + showGlobalError(getString(R.string.an_error_occurred)) + } + navBack() + } + } + + override fun onCopyAddress(address: String) { + onAccountAddressCopied(address) + } + + private fun navToNameJointAccount(threshold: Int) { + nav( + HomeNavigationDirections.actionGlobalToNameJointAccountFragment( + threshold = threshold, + participantAddresses = args.invitationNavArgs.participantAddresses.toTypedArray() + ) + ) + } } diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/JointAccountInvitationDetailScreen.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/JointAccountInvitationDetailScreen.kt new file mode 100644 index 000000000..5d9b08357 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/JointAccountInvitationDetailScreen.kt @@ -0,0 +1,378 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.jointaccountinvitation.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.algorand.android.R +import com.algorand.android.models.AccountIconResource +import com.algorand.android.modules.accountcore.ui.model.AccountDisplayName +import com.algorand.android.modules.accounticon.ui.model.AccountIconDrawablePreview +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.model.JointAccountInvitationInboxItem +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.ui.compose.widget.AccountIcon +import com.algorand.android.ui.compose.widget.PeraToolbar +import com.algorand.android.ui.compose.widget.PeraToolbarIcon +import com.algorand.android.ui.compose.widget.button.PeraPrimaryButton +import com.algorand.android.ui.compose.widget.button.PeraSecondaryButton +import com.algorand.android.ui.compose.widget.modifier.clickableNoRipple + +@Composable +fun JointAccountInvitationDetailScreen( + invitation: JointAccountInvitationInboxItem, + accountDisplayNames: Map, + accountIcons: Map, + listener: JointAccountInvitationDetailScreenListener +) { + Column( + modifier = Modifier.fillMaxSize() + ) { + ToolbarSection( + accountAddressShortened = invitation.accountAddressShortened, + onBackClick = listener::onBackClick + ) + + ScrollableContentSection( + modifier = Modifier.weight(1f), + invitation = invitation, + accountDisplayNames = accountDisplayNames, + accountIcons = accountIcons, + listener = listener + ) + + BottomActionsSection( + onRejectClick = listener::onRejectClick, + onAcceptClick = listener::onAcceptClick + ) + } +} + +@Composable +private fun ToolbarSection( + accountAddressShortened: String, + onBackClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) { + PeraToolbar( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.joint_account), + startContainer = { + PeraToolbarIcon( + iconResId = R.drawable.ic_left_arrow, + modifier = Modifier.clickableNoRipple(onClick = onBackClick) + ) + } + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = accountAddressShortened, + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.gray, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun ScrollableContentSection( + modifier: Modifier = Modifier, + invitation: JointAccountInvitationInboxItem, + accountDisplayNames: Map, + accountIcons: Map, + listener: JointAccountInvitationDetailScreenListener +) { + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + ) { + Spacer(modifier = Modifier.height(24.dp)) + + InformationCard( + threshold = invitation.threshold, + participantCount = invitation.participantAddresses.size + ) + + Spacer(modifier = Modifier.height(32.dp)) + + ParticipantsSection( + participantAddresses = invitation.participantAddresses, + accountDisplayNames = accountDisplayNames, + accountIcons = accountIcons, + onCopyAddress = listener::onCopyAddress + ) + } +} + +@Composable +private fun BottomActionsSection( + onRejectClick: () -> Unit, + onAcceptClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(PeraTheme.colors.background.primary) + .padding(24.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + PeraSecondaryButton( + modifier = Modifier.weight(1f), + text = stringResource(R.string.ignore), + onClick = onRejectClick + ) + PeraPrimaryButton( + modifier = Modifier.weight(1f), + text = stringResource(R.string.add_to_accounts), + onClick = onAcceptClick + ) + } + } +} + +@Composable +private fun InformationCard( + threshold: Int, + participantCount: Int +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = PeraTheme.colors.layer.grayLighter, + shape = RoundedCornerShape(16.dp) + ) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + NumberOfAccountsRow(participantCount = participantCount) + + ThresholdRow(threshold = threshold) + } +} + +@Composable +private fun ThresholdRow(threshold: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp) + ) { + Text( + text = stringResource(R.string.threshold), + style = PeraTheme.typography.body.regular.sans, + color = PeraTheme.colors.text.main + ) + Text( + text = stringResource(R.string.minimum_number_of_accounts), + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.gray + ) + } + + Text( + text = threshold.toString(), + style = PeraTheme.typography.title.small.sansMedium, + color = PeraTheme.colors.text.main + ) + } +} + +@Composable +private fun NumberOfAccountsRow(participantCount: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.number_of_accounts), + style = PeraTheme.typography.body.regular.sans, + color = PeraTheme.colors.text.main + ) + Text( + text = stringResource(R.string.you_included), + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.gray + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AccountIcon( + modifier = Modifier.size(32.dp), + iconDrawablePreview = AccountIconDrawablePreview( + backgroundColorResId = AccountIconResource.JOINT.backgroundColorResId, + iconTintResId = AccountIconResource.JOINT.iconTintResId, + iconResId = AccountIconResource.JOINT.iconResId + ) + ) + Text( + text = participantCount.toString(), + style = PeraTheme.typography.title.small.sansMedium, + color = PeraTheme.colors.text.grayLighter + ) + } + } +} + +@Composable +private fun ParticipantsSection( + participantAddresses: List, + accountDisplayNames: Map, + accountIcons: Map, + onCopyAddress: (String) -> Unit +) { + Text( + text = stringResource(R.string.accounts_with_count, participantAddresses.size), + style = PeraTheme.typography.body.regular.sansMedium, + color = PeraTheme.colors.text.main + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Column( + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + participantAddresses.forEachIndexed { index, address -> + ParticipantItem( + address = address, + accountDisplayName = accountDisplayNames[address], + accountIcon = accountIcons[address], + onCopyAddress = onCopyAddress + ) + if (index < participantAddresses.size - 1) { + Spacer(modifier = Modifier.height(0.dp)) + Divider() + } + } + } +} + +@Composable +private fun Divider() { + Spacer( + modifier = Modifier + .fillMaxWidth() + .padding(start = 56.dp) + .height(1.dp) + .background(PeraTheme.colors.layer.grayLighter) + ) +} + +@Composable +private fun ParticipantItem( + address: String, + accountDisplayName: AccountDisplayName?, + accountIcon: AccountIconDrawablePreview?, + onCopyAddress: (String) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(76.dp) + .background( + color = PeraTheme.colors.background.primary + ) + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.width(16.dp)) + + AccountIcon( + modifier = Modifier.size(40.dp), + iconDrawablePreview = accountIcon ?: AccountIconDrawablePreview( + backgroundColorResId = AccountIconResource.JOINT.backgroundColorResId, + iconTintResId = AccountIconResource.JOINT.iconTintResId, + iconResId = AccountIconResource.JOINT.iconResId + ) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = accountDisplayName?.primaryDisplayName ?: address, + style = PeraTheme.typography.body.regular.sans, + color = PeraTheme.colors.text.main + ) + accountDisplayName?.secondaryDisplayName?.let { + Text( + text = it, + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.grayLighter + ) + } + } + + Icon( + modifier = Modifier + .size(24.dp) + .clickableNoRipple { onCopyAddress(address) }, + painter = painterResource(R.drawable.ic_copy), + contentDescription = stringResource(R.string.copy_address), + tint = PeraTheme.colors.text.grayLighter + ) + + Spacer(modifier = Modifier.width(16.dp)) + } +} + +interface JointAccountInvitationDetailScreenListener { + fun onBackClick() + fun onAcceptClick() + fun onRejectClick() + fun onCopyAddress(address: String) +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/JointAccountInvitationDetailViewModel.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/JointAccountInvitationDetailViewModel.kt new file mode 100644 index 000000000..2e4f601fb --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/JointAccountInvitationDetailViewModel.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.jointaccountinvitation.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.algorand.android.modules.accountcore.ui.usecase.GetAccountDisplayName +import com.algorand.android.modules.accountcore.ui.usecase.GetAccountIconDrawablePreview +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.model.JointAccountInvitationDetailNavArgs +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.model.JointAccountInvitationDetailViewState +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.model.JointAccountInvitationInboxItem +import com.algorand.android.utils.launchIO +import com.algorand.wallet.deviceregistration.domain.usecase.GetSelectedNodeDeviceId +import com.algorand.wallet.inbox.domain.repository.InboxApiRepository +import com.algorand.wallet.inbox.domain.usecase.RefreshInboxCache +import com.algorand.wallet.utils.date.TimeProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@HiltViewModel +class JointAccountInvitationDetailViewModel @Inject constructor( + private val getAccountDisplayName: GetAccountDisplayName, + private val getAccountIconDrawablePreview: GetAccountIconDrawablePreview, + private val getSelectedNodeDeviceId: GetSelectedNodeDeviceId, + private val inboxApiRepository: InboxApiRepository, + private val refreshInboxCache: RefreshInboxCache, + private val timeProvider: TimeProvider, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val navArgs: JointAccountInvitationDetailNavArgs = + checkNotNull(savedStateHandle[INVITATION_NAV_ARGS_KEY]) + + private val invitation = createInvitation() + + private val _viewStateFlow = MutableStateFlow( + JointAccountInvitationDetailViewState.Loading + ) + val viewStateFlow: StateFlow = _viewStateFlow.asStateFlow() + + init { + loadAccountDetails() + } + + private fun createInvitation(): JointAccountInvitationInboxItem { + val creationTime = timeProvider.getZonedDateTimeNow() + return JointAccountInvitationInboxItem( + id = "${navArgs.accountAddress}_${creationTime.toInstant().toEpochMilli()}", + accountAddress = navArgs.accountAddress, + accountAddressShortened = navArgs.accountAddressShortened, + creationDateTime = creationTime, + timeDifference = 0L, + isRead = false, + threshold = navArgs.threshold, + participantAddresses = navArgs.participantAddresses + ) + } + + private fun loadAccountDetails() { + viewModelScope.launchIO { + val allAddresses = listOf(navArgs.accountAddress) + navArgs.participantAddresses + val displayNames = allAddresses.associateWith { address -> + getAccountDisplayName(address) + } + val icons = allAddresses.associateWith { address -> + getAccountIconDrawablePreview(address) + } + _viewStateFlow.value = JointAccountInvitationDetailViewState.Content( + invitation = invitation, + accountDisplayNames = displayNames, + accountIcons = icons + ) + } + } + + suspend fun rejectInvitation(): Boolean { + return try { + val deviceId = getSelectedNodeDeviceId()?.toLongOrNull() + if (deviceId != null) { + inboxApiRepository.deleteJointInvitationNotification(deviceId, navArgs.accountAddress) + } + refreshInboxCache() + true + } catch (e: Exception) { + false + } + } + + private companion object { + const val INVITATION_NAV_ARGS_KEY = "invitationNavArgs" + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/model/JointAccountInvitationDetailNavArgs.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/model/JointAccountInvitationDetailNavArgs.kt index 89069f4d2..f5d01e76f 100644 --- a/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/model/JointAccountInvitationDetailNavArgs.kt +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/model/JointAccountInvitationDetailNavArgs.kt @@ -15,10 +15,10 @@ package com.algorand.android.modules.inbox.jointaccountinvitation.ui.model import android.os.Parcelable import kotlinx.parcelize.Parcelize -// TODO: Implement JointAccountInvitationDetailNavArgs @Parcelize data class JointAccountInvitationDetailNavArgs( val accountAddress: String, + val accountAddressShortened: String, val threshold: Int, val participantAddresses: List ) : Parcelable diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/model/JointAccountInvitationDetailViewState.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/model/JointAccountInvitationDetailViewState.kt new file mode 100644 index 000000000..5875961e8 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/model/JointAccountInvitationDetailViewState.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.jointaccountinvitation.ui.model + +import com.algorand.android.modules.accountcore.ui.model.AccountDisplayName +import com.algorand.android.modules.accounticon.ui.model.AccountIconDrawablePreview + +sealed interface JointAccountInvitationDetailViewState { + + data object Loading : JointAccountInvitationDetailViewState + + data class Content( + val invitation: JointAccountInvitationInboxItem, + val accountDisplayNames: Map, + val accountIcons: Map + ) : JointAccountInvitationDetailViewState +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/model/JointAccountInvitationInboxItem.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/model/JointAccountInvitationInboxItem.kt new file mode 100644 index 000000000..1366b3fbe --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/model/JointAccountInvitationInboxItem.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.jointaccountinvitation.ui.model + +import java.time.ZonedDateTime + +data class JointAccountInvitationInboxItem( + val id: String, + val accountAddress: String, + val accountAddressShortened: String, + val creationDateTime: ZonedDateTime, + val timeDifference: Long, + val isRead: Boolean, + val threshold: Int, + val participantAddresses: List +) diff --git a/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/preview/JointAccountInvitationDetailScreenPreview.kt b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/preview/JointAccountInvitationDetailScreenPreview.kt new file mode 100644 index 000000000..a5a60fe88 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/preview/JointAccountInvitationDetailScreenPreview.kt @@ -0,0 +1,145 @@ +@file:Suppress("EmptyFunctionBlock", "Unused") +/* + * Copyright 2022-2025 Pera Wallet, LDA + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.algorand.android.modules.inbox.jointaccountinvitation.ui.preview + +import androidx.compose.runtime.Composable +import com.algorand.android.modules.accountcore.ui.model.AccountDisplayName +import com.algorand.android.modules.accountcore.ui.usecase.AccountIconDrawablePreviews +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.JointAccountInvitationDetailScreen +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.JointAccountInvitationDetailScreenListener +import com.algorand.android.modules.inbox.jointaccountinvitation.ui.model.JointAccountInvitationInboxItem +import com.algorand.android.ui.compose.preview.PeraPreviewLightDark +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.utils.toShortenedAddress +import java.time.ZonedDateTime + +@PeraPreviewLightDark +@Composable +fun JointAccountInvitationDetailScreenPreview() { + PeraTheme { + val listener = object : JointAccountInvitationDetailScreenListener { + override fun onBackClick() {} + override fun onAcceptClick() {} + override fun onRejectClick() {} + override fun onCopyAddress(address: String) {} + } + + val invitation = JointAccountInvitationInboxItem( + id = "preview_invitation_1", + accountAddress = "DUA4ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ2345678902ETI", + accountAddressShortened = "DUA4...2ETI", + creationDateTime = ZonedDateTime.now(), + timeDifference = 0L, + isRead = false, + threshold = 2, + participantAddresses = listOf( + "HZQ73CABCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890PSDZZE", + "tahir.algo", + "CNSW64ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890C4HNPI" + ) + ) + + val accountDisplayNames = mapOf( + invitation.accountAddress to AccountDisplayName( + accountAddress = invitation.accountAddress, + primaryDisplayName = invitation.accountAddressShortened, + secondaryDisplayName = null + ), + invitation.participantAddresses[0] to AccountDisplayName( + accountAddress = invitation.participantAddresses[0], + primaryDisplayName = "HZQ73C...PSDZZE", + secondaryDisplayName = "Joseph" + ), + invitation.participantAddresses[1] to AccountDisplayName( + accountAddress = invitation.participantAddresses[1], + primaryDisplayName = "tahir.algo", + secondaryDisplayName = "DUA4...2ETI" + ), + invitation.participantAddresses[2] to AccountDisplayName( + accountAddress = invitation.participantAddresses[2], + primaryDisplayName = "CNSW64...C4HNPI", + secondaryDisplayName = null + ) + ) + + val accountIcons = mapOf( + invitation.accountAddress to AccountIconDrawablePreviews.getDefaultIconDrawablePreview(), + invitation.participantAddresses[0] to AccountIconDrawablePreviews.getDefaultIconDrawablePreview(), + invitation.participantAddresses[1] to AccountIconDrawablePreviews.getDefaultIconDrawablePreview(), + invitation.participantAddresses[2] to AccountIconDrawablePreviews.getDefaultIconDrawablePreview() + ) + + JointAccountInvitationDetailScreen( + invitation = invitation, + accountDisplayNames = accountDisplayNames, + accountIcons = accountIcons, + listener = listener + ) + } +} + +@PeraPreviewLightDark +@Composable +fun JointAccountInvitationDetailScreenWithManyParticipantsPreview() { + PeraTheme { + val listener = object : JointAccountInvitationDetailScreenListener { + override fun onBackClick() {} + override fun onAcceptClick() {} + override fun onRejectClick() {} + override fun onCopyAddress(address: String) {} + } + + val invitation = JointAccountInvitationInboxItem( + id = "preview_invitation_2", + accountAddress = "DUA4ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ2345678902ETI", + accountAddressShortened = "DUA4...2ETI", + creationDateTime = ZonedDateTime.now(), + timeDifference = 0L, + isRead = false, + threshold = 2, + participantAddresses = listOf( + "HZQ73CABCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890PSDZZE", + "JHF7ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890VE2A", + "KLMN8ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890XYZ1", + "NOPQ9ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABC2", + "RSTU0ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890DEF3" + ) + ) + + val accountDisplayNames = invitation.participantAddresses.associateWith { address -> + AccountDisplayName( + accountAddress = address, + primaryDisplayName = address.toShortenedAddress(), + secondaryDisplayName = null + ) + } + mapOf( + invitation.accountAddress to AccountDisplayName( + accountAddress = invitation.accountAddress, + primaryDisplayName = invitation.accountAddressShortened, + secondaryDisplayName = null + ) + ) + + val accountIcons = (listOf(invitation.accountAddress) + invitation.participantAddresses).associateWith { + AccountIconDrawablePreviews.getDefaultIconDrawablePreview() + } + + JointAccountInvitationDetailScreen( + invitation = invitation, + accountDisplayNames = accountDisplayNames, + accountIcons = accountIcons, + listener = listener + ) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/ui/webview/bridge/usecase/GetGetAddressesWebResponseUseCase.kt b/app/src/main/kotlin/com/algorand/android/ui/webview/bridge/usecase/GetGetAddressesWebResponseUseCase.kt index 56407a932..61047fda9 100644 --- a/app/src/main/kotlin/com/algorand/android/ui/webview/bridge/usecase/GetGetAddressesWebResponseUseCase.kt +++ b/app/src/main/kotlin/com/algorand/android/ui/webview/bridge/usecase/GetGetAddressesWebResponseUseCase.kt @@ -53,7 +53,6 @@ internal class GetGetAddressesWebResponseUseCase @Inject constructor( AccountType.NoAuth -> "NoAuth" AccountType.Rekeyed -> "Rekeyed" AccountType.RekeyedAuth -> "RekeyedAuth" - AccountType.Joint -> "Joint" } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0be52ec93..e605e5f9e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1246,6 +1246,16 @@ Increase Decrease + + + + + 0m + 1m + %1$dm + %1$dh + %1$dd +