Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
53f2564
chore(multisig): Miscellaneous updates and fixes
yasin-ce Jan 20, 2026
cd2fcb7
feat(multisig): Implement joint account creation flow
yasin-ce Jan 20, 2026
ec9a896
feat(multisig): Implement inbox pages for sign requests
yasin-ce Jan 20, 2026
2954595
fix: Resolve merge conflicts and update domain model naming
yasin-ce Jan 22, 2026
7466119
fix: Update app inbox mappers to use correct domain model names
yasin-ce Jan 22, 2026
389afda
Merge branch 'multisig/07-creation-flow' into multisig/08-inbox-pages
yasin-ce Jan 23, 2026
2d2d8b0
Fix JointAccountInvitationDetailFragment to use InboxApiRepository
yasin-ce Jan 23, 2026
816af45
Merge branch 'multisig/07-creation-flow' into multisig/08-inbox-pages
yasin-ce Jan 23, 2026
d8a49ec
Merge branch 'multisig/07-creation-flow' into multisig/08-inbox-pages
yasin-ce Jan 26, 2026
db2437a
Merge branch 'multisig/07-creation-flow' into multisig/08-inbox-pages
yasin-ce Jan 26, 2026
4592163
fix: Address PR #518 review comments
yasin-ce Jan 26, 2026
d0a7bfa
refactor: Use TimeProvider and RelativeTimeDifference in SignatureReq…
yasin-ce Jan 26, 2026
ed0641d
refactor: Move deep link handling flag to ViewModel and implement Inb…
yasin-ce Jan 26, 2026
0e0a8b0
refactor: Move account display logic to JointAccountInvitationDetailV…
yasin-ce Jan 26, 2026
d8d0d97
refactor: Simplify InboxScreenPreview by using StateFlow overload
yasin-ce Jan 26, 2026
03876f2
refactor: Fix InboxLastOpenedTimeLocalSource DI with PersistentCacheP…
yasin-ce Jan 26, 2026
0d6583f
refactor: Implement ViewState/ViewEvent pattern for Inbox
yasin-ce Jan 26, 2026
13d3ab6
fix: Address PR review feedback and add coding rules
yasin-ce Jan 26, 2026
31bfdc0
fix: Address PR review feedback and add coding rules
yasin-ce Jan 26, 2026
5603799
Merge branch 'multisig/08-inbox-pages' of github.com:perawallet/pera-…
yasin-ce Jan 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 168 additions & 2 deletions .cursorrules
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item>) : 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
Expand All @@ -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<String> =
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
Expand All @@ -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**
Expand Down Expand Up @@ -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<MapperInterface>()
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

Expand All @@ -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
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,6 @@ render.experimental.xml

# Claude
.claude/

# Backend Feedback
BACKEND_FEEDBACK.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,6 @@ internal class GetAccountOriginalStateIconDrawablePreviewUseCase @Inject constru
R.color.layer_gray_lighter
}
}

// TODO: Handle Joint Account properly
AccountType.Joint -> STANDARD.backgroundColorResId
}
}

Expand All @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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<Int> {
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<SpotBannerFlowData> {
return accountLiteCacheData.accountLites.values.map { lite ->
with(lite) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConfirmationBottomSheetResult>(RESULT_KEY) {
if (it.isAccepted) viewModel.rejectTransaction()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
)
}
}
Loading