diff --git a/.cursor/rules/design_system_rules.md b/.cursor/rules/design_system_rules.md new file mode 100644 index 000000000..5eb9af276 --- /dev/null +++ b/.cursor/rules/design_system_rules.md @@ -0,0 +1,742 @@ +# Pera Wallet Android - Design System Rules + +Comprehensive design system documentation for AI assistants integrating Figma designs via Model Context Protocol (MCP). + +--- + +## Overview + +**Architecture:** Hybrid Android app (Jetpack Compose + XML Legacy) + +- **Primary UI:** Jetpack Compose with Material 3 +- **Legacy:** Fragment-based XML layouts +- **Theme:** Dual-mode (Light/Dark) with semantic tokens +- **Build:** Gradle with Kotlin DSL + +--- + +## Color System + +### Locations + +**Compose (Primary):** + +- `app/src/main/kotlin/com/algorand/android/ui/compose/theme/Color.kt` - ColorPalette & PeraColor interface +- `app/src/main/kotlin/com/algorand/android/ui/compose/theme/PeraLightColor.kt` - Light theme +- `app/src/main/kotlin/com/algorand/android/ui/compose/theme/PeraDarkColor.kt` - Dark theme + +**XML (Legacy):** + +- `app/src/main/res/values/colors.xml` - Light mode +- `app/src/main/res/values-night/colors.xml` - Dark mode + +### Color Families + +10 families with V50-V900 variants: + +- **Turquoise** - Primary accent, success +- **Purple** - Helper elements +- **Salmon** - Negative/error states +- **Gray** - Backgrounds, text, layers +- **Yellow** - Warnings, dark mode primary +- **Blue, Pink, Navy, Red** - Additional accents + +### Semantic Mapping Examples + +| Token | Light | Dark | Use | +|-----------------------------|----------------|-------------|-----------------| +| `button.primary.background` | Gray.V800 | Yellow.V400 | Primary CTA | +| `button.primary.text` | Gray.V50 | Gray.V900 | Button text | +| `text.main` | Gray.V900 | Gray.V100 | Primary text | +| `text.secondary` | Gray.V500 | Gray.V400 | Secondary text | +| `background.primary` | Gray.V50 | Gray.V900 | Main background | +| `link.primary` | Turquoise.V600 | Yellow.V400 | Links | + +### Usage + +```kotlin +// GOOD: Use semantic tokens +Box(modifier = Modifier.background(PeraTheme.colors.background.primary)) +Text(text = "Hello", color = PeraTheme.colors.text.main) + +// BAD: Hardcoded values +Box(modifier = Modifier.background(Color(0xFF3C3C3C))) +Text(text = "Hello", color = Color.Black) +``` + +### Adding New Colors + +1. Add to `ColorPalette` in `Color.kt` +2. Add semantic interface to `PeraColor` +3. Implement in `PeraLightColor.kt` +4. Implement in `PeraDarkColor.kt` + +--- + +## Typography System + +### Locations + +- `app/src/main/kotlin/com/algorand/android/ui/compose/typography/PeraTypography.kt` - Main structure +- `app/src/main/kotlin/com/algorand/android/ui/compose/typography/PeraTypography[Title|Body|Footnote|Caption].kt` - + Implementations +- `app/src/main/res/font/` - Font files (DM Sans, DM Mono) + +### Hierarchy + +| Category | Size | Line Height | Weight | Letter Spacing | +|---------------|------|-------------|--------------------------|----------------| +| Title Large | 36sp | 48sp | Regular/Medium/Mono | -0.72sp | +| Title Regular | 32sp | 40sp | Regular/Medium/Bold | -0.64sp | +| Title Small | 28sp | 32sp | Regular/Medium | -0.56sp | +| Body Large | 19sp | 28sp | Regular/Medium/Mono | 0sp | +| Body Regular | 15sp | 24sp | Regular/Medium/Bold/Mono | 0sp | +| Footnote | 13sp | 20sp | Regular/Bold/Medium/Mono | 0sp | +| Caption | 11sp | 16sp | Regular/Bold/Medium/Mono | 0sp | + +### Font Variants + +Each level has 3-4 variants: + +- `.sans` - DM Sans Regular +- `.sansMedium` - DM Sans Medium +- `.sansBold` - DM Sans Bold +- `.mono` - DM Mono + +### Usage + +```kotlin +// GOOD: Use typography tokens +Text( + text = "Headline", + style = PeraTheme.typography.title.regular.sansMedium +) + +// BAD: Hardcoded +Text( + text = "Headline", + fontSize = 32.sp, + fontWeight = FontWeight.Medium +) +``` + +--- + +## Component Library + +### Location + +`app/src/main/kotlin/com/algorand/android/ui/compose/widget/` + +### Categories (87+ components) + +- `button/` - Primary, Secondary, Tertiary buttons +- `text/` - Headline, Title, Body, Link text +- `textfield/` - Input fields, slim variants +- `icon/` - Icon wrappers, round shapes +- `bottomsheet/` - Bottom sheet components +- `asset/` - Asset display components +- `chart/` - Chart visualizations +- `progress/` - Progress indicators, loaders +- `quickaction/` - Quick action buttons +- `modifier/` - Custom modifiers + +### Core Components + +**Buttons:** + +```kotlin +PeraPrimaryButton( + text: String, + onClick: () -> Unit, + state: PeraButtonState = ENABLED, // ENABLED, DISABLED, LOADING + iconRes: Int? = null, + iconPosition: IconPosition = START, + modifier: Modifier = Modifier +) +``` + +- Min height: 52dp (normal), 40dp (small) +- Corner radius: 4dp (normal), 32dp (pill) +- Primary: Dark bg (light), Yellow bg (dark) +- Secondary: Light bg (light), Dark bg (dark) + +**Text:** + +```kotlin +PeraHeadlineText(text, modifier, color, maxLines, overflow) +PeraTitleText(text, modifier, color) +PeraBodyText(text, modifier, color, maxLines) +PeraLinkText(text, modifier, onClick) +``` + +**TextFields:** + +```kotlin +PeraTextField( + value: String, + onValueChange: (String) -> Unit, + label: String? = null, + placeholder: String? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + isError: Boolean = false, + errorText: String? = null, + modifier: Modifier = Modifier +) +``` + +**Icons:** + +```kotlin +PeraIcon( + painter: Painter, + contentDescription: String?, + tint: Color = Color.Unspecified, + modifier: Modifier = Modifier +) +``` + +- Standard sizes: 20dp (small), 24dp (normal), 36dp (large), 40dp (xlarge), 64dp (standalone) + +### Component Naming Convention + +- Prefix: `Pera` +- Descriptive: `PeraPrimaryButton` (not `PeraButton1`) +- One component per file +- File name matches component name + +### Creating New Components + +1. Create file in `widget/[category]/ComponentName.kt` +2. Use design tokens, not hardcoded values +3. Support states (enabled, disabled, loading, error) +4. Add previews for light/dark themes +5. Provide accessibility (contentDescription) + +```kotlin +@Composable +fun PeraMyComponent( + // Required params + title: String, + // Optional params with defaults + modifier: Modifier = Modifier, + description: String? = null, + // Callbacks last + onClick: (() -> Unit)? = null +) { + Column( + modifier = modifier + .background(PeraTheme.colors.background.secondary) + .padding(16.dp) + ) { + Text( + text = title, + style = PeraTheme.typography.title.regular.sansMedium, + color = PeraTheme.colors.text.main + ) + } +} + +@Preview(name = "Light", showBackground = true) +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + PeraTheme { + PeraMyComponent(title = "Preview") + } +} +``` + +--- + +## Asset Management + +### Structure + +``` +app/src/main/res/ +├── drawable/ # Default, vector drawables +├── drawable-hdpi/ # 1.5x density +├── drawable-mdpi/ # 1x density +├── drawable-xhdpi/ # 2x density +├── drawable-xxhdpi/ # 3x density +├── drawable-xxxhdpi/ # 4x density +├── drawable-night/ # Dark theme variants +└── font/ # Custom fonts +``` + +### Counts + +- 286+ backgrounds (`bg_*.xml`) +- 80+ icons (`ic_*.xml`) +- 349 XML layouts +- 75 navigation graphs + +### Naming Convention + +**Format:** `[type]_[description]_[details].xml` + +**Prefixes:** + +- `bg_` - Backgrounds, shapes +- `ic_` - Icons +- `img_` - Images +- `anim_` - Animations + +**Examples:** + +``` +bg_rectangle_radius_4dp.xml +bg_layer_gray_lighter.xml +ic_staking.xml +ic_search.xml +img_feature_banner.png +``` + +### Adding Assets from Figma + +**Icons (Vector):** + +1. Export SVG from Figma +2. Convert to Android Vector Drawable (Android Studio: New → Vector Asset → Local file) +3. Save as `drawable/ic_[name].xml` +4. Use: `painterResource(R.drawable.ic_[name])` + +**Images (Raster):** + +1. Export PNG at 1x, 2x, 3x, 4x +2. Place in `drawable-mdpi/`, `drawable-xhdpi/`, `drawable-xxhdpi/`, `drawable-xxxhdpi/` +3. Use: `painterResource(R.drawable.img_[name])` + +**Dark Variants:** + +- Place dark theme variants in `drawable-night/` + +--- + +## Icon System + +### Sources + +1. **Material Icons:** + ```kotlin + import androidx.compose.material.icons.Icons + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + ``` + +2. **Custom Vector Drawables:** + ```kotlin + PeraIcon( + painter = painterResource(R.drawable.ic_staking), + contentDescription = "Staking" + ) + ``` + +### Sizing + +```kotlin +modifier = Modifier.size(20.dp) // Small action +modifier = Modifier.size(24.dp) // Normal +modifier = Modifier.size(36.dp) // Large +modifier = Modifier.size(40.dp) // Extra large +modifier = Modifier.size(64.dp) // Feature icon +``` + +### Coloring + +```kotlin +// Use semantic color +tint = PeraTheme.colors.icon.primary + +// Preserve original colors +tint = Color.Unspecified +``` + +--- + +## Dimensions & Spacing + +### File + +`app/src/main/res/values/dimens.xml` + +### Spacing Scale + +``` +2dp (xxxsmall) +4dp (xxsmall) +8dp (xsmall) +12dp (small) +16dp (normal) ← Most common +20dp (large) +24dp (xlarge) +32dp (xxlarge) +36dp (xxxlarge) +40dp (xxxxlarge) +48dp (xxxxxlarge) +``` + +### In Compose + +```kotlin +// Use dp values directly +modifier = Modifier.padding(16.dp) +modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp) +``` + +### Component Dimensions + +| Component | Height | Corner Radius | Padding | +|-----------------|--------|---------------|---------| +| Button (normal) | 52dp | 4dp | 16dp | +| Button (small) | 40dp | 32dp | 12dp | +| TextField | 48dp+ | 8dp | 16dp | +| Card | Wrap | 8dp | 16dp | +| Toolbar | 44dp | - | - | + +--- + +## Project Structure + +``` +app/src/main/kotlin/com/algorand/android/ui/ +├── compose/ +│ ├── theme/ +│ │ ├── Color.kt # ColorPalette, PeraColor interface +│ │ ├── PeraLightColor.kt # Light theme implementation +│ │ ├── PeraDarkColor.kt # Dark theme implementation +│ │ └── PeraTheme.kt # Theme composition +│ ├── typography/ +│ │ ├── PeraTypography.kt # Main structure +│ │ ├── PeraTypographyTitle.kt # Title styles +│ │ ├── PeraTypographyBody.kt # Body styles +│ │ ├── PeraTypographyFootnote.kt # Footnote styles +│ │ └── PeraTypographyCaption.kt # Caption styles +│ └── widget/ # 87+ components +│ ├── button/ +│ ├── text/ +│ ├── textfield/ +│ ├── icon/ +│ ├── bottomsheet/ +│ ├── asset/ +│ ├── chart/ +│ ├── progress/ +│ ├── quickaction/ +│ └── modifier/ +└── [feature modules]/ + └── *Fragment.kt # Feature screens + +app/src/main/res/ +├── values/ +│ ├── colors.xml # Light colors +│ ├── dimens.xml # Dimensions +│ ├── styles.xml # XML styles (765 lines) +│ └── strings.xml # Text resources +├── values-night/ +│ ├── colors.xml # Dark colors +│ └── styles.xml # Dark styles +├── drawable/ # Vector drawables (286+) +├── drawable-night/ # Dark theme drawables +├── font/ # DM Sans, DM Mono +├── layout/ # XML layouts (349) +└── navigation/ # Nav graphs (75) +``` + +--- + +## Figma Integration Workflow + +### 1. Analyze Figma Design + +Extract: + +- Colors (fill, stroke, background) +- Typography (font, size, weight, line height, letter spacing) +- Spacing/padding +- Corner radius, shadows, borders +- Component states +- Icons and assets + +### 2. Update Design Tokens (if new) + +**Colors:** + +```kotlin +// 1. Add to ColorPalette (Color.kt) +val NewFamily = ColorFamily( + V900 = Color(0xFF...), + // ... V800 down to V50 +) + +// 2. Add to PeraColor interface +interface MyFeature { + val primary: Color + val secondary: Color +} + +// 3. Implement in PeraLightColor.kt +override val myFeature = object : PeraColor.MyFeature { + override val primary = ColorPalette.Turquoise.V600 + override val secondary = ColorPalette.Gray.V200 +} + +// 4. Implement in PeraDarkColor.kt +override val myFeature = object : PeraColor.MyFeature { + override val primary = ColorPalette.Yellow.V400 + override val secondary = ColorPalette.Gray.V700 +} +``` + +**Typography:** + +```kotlin +// Add new style to PeraTypography*.kt if needed +data class NewStyle( + val sans: TextStyle, + val sansMedium: TextStyle +) +``` + +### 3. Create/Update Components + +```kotlin +// widget/[category]/MyComponent.kt +@Composable +fun PeraMyComponent( + title: String, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + Column( + modifier = modifier + .background(PeraTheme.colors.background.secondary) + .padding(16.dp) + .clickable { onClick?.invoke() } + ) { + Text( + text = title, + style = PeraTheme.typography.title.regular.sansMedium, + color = PeraTheme.colors.text.main + ) + } +} +``` + +### 4. Add Assets + +**Icons:** + +1. Export SVG from Figma +2. Convert to Vector Drawable +3. Save as `drawable/ic_[name].xml` + +**Images:** + +1. Export PNG at multiple densities +2. Place in `drawable-*dpi/` directories + +### 5. Implement Screen + +```kotlin +@Composable +fun MyFeatureScreen( + viewModel: MyViewModel = hiltViewModel() +) { + PeraTheme { + Column( + modifier = Modifier + .fillMaxSize() + .background(PeraTheme.colors.background.primary) + .padding(16.dp) + ) { + PeraHeadlineText("Title") + PeraBodyText("Description") + PeraPrimaryButton( + text = "Action", + onClick = { /* ... */ } + ) + } + } +} +``` + +### 6. Test Both Themes + +```kotlin +@Preview(name = "Light", showBackground = true) +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview() { + PeraTheme { + MyFeatureScreen() + } +} +``` + +--- + +## Code Patterns + +### ✅ DO + +```kotlin +// Use design tokens +Box(modifier = Modifier.background(PeraTheme.colors.background.primary)) +Text( + text = "Hello", + style = PeraTheme.typography.body.regular.sansMedium, + color = PeraTheme.colors.text.main +) + +// Support states +enum class ButtonState { ENABLED, DISABLED, LOADING } + +// Add previews +@Preview(name = "Light", showBackground = true) +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview() { /* ... */ } + +// Provide accessibility +PeraIcon( + painter = painterResource(R.drawable.ic_search), + contentDescription = "Search" +) + +// Use semantic naming +PeraPrimaryButton (not PeraButton1) +ic_staking.xml (not icon_1.xml) +``` + +### ❌ DON'T + +```kotlin +// Hardcode values +Box(modifier = Modifier.background(Color(0xFF3C3C3C))) +Text(text = "Hello", fontSize = 16.sp, color = Color.Black) + +// Skip states +// Components should support ENABLED, DISABLED, LOADING, ERROR + +// Forget previews +// Always add light + dark previews + +// Skip accessibility +Icon(/* no contentDescription */) + +// Use generic naming +PeraButton1, color_1, icon_1 +``` + +--- + +## Theme System + +### PeraTheme Setup + +```kotlin +@Composable +fun PeraTheme( + isDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val peraColors = if (isDarkTheme) { + PeraDarkColor() + } else { + PeraLightColor() + } + + CompositionLocalProvider( + localPeraColors provides peraColors + ) { + MaterialTheme( + colorScheme = if (isDarkTheme) darkColorScheme() else lightColorScheme(), + content = content + ) + } +} +``` + +### Usage + +```kotlin +// Wrap all screens +@Composable +fun MyScreen() { + PeraTheme { + // Content automatically uses theme + } +} + +// Access theme values +val bgColor = PeraTheme.colors.background.primary +val textStyle = PeraTheme.typography.body.regular.sansMedium +``` + +--- + +## Quick Reference + +### File Paths + +| Task | Path | +|---------------------|----------------------------------------------------------------------| +| Add color | `app/src/main/kotlin/com/algorand/android/ui/compose/theme/Color.kt` | +| Light colors | `.../theme/PeraLightColor.kt` | +| Dark colors | `.../theme/PeraDarkColor.kt` | +| Typography | `.../typography/PeraTypography*.kt` | +| Button component | `.../widget/button/` | +| Text component | `.../widget/text/` | +| TextField component | `.../widget/textfield/` | +| Add icon | `app/src/main/res/drawable/ic_[name].xml` | +| Add dimension | `app/src/main/res/values/dimens.xml` | + +### Color Usage + +| Token | Light | Dark | Use | +|-----------------------------|----------------|-------------|--------------| +| `background.primary` | Gray.V50 | Gray.V900 | Main bg | +| `text.main` | Gray.V900 | Gray.V100 | Primary text | +| `button.primary.background` | Gray.V800 | Yellow.V400 | Primary CTA | +| `link.primary` | Turquoise.V600 | Yellow.V400 | Links | + +### Common Specs + +| Component | Height | Radius | Padding | +|----------------|--------|--------|---------| +| Primary Button | 52dp | 4dp | 16dp | +| Small Button | 40dp | 32dp | 12dp | +| TextField | 48dp+ | 8dp | 16dp | +| Card | Wrap | 8dp | 16dp | + +--- + +## Summary + +**Key Principles:** + +1. **Always use design tokens** - No hardcoded colors, typography, or dimensions +2. **Support both themes** - Light and dark mode +3. **Support all states** - Enabled, disabled, loading, error +4. **Add previews** - Light + dark for all components +5. **Provide accessibility** - Content descriptions for all icons/images +6. **Follow naming conventions** - Semantic, descriptive names +7. **One component per file** - Clear organization +8. **Reuse existing components** - Don't recreate what exists + +**When implementing Figma designs:** + +1. Extract design tokens +2. Update theme files if new tokens needed +3. Build/update components using tokens +4. Add assets as vector drawables +5. Compose screens using component library +6. Test both themes +7. Add accessibility + +**Component Library:** 87+ reusable Compose widgets ready to use. Check `widget/` subdirectories before creating new +components. + +**Design System:** Comprehensive semantic token system with 10 color families, 4-level typography hierarchy, and dual +theme support. diff --git a/app/src/main/kotlin/com/algorand/android/MainActivity.kt b/app/src/main/kotlin/com/algorand/android/MainActivity.kt index 4a9bc9ba9..b95b9fa93 100644 --- a/app/src/main/kotlin/com/algorand/android/MainActivity.kt +++ b/app/src/main/kotlin/com/algorand/android/MainActivity.kt @@ -41,6 +41,7 @@ import com.algorand.android.models.TransactionSignData import com.algorand.android.models.WalletConnectRequest import com.algorand.android.models.WalletConnectRequest.WalletConnectArbitraryDataRequest import com.algorand.android.models.WalletConnectRequest.WalletConnectTransaction +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.autolockmanager.ui.AutoLockManager import com.algorand.android.modules.deeplink.ui.DeeplinkHandler @@ -393,7 +394,8 @@ class MainActivity : is TransactionManagerResult.Success.TransactionRequestSigned -> { hideProgress() hideLedgerLoadingDialog() - TODO("Implement this") + PendingSignaturesDialogFragment.newInstance(result.signRequestId) + .show(supportFragmentManager, PendingSignaturesDialogFragment.TAG) } TransactionManagerResult.LedgerOperationCanceled -> { @@ -576,7 +578,11 @@ class MainActivity : private fun navToJointAccountImportDeepLink(address: String) { navToHome() - TODO("Implement this") + nav( + HomeNavigationDirections.actionGlobalToJointAccountDetailFragment( + accountAddress = address + ) + ) } fun navToContactAdditionNavigation(address: String, label: String?) { diff --git a/app/src/main/kotlin/com/algorand/android/core/transaction/JointAccountTransactionSignHelper.kt b/app/src/main/kotlin/com/algorand/android/core/transaction/JointAccountTransactionSignHelper.kt index 077236d81..b2db03fc5 100644 --- a/app/src/main/kotlin/com/algorand/android/core/transaction/JointAccountTransactionSignHelper.kt +++ b/app/src/main/kotlin/com/algorand/android/core/transaction/JointAccountTransactionSignHelper.kt @@ -13,6 +13,7 @@ package com.algorand.android.core.transaction import com.algorand.android.models.TransactionSignData +import com.algorand.android.modules.addaccount.joint.transaction.domain.usecase.SignAndSubmitJointAccountSignature import com.algorand.android.utils.extensions.encodeBase64 import com.algorand.wallet.account.info.domain.usecase.GetAccountRekeyAdminAddress import com.algorand.wallet.account.local.domain.model.LocalAccount @@ -31,6 +32,7 @@ class JointAccountTransactionSignHelper @Inject constructor( private val getSignableAccountsByAddresses: GetSignableAccountsByAddresses, private val localAccountSigningHelper: LocalAccountSigningHelper, private val proposeJointSignRequest: ProposeJointSignRequest, + private val signAndSubmitJointAccountSignature: SignAndSubmitJointAccountSignature, private val getJointAccountProposerAddress: GetJointAccountProposerAddress, private val getAccountRekeyAdminAddress: GetAccountRekeyAdminAddress ) { @@ -98,7 +100,16 @@ class JointAccountTransactionSignHelper @Inject constructor( ) { val eligibleSigners = getSignableAccountsByAddresses(jointAccount.participantAddresses) - TODO("Implement auto sign with local accounts") + for (signer in eligibleSigners) { + val result = signAndSubmitJointAccountSignature( + signRequestId = signRequestId, + participantAddress = signer.algoAddress, + rawTransactions = rawTransactions + ) + if (result !is PeraResult.Success) { + // Failed to auto-sign for participant, continue with others + } + } } private fun prepareRawTransactionLists(transactionDataList: List): List>? { diff --git a/app/src/main/kotlin/com/algorand/android/core/transaction/TransactionSignBaseFragment.kt b/app/src/main/kotlin/com/algorand/android/core/transaction/TransactionSignBaseFragment.kt index b4fb67fba..b3808312d 100644 --- a/app/src/main/kotlin/com/algorand/android/core/transaction/TransactionSignBaseFragment.kt +++ b/app/src/main/kotlin/com/algorand/android/core/transaction/TransactionSignBaseFragment.kt @@ -102,7 +102,7 @@ abstract class TransactionSignBaseFragment( } protected open fun onJointAccountSignRequestCreated(signRequestId: String) { - TODO("Implement this") + nav(HomeNavigationDirections.actionGlobalToJointAccountSignRequestFragment(signRequestId)) } private val ledgerLoadingDialogListener = LedgerLoadingDialog.Listener { shouldStopResources -> diff --git a/app/src/main/kotlin/com/algorand/android/modules/accountdetail/history/ui/AccountHistoryFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/accountdetail/history/ui/AccountHistoryFragment.kt index cd5ea63db..aa21a4e1c 100644 --- a/app/src/main/kotlin/com/algorand/android/modules/accountdetail/history/ui/AccountHistoryFragment.kt +++ b/app/src/main/kotlin/com/algorand/android/modules/accountdetail/history/ui/AccountHistoryFragment.kt @@ -125,11 +125,11 @@ class AccountHistoryFragment : BaseFragment(R.layout.fragment_account_history) { initUi() initObserver() handleLoadState() + initSavedStateListener() } override fun onResume() { super.onResume() - initSavedStateListener() accountHistoryViewModel.activatePendingTransaction() } diff --git a/app/src/main/kotlin/com/algorand/android/modules/accountdetail/jointaccountdetail/ui/JointAccountDetailFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/accountdetail/jointaccountdetail/ui/JointAccountDetailFragment.kt index 6e9279e77..46b29b547 100644 --- a/app/src/main/kotlin/com/algorand/android/modules/accountdetail/jointaccountdetail/ui/JointAccountDetailFragment.kt +++ b/app/src/main/kotlin/com/algorand/android/modules/accountdetail/jointaccountdetail/ui/JointAccountDetailFragment.kt @@ -19,6 +19,7 @@ import android.view.ViewGroup import androidx.compose.runtime.getValue import androidx.fragment.app.viewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.algorand.android.HomeNavigationDirections import com.algorand.android.core.DaggerBaseFragment import com.algorand.android.models.FragmentConfiguration import com.algorand.android.modules.accountdetail.jointaccountdetail.viewmodel.JointAccountDetailViewModel @@ -95,10 +96,22 @@ class JointAccountDetailFragment : DaggerBaseFragment(0), JointAccountDetailList } private fun navigateToNameJointAccount(event: ViewEvent.NavigateToNameJointAccount) { - TODO("Implement this") + nav( + HomeNavigationDirections.actionGlobalToNameJointAccountFragment( + threshold = event.threshold, + participantAddresses = event.participantAddresses.toTypedArray() + ) + ) } private fun navigateToEditContact(event: ViewEvent.NavigateToEditContact) { - TODO("Implement this") + nav( + HomeNavigationDirections.actionGlobalEditContactFragment( + contactName = event.contactName, + contactPublicKey = event.contactPublicKey, + contactDatabaseId = event.contactDatabaseId, + contactProfileImageUri = event.contactProfileImageUri + ) + ) } } diff --git a/app/src/main/kotlin/com/algorand/android/modules/accountdetail/ui/AccountDetailFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/accountdetail/ui/AccountDetailFragment.kt index 03829550e..02741b081 100644 --- a/app/src/main/kotlin/com/algorand/android/modules/accountdetail/ui/AccountDetailFragment.kt +++ b/app/src/main/kotlin/com/algorand/android/modules/accountdetail/ui/AccountDetailFragment.kt @@ -256,7 +256,11 @@ class AccountDetailFragment : } private fun navToJointAccountDetailFragment() { - TODO("Implement this") + nav( + AccountDetailFragmentDirections.actionAccountDetailFragmentToJointAccountDetailFragment( + accountDetailViewModel.accountAddress + ) + ) } override fun onImageItemClick(nftAssetId: Long) { @@ -559,7 +563,12 @@ class AccountDetailFragment : } private fun navToInboxWithFilter() { - TODO("Implement this") + nav( + AccountDetailFragmentDirections + .actionAccountDetailFragmentToAssetInboxAllAccountsNavigation( + filterAccountAddress = accountDetailViewModel.accountAddress + ) + ) } private fun navToBuySellActionsBottomSheet() { 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 d1a4d8929..fea5a4044 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 @@ -25,9 +25,7 @@ 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.usecase.IsFeatureToggleEnabled import com.algorand.wallet.spotbanner.domain.model.SpotBannerFlowData import com.algorand.wallet.spotbanner.domain.usecase.GetSpotBannersFlow import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -44,12 +42,10 @@ 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 isFeatureToggleEnabled: IsFeatureToggleEnabled ) { suspend fun getInitialAccountPreview(): AccountPreview { diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/di/AddAccountIntroUiModule.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/di/AddAccountIntroUiModule.kt new file mode 100644 index 000000000..e5460746a --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/di/AddAccountIntroUiModule.kt @@ -0,0 +1,44 @@ +/* + * 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.addaccount.intro.di + +import com.algorand.android.modules.addaccount.intro.domain.usecase.CreateAlgo25Account +import com.algorand.android.modules.addaccount.intro.domain.usecase.CreateAlgo25AccountUseCase +import com.algorand.android.modules.addaccount.intro.domain.usecase.CreateHdKeyAccount +import com.algorand.android.modules.addaccount.intro.domain.usecase.CreateHdKeyAccountUseCase +import com.algorand.android.modules.addaccount.intro.domain.usecase.GetAddAccountIntroPreview +import com.algorand.android.modules.addaccount.intro.domain.usecase.GetAddAccountIntroPreviewUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal object AddAccountIntroUiModule { + + @Provides + fun provideGetAddAccountIntroPreview( + impl: GetAddAccountIntroPreviewUseCase + ): GetAddAccountIntroPreview = impl + + @Provides + fun provideCreateHdKeyAccount( + impl: CreateHdKeyAccountUseCase + ): CreateHdKeyAccount = impl + + @Provides + fun provideCreateAlgo25Account( + impl: CreateAlgo25AccountUseCase + ): CreateAlgo25Account = impl +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/exception/AccountCreationException.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/exception/AccountCreationException.kt new file mode 100644 index 000000000..f29d5abfd --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/exception/AccountCreationException.kt @@ -0,0 +1,15 @@ +/* + * 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.addaccount.intro.domain.exception + +class AccountCreationException(message: String) : Exception(message) diff --git a/app/src/main/kotlin/com/algorand/android/models/RegisterIntroPreview.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/model/AddAccountIntroPreview.kt similarity index 84% rename from app/src/main/kotlin/com/algorand/android/models/RegisterIntroPreview.kt rename to app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/model/AddAccountIntroPreview.kt index 163b590ad..88bbae287 100644 --- a/app/src/main/kotlin/com/algorand/android/models/RegisterIntroPreview.kt +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/model/AddAccountIntroPreview.kt @@ -7,14 +7,14 @@ * 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 + * limitations under the License */ -package com.algorand.android.models +package com.algorand.android.modules.addaccount.intro.domain.model import androidx.annotation.StringRes -data class RegisterIntroPreview( +data class AddAccountIntroPreview( @param:StringRes val titleRes: Int, val isSkipButtonVisible: Boolean, val isCloseButtonVisible: Boolean, diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/CreateAlgo25Account.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/CreateAlgo25Account.kt new file mode 100644 index 000000000..4c996fe9d --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/CreateAlgo25Account.kt @@ -0,0 +1,20 @@ +/* + * 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.addaccount.intro.domain.usecase + +import com.algorand.android.models.AccountCreation +import com.algorand.android.models.Result + +fun interface CreateAlgo25Account { + suspend operator fun invoke(): Result +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/CreateAlgo25AccountUseCase.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/CreateAlgo25AccountUseCase.kt new file mode 100644 index 000000000..2a749101e --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/CreateAlgo25AccountUseCase.kt @@ -0,0 +1,46 @@ +/* + * 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.addaccount.intro.domain.usecase + +import com.algorand.android.models.AccountCreation +import com.algorand.android.models.Result +import com.algorand.android.modules.addaccount.intro.domain.exception.AccountCreationException +import com.algorand.android.utils.analytics.CreationType +import com.algorand.wallet.algosdk.transaction.sdk.AlgoAccountSdk +import com.algorand.wallet.encryption.domain.manager.AESPlatformManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +internal class CreateAlgo25AccountUseCase @Inject constructor( + private val algoAccountSdk: AlgoAccountSdk, + private val aesPlatformManager: AESPlatformManager +) : CreateAlgo25Account { + + override suspend fun invoke(): Result = withContext(Dispatchers.Default) { + val account = algoAccountSdk.createAlgo25Account() + ?: return@withContext Result.Error(AccountCreationException("Failed to generate Algo25 account")) + + Result.Success( + AccountCreation( + address = account.address, + customName = null, + isBackedUp = false, + type = AccountCreation.Type.Algo25( + aesPlatformManager.encryptByteArray(account.secretKey) + ), + creationType = CreationType.CREATE + ) + ) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/CreateHdKeyAccount.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/CreateHdKeyAccount.kt new file mode 100644 index 000000000..934560de2 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/CreateHdKeyAccount.kt @@ -0,0 +1,20 @@ +/* + * 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.addaccount.intro.domain.usecase + +import com.algorand.android.models.AccountCreation +import com.algorand.android.models.Result + +fun interface CreateHdKeyAccount { + operator fun invoke(): Result +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/CreateHdKeyAccountUseCase.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/CreateHdKeyAccountUseCase.kt new file mode 100644 index 000000000..29a607682 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/CreateHdKeyAccountUseCase.kt @@ -0,0 +1,51 @@ +/* + * 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.addaccount.intro.domain.usecase + +import com.algorand.android.models.AccountCreation +import com.algorand.android.models.Result +import com.algorand.android.modules.addaccount.intro.domain.exception.AccountCreationException +import com.algorand.android.ui.onboarding.creation.mapper.AccountCreationHdKeyTypeMapper +import com.algorand.android.utils.analytics.CreationType +import com.algorand.wallet.algosdk.bip39.model.HdKeyAddressIndex +import com.algorand.wallet.algosdk.bip39.sdk.Bip39WalletProvider +import javax.inject.Inject + +internal class CreateHdKeyAccountUseCase @Inject constructor( + private val bip39WalletProvider: Bip39WalletProvider, + private val accountCreationHdKeyTypeMapper: AccountCreationHdKeyTypeMapper +) : CreateHdKeyAccount { + + override fun invoke(): Result { + return try { + val wallet = bip39WalletProvider.createBip39Wallet() + val hdKeyAddress = wallet.generateAddress(HdKeyAddressIndex()) + val hdKeyType = accountCreationHdKeyTypeMapper( + wallet.getEntropy().value, + hdKeyAddress, + seedId = null + ) + Result.Success( + AccountCreation( + address = hdKeyAddress.address, + customName = null, + isBackedUp = false, + type = hdKeyType, + creationType = CreationType.CREATE + ) + ) + } catch (e: Exception) { + Result.Error(AccountCreationException("Failed to generate HD key account: ${e.message}")) + } + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/GetAddAccountIntroPreview.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/GetAddAccountIntroPreview.kt new file mode 100644 index 000000000..3443fd603 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/GetAddAccountIntroPreview.kt @@ -0,0 +1,20 @@ +/* + * 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.addaccount.intro.domain.usecase + +import com.algorand.android.modules.addaccount.intro.domain.model.AddAccountIntroPreview +import kotlinx.coroutines.flow.Flow + +fun interface GetAddAccountIntroPreview { + operator fun invoke(isShowingCloseButton: Boolean): Flow +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/GetAddAccountIntroPreviewUseCase.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/GetAddAccountIntroPreviewUseCase.kt new file mode 100644 index 000000000..fbc2acb4b --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/domain/usecase/GetAddAccountIntroPreviewUseCase.kt @@ -0,0 +1,40 @@ +/* + * 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.addaccount.intro.domain.usecase + +import com.algorand.android.modules.addaccount.intro.domain.model.AddAccountIntroPreview +import com.algorand.android.modules.addaccount.intro.mapper.AddAccountIntroPreviewMapper +import com.algorand.wallet.account.local.domain.usecase.GetHasAnyHdSeedId +import com.algorand.wallet.account.local.domain.usecase.IsThereAnyLocalAccount +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +internal class GetAddAccountIntroPreviewUseCase @Inject constructor( + private val addAccountIntroPreviewMapper: AddAccountIntroPreviewMapper, + private val hasAnyHdSeedId: GetHasAnyHdSeedId, + private val isThereAnyLocalAccount: IsThereAnyLocalAccount +) : GetAddAccountIntroPreview { + + override fun invoke(isShowingCloseButton: Boolean): Flow = flow { + val hasHdWallet = hasAnyHdSeedId() + val hasLocalAccount = isThereAnyLocalAccount() + emit( + addAccountIntroPreviewMapper( + isShowingCloseButton = isShowingCloseButton, + hasHdWallet = hasHdWallet, + hasLocalAccount = hasLocalAccount + ) + ) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/mapper/AddAccountIntroPreviewDecider.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/mapper/AddAccountIntroPreviewDecider.kt new file mode 100644 index 000000000..846eb4a97 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/mapper/AddAccountIntroPreviewDecider.kt @@ -0,0 +1,33 @@ +/* + * 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.addaccount.intro.mapper + +import androidx.annotation.StringRes +import com.algorand.android.R +import javax.inject.Inject + +class AddAccountIntroPreviewDecider @Inject constructor() { + + @StringRes + fun decideTitleRes(hasLocalAccount: Boolean): Int { + return if (hasLocalAccount) { + R.string.add_an_account + } else { + R.string.welcome_to_pera + } + } + + fun decideIsSkipButtonVisible(hasLocalAccount: Boolean): Boolean { + return !hasLocalAccount + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/mapper/AddAccountIntroPreviewMapper.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/mapper/AddAccountIntroPreviewMapper.kt new file mode 100644 index 000000000..b5dae845d --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/mapper/AddAccountIntroPreviewMapper.kt @@ -0,0 +1,34 @@ +/* + * 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.addaccount.intro.mapper + +import com.algorand.android.modules.addaccount.intro.domain.model.AddAccountIntroPreview +import javax.inject.Inject + +class AddAccountIntroPreviewMapper @Inject constructor( + private val addAccountIntroPreviewDecider: AddAccountIntroPreviewDecider +) { + + operator fun invoke( + isShowingCloseButton: Boolean, + hasHdWallet: Boolean, + hasLocalAccount: Boolean + ): AddAccountIntroPreview { + return AddAccountIntroPreview( + titleRes = addAccountIntroPreviewDecider.decideTitleRes(hasLocalAccount), + isSkipButtonVisible = addAccountIntroPreviewDecider.decideIsSkipButtonVisible(hasLocalAccount), + isCloseButtonVisible = isShowingCloseButton, + hasHdWallet = hasHdWallet + ) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/preview/AddAccountIntroScreenPreview.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/preview/AddAccountIntroScreenPreview.kt new file mode 100644 index 000000000..9a2db0309 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/preview/AddAccountIntroScreenPreview.kt @@ -0,0 +1,190 @@ +@file:Suppress("EmptyFunctionBlock", "Unused", "LongMethod") +/* + * 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.addaccount.intro.preview + +import android.content.SharedPreferences +import androidx.compose.runtime.Composable +import androidx.lifecycle.SavedStateHandle +import com.algorand.android.R +import com.algorand.android.models.AccountCreation +import com.algorand.android.models.Result +import com.algorand.android.modules.addaccount.intro.domain.model.AddAccountIntroPreview +import com.algorand.android.modules.addaccount.intro.domain.usecase.CreateAlgo25Account +import com.algorand.android.modules.addaccount.intro.domain.usecase.CreateHdKeyAccount +import com.algorand.android.modules.addaccount.intro.domain.usecase.GetAddAccountIntroPreviewUseCase +import com.algorand.android.modules.addaccount.intro.mapper.AddAccountIntroPreviewDecider +import com.algorand.android.modules.addaccount.intro.mapper.AddAccountIntroPreviewMapper +import com.algorand.android.modules.addaccount.intro.view.AddAccountIntroScreen +import com.algorand.android.modules.addaccount.intro.view.AddAccountIntroScreenListener +import com.algorand.android.modules.addaccount.intro.viewmodel.AddAccountIntroViewModel +import com.algorand.android.modules.addaccount.intro.viewmodel.AddAccountIntroViewModel.ViewState +import com.algorand.android.modules.tracking.onboarding.register.registerintro.OnboardingCreateNewAccountEventTracker +import com.algorand.android.modules.tracking.onboarding.register.registerintro.OnboardingWelcomeAccountRecoverEventTracker +import com.algorand.android.modules.tracking.onboarding.register.registerintro.RegisterIntroFragmentEventTracker +import com.algorand.android.repository.RegistrationRepository +import com.algorand.android.sharedpref.RegistrationSkipLocalSource +import com.algorand.android.ui.compose.preview.PeraPreviewLightDark +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.usecase.RegistrationUseCase +import com.algorand.android.utils.analytics.CreationType +import com.algorand.wallet.account.local.domain.usecase.GetHasAnyHdSeedId +import com.algorand.wallet.account.local.domain.usecase.IsThereAnyLocalAccount +import com.algorand.wallet.analytics.domain.service.PeraEventTracker +import com.algorand.wallet.remoteconfig.domain.usecase.IsFeatureToggleEnabled +import com.algorand.wallet.viewmodel.StateDelegate + +@PeraPreviewLightDark +@Composable +fun AddAccountIntroScreenPreview() { + PeraTheme { + val listener = object : AddAccountIntroScreenListener { + override fun onAddAccountClick() {} + override fun onAddJointAccountClick() {} + override fun onImportAccountClick() {} + override fun onWatchAddressClick() {} + override fun onCreateUniversalWalletClick() {} + override fun onCreateAlgo25AccountClick() {} + override fun onCloseClick() {} + } + AddAccountIntroScreen( + listener = listener, + viewModel = getMockViewModel() + ) + } +} + +@Suppress("UNUSED_VARIABLE", "UNUSED_PARAMETER", "UNCHECKED_CAST") +private fun getMockViewModel(): AddAccountIntroViewModel { + val addAccountIntroPreviewDecider = AddAccountIntroPreviewDecider() + val addAccountIntroPreviewMapper = AddAccountIntroPreviewMapper(addAccountIntroPreviewDecider) + val mockSharedPreferences = object : SharedPreferences { + override fun contains(key: String?): Boolean = false + override fun edit(): SharedPreferences.Editor = object : SharedPreferences.Editor { + override fun putString(key: String?, value: String?): SharedPreferences.Editor = this + override fun putStringSet(key: String?, values: MutableSet?): SharedPreferences.Editor = this + override fun putInt(key: String?, value: Int): SharedPreferences.Editor = this + override fun putLong(key: String?, value: Long): SharedPreferences.Editor = this + override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = this + override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor = this + override fun remove(key: String?): SharedPreferences.Editor = this + override fun clear(): SharedPreferences.Editor = this + override fun commit(): Boolean = true + override fun apply() = Unit + } + override fun getAll(): MutableMap = mutableMapOf() + override fun getBoolean(key: String?, defValue: Boolean): Boolean = defValue + override fun getFloat(key: String?, defValue: Float): Float = defValue + override fun getInt(key: String?, defValue: Int): Int = defValue + override fun getLong(key: String?, defValue: Long): Long = defValue + override fun getString(key: String?, defValue: String?): String? = defValue + override fun getStringSet( + key: String?, + defValues: MutableSet? + ): MutableSet? = defValues + + override fun registerOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener? + ) = Unit + + override fun unregisterOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener? + ) = Unit + } + val registrationSkipLocalSource = RegistrationSkipLocalSource(mockSharedPreferences) + val registrationRepository = RegistrationRepository(registrationSkipLocalSource) + val registrationUseCase = RegistrationUseCase(registrationRepository) + val peraEventTracker = object : PeraEventTracker { + override suspend fun logEvent(eventName: String) = Unit + override suspend fun logEvent(eventName: String, payloadMap: Map) = Unit + } + val onboardingCreateNewAccountEventTracker = OnboardingCreateNewAccountEventTracker( + peraEventTracker, + registrationUseCase + ) + val onboardingWelcomeAccountRecoverEventTracker = OnboardingWelcomeAccountRecoverEventTracker( + peraEventTracker, + registrationUseCase + ) + val registerIntroFragmentEventTracker = RegisterIntroFragmentEventTracker( + onboardingCreateNewAccountEventTracker, + onboardingWelcomeAccountRecoverEventTracker + ) + val hasAnyHdSeedId = GetHasAnyHdSeedId { true } + val isThereAnyLocalAccount = IsThereAnyLocalAccount { false } + val getAddAccountIntroPreview = GetAddAccountIntroPreviewUseCase( + addAccountIntroPreviewMapper = addAccountIntroPreviewMapper, + hasAnyHdSeedId = hasAnyHdSeedId, + isThereAnyLocalAccount = isThereAnyLocalAccount + ) + val createHdKeyAccount = CreateHdKeyAccount { + Result.Success( + AccountCreation( + address = "PREVIEW_HD_ADDRESS", + customName = null, + isBackedUp = false, + type = AccountCreation.Type.HdKey( + byteArrayOf(), + byteArrayOf(), + byteArrayOf(), + 0, + 0, + 0, + 0, + null + ), + creationType = CreationType.CREATE + ) + ) + } + val createAlgo25Account = object : CreateAlgo25Account { + override suspend fun invoke(): Result { + return Result.Success( + AccountCreation( + address = "PREVIEW_ALGO25_ADDRESS", + customName = null, + isBackedUp = false, + type = AccountCreation.Type.Algo25(byteArrayOf()), + creationType = CreationType.CREATE + ) + ) + } + } + val isFeatureToggleEnabled = IsFeatureToggleEnabled { true } + val savedStateHandle = SavedStateHandle() + + val stateDelegate = StateDelegate().apply { + setDefaultState( + ViewState.Content( + AddAccountIntroPreview( + titleRes = R.string.add_an_account, + isSkipButtonVisible = true, + isCloseButtonVisible = false, + hasHdWallet = true + ) + ) + ) + } + + return AddAccountIntroViewModel( + stateDelegate = stateDelegate, + getAddAccountIntroPreview = getAddAccountIntroPreview, + registrationUseCase = registrationUseCase, + createHdKeyAccount = createHdKeyAccount, + createAlgo25Account = createAlgo25Account, + isFeatureToggleEnabled = isFeatureToggleEnabled, + peraEventTracker = peraEventTracker, + registerIntroFragmentEventTracker = registerIntroFragmentEventTracker, + savedStateHandle = savedStateHandle + ) +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/view/AddAccountIntroFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/view/AddAccountIntroFragment.kt new file mode 100644 index 000000000..c74e32a2d --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/view/AddAccountIntroFragment.kt @@ -0,0 +1,173 @@ +/* + * 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.addaccount.intro.view + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.algorand.android.LoginNavigationDirections +import kotlinx.coroutines.launch +import com.algorand.android.MainActivity +import com.algorand.android.R +import com.algorand.android.core.DaggerBaseFragment +import com.algorand.android.customviews.toolbar.buttoncontainer.model.TextButton +import com.algorand.android.models.FragmentConfiguration +import com.algorand.android.models.Result +import com.algorand.android.modules.addaccount.intro.viewmodel.AddAccountIntroViewModel +import com.algorand.android.modules.addaccount.joint.info.ui.JointAccountInfoDialogDelegate +import com.algorand.android.modules.tracking.core.PeraClickEvent +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.utils.extensions.collectLatestOnLifecycle +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class AddAccountIntroFragment : DaggerBaseFragment(0), AddAccountIntroScreenListener { + + private val viewModel: AddAccountIntroViewModel by viewModels() + + override val fragmentConfiguration: FragmentConfiguration = FragmentConfiguration() + + private val jointAccountInfoDialogDelegate by lazy { + JointAccountInfoDialogDelegate( + onContinueClick = ::navToJointAccountFlow + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setContent { + PeraTheme { + AddAccountIntroScreen( + listener = this@AddAccountIntroFragment, + viewModel = viewModel + ) + } + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initObservers() + } + + private fun initObservers() { + viewLifecycleOwner.collectLatestOnLifecycle( + viewModel.state, + ::handleStateChange + ) + } + + private suspend fun handleStateChange(state: AddAccountIntroViewModel.ViewState) { + when (state) { + is AddAccountIntroViewModel.ViewState.Idle -> Unit + is AddAccountIntroViewModel.ViewState.Content -> { + configureToolbar(state.preview.isCloseButtonVisible, state.preview.isSkipButtonVisible) + (activity as? MainActivity)?.hideProgress() + } + } + } + + override fun onAddAccountClick() { + viewModel.logOnboardingWelcomeAccountCreateClickEvent() + nav( + AddAccountIntroFragmentDirections.actionRegisterIntroFragmentToHdWalletSelectionFragment() + ) + } + + override fun onAddJointAccountClick() { + viewModel.logEvent(PeraClickEvent.TAP_ONBOARDING_CREATE_WALLET) + context?.let { jointAccountInfoDialogDelegate.show(it) } + } + + override fun onImportAccountClick() { + viewModel.logOnboardingWelcomeAccountRecoverClickEvent() + nav(AddAccountIntroFragmentDirections.actionRegisterIntroFragmentToRecoveryTypeSelectionNavigation()) + } + + override fun onWatchAddressClick() { + viewModel.logEvent(PeraClickEvent.TAP_ONBOARDING_WELCOME_WATCH) + nav(AddAccountIntroFragmentDirections.actionRegisterIntroFragmentToWatchAccountInfoFragment()) + } + + override fun onCreateUniversalWalletClick() { + viewModel.logEvent(PeraClickEvent.TAP_ONBOARDING_CREATE_WALLET) + when (val result = viewModel.createHdKeyAccount()) { + is Result.Success -> { + nav( + AddAccountIntroFragmentDirections + .actionRegisterIntroFragmentToCreateWalletNameRegistrationNavigation(result.data) + ) + } + + is Result.Error -> { + showGlobalError(getString(R.string.an_error_occurred)) + } + } + } + + override fun onCreateAlgo25AccountClick() { + viewModel.logEvent(PeraClickEvent.TAP_ONBOARDING_CREATE_WALLET) + viewLifecycleOwner.lifecycleScope.launch { + when (val result = viewModel.createAlgo25Account()) { + is Result.Success -> { + nav( + AddAccountIntroFragmentDirections + .actionRegisterIntroFragmentToCreateWalletNameRegistrationNavigation(result.data) + ) + } + + is Result.Error -> { + showGlobalError(getString(R.string.an_error_occurred)) + } + } + } + } + + override fun onCloseClick() { + navBack() + } + + private fun configureToolbar(isCloseButtonVisible: Boolean, isSkipButtonVisible: Boolean) { + getAppToolbar()?.let { toolbar -> + if (isCloseButtonVisible) { + toolbar.configureStartButton(R.drawable.ic_close, ::navBack) + } + if (isSkipButtonVisible) { + toolbar.setEndButton(button = TextButton(R.string.skip, onClick = ::onSkipClick)) + } + } + } + + private fun navToJointAccountFlow() { + viewModel.logEvent(PeraClickEvent.TAP_ONBOARDING_CREATE_WALLET) + nav( + AddAccountIntroFragmentDirections + .actionRegisterIntroFragmentToCreateJointAccountFragment() + ) + } + + private fun onSkipClick() { + viewModel.logEvent(PeraClickEvent.TAP_ONBOARDING_WELCOME_SKIP) + viewModel.setRegisterSkip() + nav(LoginNavigationDirections.actionGlobalToHomeNavigation()) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/view/AddAccountIntroScreen.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/view/AddAccountIntroScreen.kt new file mode 100644 index 000000000..073a87190 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/view/AddAccountIntroScreen.kt @@ -0,0 +1,359 @@ +/* + * 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.addaccount.intro.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +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.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.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.algorand.android.R +import com.algorand.android.modules.addaccount.intro.viewmodel.AddAccountIntroViewModel +import com.algorand.android.modules.addaccount.intro.viewmodel.AddAccountIntroViewModel.ViewState.Content +import com.algorand.android.modules.addaccount.intro.viewmodel.AddAccountIntroViewModel.ViewState.Idle +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.ui.compose.widget.GroupChoiceNewBadge +import com.algorand.android.ui.compose.widget.GroupChoiceWidget +import com.algorand.android.ui.compose.widget.icon.PeraIcon +import com.algorand.android.utils.browser.PRIVACY_POLICY_URL +import com.algorand.android.utils.browser.TERMS_AND_SERVICES_URL +import com.algorand.android.utils.browser.openPrivacyPolicyUrl +import com.algorand.android.utils.browser.openTermsAndServicesUrl + +@Composable +fun AddAccountIntroScreen( + modifier: Modifier = Modifier, + listener: AddAccountIntroScreenListener, + viewModel: AddAccountIntroViewModel +) { + val state by viewModel.state.collectAsStateWithLifecycle() + var showOtherOptions by remember { mutableStateOf(false) } + + Column( + modifier = modifier.fillMaxSize() + ) { + Header(onCloseClick = listener::onCloseClick) + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + when (state) { + is Idle -> Unit + is Content -> { + val preview = (state as Content).preview + if (preview.hasHdWallet) { + AddAccountWidget(listener::onAddAccountClick) + } else { + CreateUniversalWalletWidget(listener::onCreateUniversalWalletClick) + } + if (viewModel.isJointAccountFeatureEnabled()) { + AddJointAccountWidget(listener::onAddJointAccountClick) + } + ImportAccountWidget(listener::onImportAccountClick) + + if (!showOtherOptions) { + SeeOtherOptionsButton( + onClick = { showOtherOptions = true } + ) + } else { + WatchAddressWidget(listener::onWatchAddressClick) + if (preview.hasHdWallet) { + CreateUniversalWalletWidget(listener::onCreateUniversalWalletClick) + } + CreateAlgo25AccountWidget(listener::onCreateAlgo25AccountClick) + } + + TermsAndPrivacy( + modifier = Modifier.padding(top = 48.dp, bottom = 24.dp) + ) + } + } + } + } +} + +@Composable +private fun Header(onCloseClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.padding(start = 20.dp, top = 10.dp)) { + IconButton(onClick = onCloseClick) { + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = stringResource(id = R.string.close), + modifier = Modifier.size(24.dp), + tint = PeraTheme.colors.text.main + ) + } + + Text( + modifier = Modifier.padding(start = 10.dp, top = 12.dp), + style = PeraTheme.typography.title.regular.sansMedium, + color = PeraTheme.colors.text.main, + text = stringResource(R.string.add_an_account_title) + ) + } + + PeraIcon( + painter = painterResource(R.drawable.pera_icon_3d), + contentDescription = stringResource(id = R.string.add_an_account_title), + contentScale = ContentScale.FillWidth + ) + } +} + +@Composable +private fun AddAccountWidget(onClick: () -> Unit) { + GroupChoiceWidget( + title = stringResource(id = R.string.add_account), + description = stringResource(id = R.string.add_account_desc), + icon = ImageVector.vectorResource(R.drawable.ic_wallet_add), + iconContentDescription = stringResource(id = R.string.add_account), + onClick = onClick + ) +} + +@Composable +private fun AddJointAccountWidget(onClick: () -> Unit) { + GroupChoiceWidget( + title = stringResource(id = R.string.add_joint_account), + description = stringResource(id = R.string.add_joint_account_desc), + icon = ImageVector.vectorResource(R.drawable.ic_joint), + iconContentDescription = stringResource(id = R.string.add_joint_account), + badge = { GroupChoiceNewBadge() }, + onClick = onClick + ) +} + +@Composable +private fun ImportAccountWidget(onClick: () -> Unit) { + GroupChoiceWidget( + title = stringResource(id = R.string.import_account), + description = stringResource(id = R.string.import_an_existing), + iconContentDescription = stringResource(id = R.string.import_account), + icon = ImageVector.vectorResource(R.drawable.ic_import_account), + onClick = onClick + ) +} + +@Composable +private fun WatchAddressWidget(onClick: () -> Unit) { + GroupChoiceWidget( + title = stringResource(id = R.string.watch_an_address), + description = stringResource(id = R.string.monitor_an_algorand_address), + iconContentDescription = stringResource(id = R.string.monitor_an_algorand_address), + icon = ImageVector.vectorResource(R.drawable.ic_eye), + onClick = onClick + ) +} + +@Composable +private fun CreateUniversalWalletWidget(onClick: () -> Unit) { + GroupChoiceWidget( + title = stringResource(id = R.string.create_universal_wallet), + description = stringResource(id = R.string.create_universal_wallet_desc), + icon = ImageVector.vectorResource(R.drawable.ic_hd_wallet), + iconContentDescription = stringResource(id = R.string.create_universal_wallet), + onClick = onClick + ) +} + +@Composable +private fun CreateAlgo25AccountWidget(onClick: () -> Unit) { + GroupChoiceWidget( + title = stringResource(id = R.string.create_algo25_account), + description = stringResource(id = R.string.create_algo25_account_desc), + icon = ImageVector.vectorResource(R.drawable.ic_wallet), + iconContentDescription = stringResource(id = R.string.create_algo25_account), + onClick = onClick + ) +} + +@Composable +private fun SeeOtherOptionsButton(onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = PeraTheme.colors.layer.grayLighter, + shape = RoundedCornerShape(8.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_arrow_down), + contentDescription = stringResource(id = R.string.see_other_options), + tint = PeraTheme.colors.text.main + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(id = R.string.see_other_options), + style = PeraTheme.typography.body.regular.sansMedium, + color = PeraTheme.colors.text.main + ) + } +} + +@Composable +private fun TermsAndPrivacy(modifier: Modifier = Modifier) { + val context = LocalContext.current + val layoutResult = remember { + mutableStateOf(null) + } + val annotatedString = createAnnotatedString() + + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.gray, + textAlign = TextAlign.Center, + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures { pos -> + layoutResult.value?.let { layoutResult -> + val offset = layoutResult.getOffsetForPosition(pos) + annotatedString.getStringAnnotations( + tag = "TERMS_AND_CONDITIONS", + start = offset, + end = offset + ).firstOrNull()?.let { + context.openTermsAndServicesUrl() + } + annotatedString.getStringAnnotations( + tag = "PRIVACY_POLICY", + start = offset, + end = offset + ).firstOrNull()?.let { + context.openPrivacyPolicyUrl() + } + } + } + }, + text = annotatedString, + onTextLayout = { + layoutResult.value = it + } + ) + } +} + +@Composable +private fun createAnnotatedString() = buildAnnotatedString { + val fullText = stringResource(R.string.by_adding_a_wallet) + val termsAndConditionsText = stringResource(id = R.string.terms_and_conditions) + val privacyPolicyText = stringResource(id = R.string.privacy_policy) + + val termsAndConditionsStartIndex = fullText.indexOf(termsAndConditionsText) + val termsAndConditionsEndIndex = termsAndConditionsStartIndex + termsAndConditionsText.length + val privacyPolicyStartIndex = fullText.indexOf(privacyPolicyText) + val privacyPolicyEndIndex = privacyPolicyStartIndex + privacyPolicyText.length + + append(fullText) + + if (termsAndConditionsStartIndex >= 0 && termsAndConditionsEndIndex > termsAndConditionsStartIndex) { + addStyle( + style = SpanStyle( + color = PeraTheme.colors.link.primary + ), + start = termsAndConditionsStartIndex, + end = termsAndConditionsEndIndex + ) + addStringAnnotation( + tag = "TERMS_AND_CONDITIONS", + annotation = TERMS_AND_SERVICES_URL, + start = termsAndConditionsStartIndex, + end = termsAndConditionsEndIndex + ) + } + + if (privacyPolicyStartIndex >= 0 && privacyPolicyEndIndex > privacyPolicyStartIndex) { + addStyle( + style = SpanStyle( + color = PeraTheme.colors.link.primary + ), + start = privacyPolicyStartIndex, + end = privacyPolicyEndIndex + ) + addStringAnnotation( + tag = "PRIVACY_POLICY", + annotation = PRIVACY_POLICY_URL, + start = privacyPolicyStartIndex, + end = privacyPolicyEndIndex + ) + } +} + +interface AddAccountIntroScreenListener { + fun onAddAccountClick() + fun onAddJointAccountClick() + fun onImportAccountClick() + fun onWatchAddressClick() + fun onCreateUniversalWalletClick() + fun onCreateAlgo25AccountClick() + fun onCloseClick() +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/viewmodel/AddAccountIntroViewModel.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/viewmodel/AddAccountIntroViewModel.kt new file mode 100644 index 000000000..beb07fce8 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/intro/viewmodel/AddAccountIntroViewModel.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.addaccount.intro.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.algorand.android.models.AccountCreation +import com.algorand.android.models.Result +import com.algorand.android.modules.addaccount.intro.domain.model.AddAccountIntroPreview +import com.algorand.android.modules.addaccount.intro.domain.usecase.CreateAlgo25Account +import com.algorand.android.modules.addaccount.intro.domain.usecase.CreateHdKeyAccount +import com.algorand.android.modules.addaccount.intro.domain.usecase.GetAddAccountIntroPreview +import com.algorand.android.modules.tracking.onboarding.register.registerintro.RegisterIntroFragmentEventTracker +import com.algorand.android.usecase.RegistrationUseCase +import com.algorand.android.utils.getOrElse +import com.algorand.wallet.analytics.domain.service.PeraEventTracker +import com.algorand.wallet.remoteconfig.domain.model.FeatureToggle +import com.algorand.wallet.remoteconfig.domain.usecase.IsFeatureToggleEnabled +import com.algorand.wallet.viewmodel.StateDelegate +import com.algorand.wallet.viewmodel.StateViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AddAccountIntroViewModel @Inject constructor( + private val stateDelegate: StateDelegate, + private val getAddAccountIntroPreview: GetAddAccountIntroPreview, + private val registrationUseCase: RegistrationUseCase, + private val createHdKeyAccount: CreateHdKeyAccount, + private val createAlgo25Account: CreateAlgo25Account, + private val isFeatureToggleEnabled: IsFeatureToggleEnabled, + private val peraEventTracker: PeraEventTracker, + private val registerIntroFragmentEventTracker: RegisterIntroFragmentEventTracker, + savedStateHandle: SavedStateHandle +) : ViewModel(), StateViewModel by stateDelegate { + + private val isShowingCloseButton = savedStateHandle.getOrElse(IS_SHOWING_CLOSE_BUTTON_KEY, false) + + init { + stateDelegate.setDefaultState(ViewState.Idle) + initializePreview() + } + + private fun initializePreview() { + viewModelScope.launch { + getAddAccountIntroPreview(isShowingCloseButton).collectLatest { preview -> + stateDelegate.updateState { + ViewState.Content(preview) + } + } + } + } + + fun setRegisterSkip() { + registrationUseCase.setRegistrationSkipPreferenceAsSkipped() + } + + fun logOnboardingWelcomeAccountCreateClickEvent() { + viewModelScope.launch { + registerIntroFragmentEventTracker.logOnboardingCreateNewAccountEventTracker() + } + } + + fun logOnboardingWelcomeAccountRecoverClickEvent() { + viewModelScope.launch { + registerIntroFragmentEventTracker.logOnboardingWelcomeAccountRecoverEvent() + } + } + + fun createHdKeyAccount(): Result = createHdKeyAccount.invoke() + + suspend fun createAlgo25Account(): Result = createAlgo25Account.invoke() + + fun isJointAccountFeatureEnabled(): Boolean { + return isFeatureToggleEnabled(FeatureToggle.JOINT_ACCOUNT.key) + } + + fun logEvent(eventName: String) { + viewModelScope.launch { + peraEventTracker.logEvent(eventName) + } + } + + sealed interface ViewState { + data object Idle : ViewState + data class Content( + val preview: AddAccountIntroPreview + ) : ViewState + } + + companion object { + private const val IS_SHOWING_CLOSE_BUTTON_KEY = "isShowingCloseButton" + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/core/JointAccountConstants.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/core/JointAccountConstants.kt new file mode 100644 index 000000000..2c69cefa0 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/core/JointAccountConstants.kt @@ -0,0 +1,18 @@ +/* + * 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.addaccount.joint.core + +object JointAccountConstants { + const val CURRENT_VERSION = 1 + const val MIN_THRESHOLD = 2 +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/domain/exception/JointAccountValidationException.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/domain/exception/JointAccountValidationException.kt new file mode 100644 index 000000000..0d6ccfced --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/domain/exception/JointAccountValidationException.kt @@ -0,0 +1,46 @@ +/* + * 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.addaccount.joint.creation.domain.exception + +import com.algorand.android.R +import com.algorand.android.models.AnnotatedString +import com.algorand.android.utils.exceptions.WarningException + +sealed class JointAccountValidationException : Exception() { + abstract fun toWarningException(): WarningException + + class InsufficientParticipants : JointAccountValidationException() { + override fun toWarningException(): WarningException { + return WarningException( + titleRes = R.string.warning, + annotatedString = AnnotatedString(R.string.joint_account_validation_insufficient_participants) + ) + } + } + + data class InvalidThreshold( + val participantCount: Int, + val threshold: Int + ) : JointAccountValidationException() { + override fun toWarningException(): WarningException { + return WarningException( + titleRes = R.string.warning, + annotatedString = AnnotatedString(R.string.joint_account_validation_invalid_threshold) + ) + } + } + + companion object { + const val MIN_PARTICIPANTS = 2 + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/mapper/JointAccountSelectionListItemMapper.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/mapper/JointAccountSelectionListItemMapper.kt new file mode 100644 index 000000000..5a8608029 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/mapper/JointAccountSelectionListItemMapper.kt @@ -0,0 +1,83 @@ +/* + * 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.addaccount.joint.creation.mapper + +import com.algorand.android.R +import com.algorand.android.models.BaseAccountSelectionListItem +import com.algorand.android.modules.accounticon.ui.model.AccountIconDrawablePreview +import com.algorand.android.modules.addaccount.joint.creation.model.JointAccountSelectionListItem +import javax.inject.Inject + +class JointAccountSelectionListItemMapper @Inject constructor() { + + fun mapToAccountItem( + accountItem: BaseAccountSelectionListItem.BaseAccountItem.AccountItem + ): JointAccountSelectionListItem.AccountItem { + val configuration = accountItem.accountListItem.itemConfiguration + return JointAccountSelectionListItem.AccountItem( + address = configuration.accountAddress, + displayName = configuration.accountDisplayName?.primaryDisplayName.orEmpty(), + secondaryDisplayName = configuration.accountDisplayName?.secondaryDisplayName, + iconDrawablePreview = configuration.accountIconDrawablePreview ?: getDefaultIconDrawablePreview(), + formattedAmount = configuration.primaryValueText.orEmpty(), + formattedCurrencyValue = configuration.secondaryValueText.orEmpty() + ) + } + + fun mapToContactItem( + contactItem: BaseAccountSelectionListItem.BaseAccountItem.ContactItem + ): JointAccountSelectionListItem.ContactItem { + return JointAccountSelectionListItem.ContactItem( + address = contactItem.address, + displayName = contactItem.displayName, + imageUri = contactItem.imageUri + ) + } + + fun mapToNfdItem( + nfdItem: BaseAccountSelectionListItem.BaseAccountItem.NftDomainAccountItem + ): JointAccountSelectionListItem.NfdItem { + return JointAccountSelectionListItem.NfdItem( + address = nfdItem.address, + domainName = nfdItem.displayName, + serviceLogoUrl = nfdItem.serviceLogoUrl + ) + } + + fun mapToExternalAddressItem( + address: String, + shortenedAddress: String + ): JointAccountSelectionListItem.ExternalAddressItem { + return JointAccountSelectionListItem.ExternalAddressItem( + address = address, + shortenedAddress = shortenedAddress, + iconDrawablePreview = getExternalAddressIconDrawablePreview() + ) + } + + private fun getDefaultIconDrawablePreview(): AccountIconDrawablePreview { + return AccountIconDrawablePreview( + backgroundColorResId = R.color.layer_gray_lighter, + iconTintResId = R.color.text_gray, + iconResId = R.drawable.ic_wallet + ) + } + + private fun getExternalAddressIconDrawablePreview(): AccountIconDrawablePreview { + return AccountIconDrawablePreview( + backgroundColorResId = R.color.wallet_1, + iconTintResId = R.color.wallet_1_icon, + iconResId = R.drawable.ic_wallet + ) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/mapper/SelectedJointAccountMapper.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/mapper/SelectedJointAccountMapper.kt new file mode 100644 index 000000000..a7d0aecf3 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/mapper/SelectedJointAccountMapper.kt @@ -0,0 +1,78 @@ +/* + * 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.addaccount.joint.creation.mapper + +import com.algorand.android.modules.accountcore.ui.model.AccountDisplayName +import com.algorand.android.modules.addaccount.joint.creation.model.JointAccountSelectionListItem +import com.algorand.android.modules.addaccount.joint.creation.model.SelectedJointAccountItem +import com.algorand.android.utils.toShortenedAddress +import javax.inject.Inject + +class SelectedJointAccountMapper @Inject constructor() { + + fun mapFromAccountItem(item: JointAccountSelectionListItem.AccountItem): SelectedJointAccountItem { + return SelectedJointAccountItem( + accountDisplayName = AccountDisplayName( + accountAddress = item.address, + primaryDisplayName = item.displayName, + secondaryDisplayName = item.secondaryDisplayName + ), + iconDrawablePreview = item.iconDrawablePreview, + isContact = false + ) + } + + fun mapFromContactItem(item: JointAccountSelectionListItem.ContactItem): SelectedJointAccountItem { + return SelectedJointAccountItem( + accountDisplayName = AccountDisplayName( + accountAddress = item.address, + primaryDisplayName = item.displayName, + secondaryDisplayName = item.address.toShortenedAddress() + ), + imageUri = item.imageUri, + isContact = true + ) + } + + fun mapFromNfdItem(item: JointAccountSelectionListItem.NfdItem): SelectedJointAccountItem { + return SelectedJointAccountItem( + accountDisplayName = AccountDisplayName( + accountAddress = item.address, + primaryDisplayName = item.domainName, + secondaryDisplayName = item.address.toShortenedAddress() + ), + isContact = false + ) + } + + fun mapFromSelectionList( + address: String, + accountList: List + ): SelectedJointAccountItem? { + for (item in accountList) { + when (item) { + is JointAccountSelectionListItem.AccountItem -> { + if (item.address == address) return mapFromAccountItem(item) + } + is JointAccountSelectionListItem.ContactItem -> { + if (item.address == address) return mapFromContactItem(item) + } + is JointAccountSelectionListItem.NfdItem -> { + if (item.address == address) return mapFromNfdItem(item) + } + is JointAccountSelectionListItem.ExternalAddressItem -> Unit + } + } + return null + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/model/JointAccountSelectionListItem.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/model/JointAccountSelectionListItem.kt new file mode 100644 index 000000000..f4ce197e4 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/model/JointAccountSelectionListItem.kt @@ -0,0 +1,46 @@ +/* + * 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.addaccount.joint.creation.model + +import android.net.Uri +import com.algorand.android.modules.accounticon.ui.model.AccountIconDrawablePreview + +sealed class JointAccountSelectionListItem { + + data class AccountItem( + val address: String, + val displayName: String, + val secondaryDisplayName: String?, + val iconDrawablePreview: AccountIconDrawablePreview, + val formattedAmount: String, + val formattedCurrencyValue: String + ) : JointAccountSelectionListItem() + + data class ContactItem( + val address: String, + val displayName: String, + val imageUri: Uri? + ) : JointAccountSelectionListItem() + + data class NfdItem( + val address: String, + val domainName: String, + val serviceLogoUrl: String? + ) : JointAccountSelectionListItem() + + data class ExternalAddressItem( + val address: String, + val shortenedAddress: String, + val iconDrawablePreview: AccountIconDrawablePreview + ) : JointAccountSelectionListItem() +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/model/SelectedJointAccountItem.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/model/SelectedJointAccountItem.kt new file mode 100644 index 000000000..c0691b579 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/model/SelectedJointAccountItem.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.addaccount.joint.creation.model + +import android.net.Uri +import android.os.Parcelable +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 SelectedJointAccountItem( + val accountDisplayName: AccountDisplayName, + val iconDrawablePreview: AccountIconDrawablePreview? = null, + val imageUri: Uri? = null, + val isContact: Boolean = false +) : Parcelable diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/addaccount/AddJointAccountFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/addaccount/AddJointAccountFragment.kt new file mode 100644 index 000000000..64e0ce338 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/addaccount/AddJointAccountFragment.kt @@ -0,0 +1,92 @@ +/* + * 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.addaccount.joint.creation.ui.addaccount + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.algorand.android.R +import com.algorand.android.core.DaggerBaseFragment +import com.algorand.android.models.FragmentConfiguration +import com.algorand.android.modules.addaccount.joint.creation.model.SelectedJointAccountItem +import com.algorand.android.modules.addaccount.joint.creation.ui.addaccount.viewmodel.AddJointAccountViewModel +import com.algorand.android.ui.compose.extensions.createComposeView +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class AddJointAccountFragment : DaggerBaseFragment(0), AddJointAccountScreenListener { + + private val viewModel: AddJointAccountViewModel by viewModels() + + override val fragmentConfiguration = FragmentConfiguration() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return createComposeView { + AddJointAccountScreen( + viewModel = viewModel, + listener = this + ) + } + } + + override fun onStart() { + super.onStart() + viewModel.resetSearchQuery() + } + + override fun onBackClick() { + viewModel.resetSearchQuery() + navBack() + } + + override fun onAccountSelected(address: String) { + val selectedAccount = viewModel.createSelectedAccountFromItem(address) + if (selectedAccount != null) { + setResultAndNavigateBack(selectedAccount) + } else { + showGlobalError(getString(R.string.an_error_occurred)) + } + } + + override fun onExternalAddressSelected(address: String) { + viewLifecycleOwner.lifecycleScope.launch { + val selectedAccount = viewModel.createSelectedAccountFromExternalAddress(address) + if (selectedAccount != null) { + setResultAndNavigateBack(selectedAccount) + } else { + showGlobalError(getString(R.string.an_error_occurred)) + } + } + } + + private fun setResultAndNavigateBack(selectedAccount: SelectedJointAccountItem) { + findNavController().previousBackStackEntry?.savedStateHandle?.set( + RESULT_SELECTED_ACCOUNT, + selectedAccount + ) + navBack() + } + + companion object { + const val RESULT_SELECTED_ACCOUNT = "result_selected_account" + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/addaccount/AddJointAccountScreen.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/addaccount/AddJointAccountScreen.kt new file mode 100644 index 000000000..29a43f36b --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/addaccount/AddJointAccountScreen.kt @@ -0,0 +1,453 @@ +/* + * 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 + */ +@file:Suppress("MagicNumber") + +package com.algorand.android.modules.addaccount.joint.creation.ui.addaccount + +import android.content.ClipboardManager +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.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.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.addaccount.joint.creation.model.JointAccountSelectionListItem +import com.algorand.android.modules.addaccount.joint.creation.ui.addaccount.viewmodel.AddJointAccountViewModel +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.ui.compose.widget.AccountItemDisplayConfig +import com.algorand.android.ui.compose.widget.ContactIcon +import com.algorand.android.ui.compose.widget.PeraAccountItem +import com.algorand.android.ui.compose.widget.PeraToolbar +import com.algorand.android.ui.compose.widget.PeraToolbarIcon +import com.algorand.android.ui.compose.widget.modifier.clickableNoRipple +import com.algorand.android.ui.compose.widget.text.PeraBodyText +import com.algorand.android.ui.compose.widget.textfield.PeraSlimTextField +import com.algorand.android.utils.getTextFromClipboard +import com.algorand.android.utils.toShortenedAddress + +@Composable +fun AddJointAccountScreen( + viewModel: AddJointAccountViewModel, + listener: AddJointAccountScreenListener +) { + val viewState by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + var localSearchQuery by remember { mutableStateOf("") } + var hasClipboardContent by remember { mutableStateOf(false) } + + LaunchedEffect(viewState.searchQuery) { + localSearchQuery = viewState.searchQuery + } + + LaunchedEffect(Unit) { + hasClipboardContent = checkClipboardContent(context) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(PeraTheme.colors.background.primary) + ) { + ToolbarSection(onBackClick = listener::onBackClick) + + SearchBarSection( + searchQuery = localSearchQuery, + onSearchQueryChange = { + localSearchQuery = it + viewModel.onSearchQueryUpdate(it) + }, + hasClipboardContent = hasClipboardContent, + onPasteClick = { + val clipboardText = context.getTextFromClipboard() + if (!clipboardText.isNullOrBlank()) { + localSearchQuery = clipboardText + viewModel.onSearchQueryUpdate(clipboardText) + } + } + ) + + if (!viewState.hasResults && localSearchQuery.isNotEmpty()) { + EmptyStateSection() + } else { + AccountListSection( + externalAddresses = viewState.externalAddresses, + accounts = viewState.accounts, + contacts = viewState.contacts, + nfds = viewState.nfds, + listener = listener + ) + } + } +} + +@Composable +private fun ToolbarSection(onBackClick: () -> Unit) { + PeraToolbar( + modifier = Modifier.padding(horizontal = 12.dp), + text = stringResource(R.string.add_account), + startContainer = { + PeraToolbarIcon( + iconResId = R.drawable.ic_left_arrow, + modifier = Modifier.clickableNoRipple(onClick = onBackClick) + ) + } + ) +} + +@Composable +private fun SearchBarSection( + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + hasClipboardContent: Boolean, + onPasteClick: () -> Unit +) { + PeraSlimTextField( + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 12.dp) + .background( + color = PeraTheme.colors.layer.grayLighter, + shape = RoundedCornerShape(4.dp) + ), + text = searchQuery, + hint = stringResource(R.string.type_new_address_or_search), + onTextChanged = onSearchQueryChange, + startIconContainer = { + SearchIcon() + }, + endIconContainer = { + if (hasClipboardContent) { + ClipboardIcon(onClick = onPasteClick) + } + } + ) +} + +@Composable +private fun SearchIcon() { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_search), + tint = PeraTheme.colors.text.gray, + contentDescription = stringResource(R.string.search) + ) + Spacer(modifier = Modifier.width(8.dp)) +} + +@Composable +private fun ClipboardIcon(onClick: () -> Unit) { + Icon( + modifier = Modifier + .size(24.dp) + .clickable(onClick = onClick), + painter = painterResource(R.drawable.ic_clipboard), + tint = PeraTheme.colors.text.gray, + contentDescription = stringResource(R.string.paste_from_clipboard) + ) +} + +private fun checkClipboardContent(context: android.content.Context): Boolean { + val clipboard = ContextCompat.getSystemService(context, ClipboardManager::class.java) + return clipboard?.primaryClip?.let { clipData -> + clipData.itemCount > 0 && clipData.getItemAt(0)?.text?.isNotBlank() == true + } ?: false +} + +@Composable +private fun EmptyStateSection() { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 32.dp), + contentAlignment = Alignment.TopStart + ) { + PeraBodyText( + text = stringResource(R.string.no_accounts_found), + color = PeraTheme.colors.text.gray + ) + } +} + +@Composable +private fun AccountListSection( + externalAddresses: List, + accounts: List, + contacts: List, + nfds: List, + listener: AddJointAccountScreenListener +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .padding(top = 16.dp) + ) { + externalAddressesSection(externalAddresses, listener::onExternalAddressSelected) + nfdsSection(nfds, listener::onAccountSelected) + accountsSection(accounts, listener::onAccountSelected) + contactsSection(contacts, listener::onAccountSelected) + } +} + +private fun LazyListScope.externalAddressesSection( + items: List, + onSelect: (String) -> Unit +) { + if (items.isEmpty()) return + items(items = items, key = { "external_${it.address}" }) { item -> + ExternalAddressSelectionItem(externalItem = item, onClick = { onSelect(item.address) }) + } + item { Spacer(modifier = Modifier.height(24.dp)) } +} + +private fun LazyListScope.nfdsSection( + items: List, + onSelect: (String) -> Unit +) { + if (items.isEmpty()) return + items(items = items, key = { "nfd_${it.address}" }) { item -> + NfdSelectionItem(nfdItem = item, onClick = { onSelect(item.address) }) + } + item { Spacer(modifier = Modifier.height(24.dp)) } +} + +@Composable +private fun AccountsSectionHeader() { + SectionHeader(text = stringResource(R.string.accounts)) + Spacer(modifier = Modifier.height(12.dp)) +} + +private fun LazyListScope.accountsSection( + items: List, + onSelect: (String) -> Unit +) { + if (items.isEmpty()) return + item { AccountsSectionHeader() } + items(items = items, key = { "account_${it.address}" }) { item -> + AccountSelectionItem(accountItem = item, onClick = { onSelect(item.address) }) + } + item { Spacer(modifier = Modifier.height(24.dp)) } +} + +@Composable +private fun ContactsSectionHeader() { + SectionHeader(text = stringResource(R.string.contacts)) + Spacer(modifier = Modifier.height(12.dp)) +} + +private fun LazyListScope.contactsSection( + items: List, + onSelect: (String) -> Unit +) { + if (items.isEmpty()) return + item { ContactsSectionHeader() } + items(items = items, key = { "contact_${it.address}" }) { item -> + ContactSelectionItem(contactItem = item, onClick = { onSelect(item.address) }) + } +} + +@Composable +private fun SectionHeader(text: String) { + PeraBodyText( + text = text, + color = PeraTheme.colors.text.gray + ) +} + +@Composable +private fun AccountSelectionItem( + accountItem: JointAccountSelectionListItem.AccountItem, + onClick: () -> Unit +) { + PeraAccountItem( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + iconDrawablePreview = accountItem.iconDrawablePreview, + displayName = AccountDisplayName( + accountAddress = accountItem.address, + primaryDisplayName = accountItem.displayName, + secondaryDisplayName = accountItem.secondaryDisplayName + ), + displayConfig = AccountItemDisplayConfig( + primaryValueText = accountItem.formattedAmount, + secondaryValueText = accountItem.formattedCurrencyValue + ), + onAccountClick = { onClick() } + ) +} + +@Composable +private fun ContactSelectionItem( + contactItem: JointAccountSelectionListItem.ContactItem, + onClick: () -> Unit +) { + PeraAccountItem( + modifier = Modifier.padding(vertical = 8.dp), + displayName = AccountDisplayName( + accountAddress = contactItem.address, + primaryDisplayName = contactItem.displayName, + secondaryDisplayName = contactItem.address.toShortenedAddress() + ), + onAccountClick = { onClick() }, + iconContent = { + ContactIcon( + imageUri = contactItem.imageUri, + size = 40.dp + ) + } + ) +} + +@Composable +private fun NfdSelectionItem( + nfdItem: JointAccountSelectionListItem.NfdItem, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .clickable(onClick = onClick), + verticalAlignment = Alignment.CenterVertically + ) { + ContactIcon(size = 40.dp) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { + PeraBodyText( + text = nfdItem.domainName, + color = PeraTheme.colors.text.main + ) + PeraBodyText( + text = nfdItem.address.toShortenedAddress(), + color = PeraTheme.colors.text.grayLighter + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Box( + modifier = Modifier + .size(40.dp) + .background( + color = PeraTheme.colors.button.square.background, + shape = RoundedCornerShape(8.dp) + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_plus), + contentDescription = stringResource(R.string.add_account), + tint = PeraTheme.colors.button.square.icon + ) + } + } +} + +@Composable +private fun ExternalAddressSelectionItem( + externalItem: JointAccountSelectionListItem.ExternalAddressItem, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = PeraTheme.colors.wallet.wallet1.background, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(AccountIconResource.CONTACT.iconResId), + contentDescription = stringResource(R.string.address), + tint = PeraTheme.colors.wallet.wallet1.icon + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + PeraBodyText( + text = externalItem.shortenedAddress, + color = PeraTheme.colors.text.main, + modifier = Modifier.weight(1f) + ) + + Box( + modifier = Modifier + .size(40.dp) + .background( + color = PeraTheme.colors.button.square.background, + shape = RoundedCornerShape(8.dp) + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_plus), + contentDescription = stringResource(R.string.add_account), + tint = PeraTheme.colors.button.square.icon + ) + } + } +} + +interface AddJointAccountScreenListener { + fun onBackClick() + fun onAccountSelected(address: String) + fun onExternalAddressSelected(address: String) +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/addaccount/viewmodel/AddJointAccountViewModel.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/addaccount/viewmodel/AddJointAccountViewModel.kt new file mode 100644 index 000000000..1adf4c234 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/addaccount/viewmodel/AddJointAccountViewModel.kt @@ -0,0 +1,108 @@ +/* + * 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.addaccount.joint.creation.ui.addaccount.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.algorand.android.modules.addaccount.joint.creation.mapper.SelectedJointAccountMapper +import com.algorand.android.modules.addaccount.joint.creation.model.JointAccountSelectionListItem +import com.algorand.android.modules.addaccount.joint.creation.model.SelectedJointAccountItem +import com.algorand.android.modules.addaccount.joint.creation.usecase.AddJointAccountSelectionUseCase +import com.algorand.android.modules.addaccount.joint.creation.usecase.CreateExternalAddressAsContact +import com.algorand.android.utils.toShortenedAddress +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.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AddJointAccountViewModel @Inject constructor( + private val stateDelegate: StateDelegate, + private val addJointAccountSelectionUseCase: AddJointAccountSelectionUseCase, + private val createExternalAddressAsContact: CreateExternalAddressAsContact, + private val selectedJointAccountMapper: SelectedJointAccountMapper +) : ViewModel(), + StateViewModel by stateDelegate { + + private var searchJob: Job? = null + + init { + stateDelegate.setDefaultState(ViewState()) + loadAccountList() + } + + fun onSearchQueryUpdate(query: String) { + stateDelegate.updateState { it.copy(searchQuery = query) } + searchJob?.cancel() + searchJob = viewModelScope.launch { + delay(SEARCH_DEBOUNCE_DELAY_MS) + loadAccountList() + } + } + + fun resetSearchQuery() { + searchJob?.cancel() + stateDelegate.updateState { it.copy(searchQuery = "") } + loadAccountList() + } + + fun createSelectedAccountFromItem(address: String): SelectedJointAccountItem? { + return selectedJointAccountMapper.mapFromSelectionList(address, state.value.accountList) + } + + suspend fun createSelectedAccountFromExternalAddress(address: String): SelectedJointAccountItem? { + val currentList = state.value.accountList + val externalItem = currentList.filterIsInstance() + .find { it.address == address } + + val shortenedAddress = externalItem?.shortenedAddress ?: address.toShortenedAddress() + + return createExternalAddressAsContact(address, shortenedAddress) + } + + private fun loadAccountList() { + viewModelScope.launch { + addJointAccountSelectionUseCase.getAccountSelectionList( + query = state.value.searchQuery, + ).collectLatest { list -> + stateDelegate.updateState { it.copy(accountList = list).withCategorizedLists() } + } + } + } + + data class ViewState( + val searchQuery: String = "", + val accountList: List = emptyList(), + val externalAddresses: List = emptyList(), + val accounts: List = emptyList(), + val contacts: List = emptyList(), + val nfds: List = emptyList() + ) { + val hasResults: Boolean get() = accountList.isNotEmpty() + + fun withCategorizedLists(): ViewState = copy( + externalAddresses = accountList.filterIsInstance(), + accounts = accountList.filterIsInstance(), + contacts = accountList.filterIsInstance(), + nfds = accountList.filterIsInstance() + ) + } + + companion object { + private const val SEARCH_DEBOUNCE_DELAY_MS = 300L + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/createaccount/CreateJointAccountFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/createaccount/CreateJointAccountFragment.kt new file mode 100644 index 000000000..953ee722f --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/createaccount/CreateJointAccountFragment.kt @@ -0,0 +1,141 @@ +/* + * 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.addaccount.joint.creation.ui.createaccount + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.algorand.android.R +import com.algorand.android.core.DaggerBaseFragment +import com.algorand.android.models.FragmentConfiguration +import com.algorand.android.modules.addaccount.joint.creation.model.SelectedJointAccountItem +import com.algorand.android.modules.addaccount.joint.creation.ui.addaccount.AddJointAccountFragment +import com.algorand.android.modules.addaccount.joint.creation.ui.createaccount.viewmodel.CreateJointAccountViewModel +import com.algorand.android.modules.addaccount.joint.creation.ui.editname.EditAccountNameFragment +import com.algorand.android.ui.compose.extensions.createComposeView +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class CreateJointAccountFragment : DaggerBaseFragment(0), CreateJointAccountScreenListener { + + private val viewModel: CreateJointAccountViewModel by viewModels() + + override val fragmentConfiguration: FragmentConfiguration = FragmentConfiguration() + + private val onBackPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + handleBackNavigation() + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return createComposeView { + CreateJointAccountScreen( + viewModel = viewModel, + listener = this + ) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback) + observeResults() + } + + private fun observeResults() { + val savedStateHandle = findNavController().currentBackStackEntry?.savedStateHandle + + savedStateHandle?.getLiveData(AddJointAccountFragment.RESULT_SELECTED_ACCOUNT) + ?.observe(viewLifecycleOwner) { selectedAccount -> + selectedAccount?.let { + val wasAdded = viewModel.addSelectedAccount(it) + if (!wasAdded) { + showGlobalError(getString(R.string.this_account_already_exists)) + } + savedStateHandle.remove(AddJointAccountFragment.RESULT_SELECTED_ACCOUNT) + } + } + + savedStateHandle?.getLiveData(EditAccountNameFragment.RESULT_UPDATED_NAME) + ?.observe(viewLifecycleOwner) { updatedName -> + val address = savedStateHandle.get(EditAccountNameFragment.RESULT_ADDRESS) + if (updatedName != null && address != null) { + viewModel.updateAccountName(address, updatedName) + savedStateHandle.remove(EditAccountNameFragment.RESULT_UPDATED_NAME) + savedStateHandle.remove(EditAccountNameFragment.RESULT_ADDRESS) + } + } + + savedStateHandle?.getLiveData(EditAccountNameFragment.RESULT_REMOVED_ADDRESS) + ?.observe(viewLifecycleOwner) { removedAddress -> + removedAddress?.let { + viewModel.removeSelectedAccount(it) + savedStateHandle.remove(EditAccountNameFragment.RESULT_REMOVED_ADDRESS) + } + } + } + + override fun onBackClick() { + handleBackNavigation() + } + + private fun handleBackNavigation() { + navBack() + } + + override fun onAddAccountClick() { + navToAddJointAccountFragment() + } + + override fun onEditAccountClick(address: String) { + navToEditAccountNameFragment(address) + } + + override fun onContinueClick() { + navToSetThresholdFragment() + } + + private fun navToAddJointAccountFragment() { + nav( + CreateJointAccountFragmentDirections + .actionCreateJointAccountFragmentToAddJointAccountFragment() + ) + } + + private fun navToEditAccountNameFragment(accountAddress: String) { + nav( + CreateJointAccountFragmentDirections + .actionCreateJointAccountFragmentToEditAccountNameFragment( + accountAddress = accountAddress + ) + ) + } + + private fun navToSetThresholdFragment() { + nav( + CreateJointAccountFragmentDirections + .actionCreateJointAccountFragmentToSetThresholdFragment( + participantAddresses = viewModel.getParticipantAddresses() + ) + ) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/createaccount/CreateJointAccountScreen.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/createaccount/CreateJointAccountScreen.kt new file mode 100644 index 000000000..30d8551aa --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/createaccount/CreateJointAccountScreen.kt @@ -0,0 +1,274 @@ +/* + * 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 + */ + +@file:Suppress("MagicNumber") + +package com.algorand.android.modules.addaccount.joint.creation.ui.createaccount + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.algorand.android.R +import com.algorand.android.modules.addaccount.joint.creation.model.SelectedJointAccountItem +import com.algorand.android.modules.addaccount.joint.creation.ui.createaccount.viewmodel.CreateJointAccountViewModel +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.ui.compose.widget.AccountIcon +import com.algorand.android.ui.compose.widget.ContactIcon +import com.algorand.android.ui.compose.widget.PeraAccountItem +import com.algorand.android.ui.compose.widget.PeraToolbarIcon +import com.algorand.android.ui.compose.widget.button.PeraButtonState +import com.algorand.android.ui.compose.widget.button.PeraPrimaryButton +import com.algorand.android.ui.compose.widget.modifier.clickableNoRipple +import com.algorand.android.ui.compose.widget.text.PeraBodyText +import com.algorand.android.ui.compose.widget.text.PeraTitleText +import com.algorand.android.utils.toShortenedAddress + +@Composable +fun CreateJointAccountScreen( + viewModel: CreateJointAccountViewModel, + listener: CreateJointAccountScreenListener +) { + val viewState by viewModel.state.collectAsStateWithLifecycle() + val selectedAccounts = viewState.selectedAccounts + Column( + modifier = Modifier.fillMaxSize() + ) { + ToolbarSection(listener = listener) + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + ContentSection( + selectedAccounts = selectedAccounts, + viewModel = viewModel, + listener = listener + ) + } + ContinueButtonSection( + isContinueEnabled = viewState.isContinueEnabled, + onContinueClick = listener::onContinueClick + ) + } +} + +@Composable +private fun ToolbarSection(listener: CreateJointAccountScreenListener) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + ) { + PeraToolbarIcon( + iconResId = R.drawable.ic_left_arrow, + modifier = Modifier.clickableNoRipple(onClick = listener::onBackClick) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + modifier = Modifier.padding(horizontal = 12.dp), + text = stringResource(R.string.create_joint_account), + style = PeraTheme.typography.title.regular.sansMedium, + color = PeraTheme.colors.text.main + ) + } +} + +@Composable +private fun ContentSection( + selectedAccounts: List, + viewModel: CreateJointAccountViewModel, + listener: CreateJointAccountScreenListener +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + ) { + Spacer(modifier = Modifier.height(12.dp)) + DescriptionSection() + Spacer(modifier = Modifier.height(32.dp)) + AccountsSection( + selectedAccounts = selectedAccounts, + viewModel = viewModel, + listener = listener + ) + Spacer(modifier = Modifier.height(8.dp)) + AddAccountButtonSection(onAddAccountClick = listener::onAddAccountClick) + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun DescriptionSection() { + PeraBodyText( + text = stringResource(R.string.create_joint_account_description), + color = PeraTheme.colors.text.gray + ) +} + +@Composable +private fun AccountsSection( + selectedAccounts: List, + viewModel: CreateJointAccountViewModel, + listener: CreateJointAccountScreenListener +) { + PeraTitleText( + text = stringResource(R.string.accounts) + ) + Spacer(modifier = Modifier.height(4.dp)) + PeraBodyText( + text = stringResource(R.string.joint_account_min_accounts_required), + color = PeraTheme.colors.text.gray + ) + Spacer(modifier = Modifier.height(16.dp)) + selectedAccounts.forEach { account -> + SelectedAccountItem( + account = account, + onEditClick = { handleContactEditClick(account, listener) }, + onRemoveClick = { handleAccountRemoveClick(account, viewModel) } + ) + } +} + +private fun handleContactEditClick( + account: SelectedJointAccountItem, + listener: CreateJointAccountScreenListener +) { + listener.onEditAccountClick(account.accountDisplayName.accountAddress) +} + +private fun handleAccountRemoveClick( + account: SelectedJointAccountItem, + viewModel: CreateJointAccountViewModel, +) { + viewModel.removeSelectedAccount(account.accountDisplayName.accountAddress) +} + +@Composable +private fun AddAccountButtonSection(onAddAccountClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onAddAccountClick) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_plus), + contentDescription = stringResource(id = R.string.add_account), + tint = PeraTheme.colors.helper.positive + ) + Spacer(modifier = Modifier.width(8.dp)) + PeraBodyText( + text = stringResource(id = R.string.add_account), + color = PeraTheme.colors.helper.positive + ) + } +} + +@Composable +private fun ContinueButtonSection( + isContinueEnabled: Boolean, + onContinueClick: () -> Unit +) { + PeraPrimaryButton( + text = stringResource(R.string.continue_text), + onClick = onContinueClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 24.dp), + state = if (isContinueEnabled) PeraButtonState.ENABLED else PeraButtonState.DISABLED + ) +} + +@Composable +private fun SelectedAccountItem( + account: SelectedJointAccountItem, + onEditClick: () -> Unit, + onRemoveClick: () -> Unit +) { + val displayName = account.accountDisplayName + PeraAccountItem( + modifier = Modifier.padding(vertical = 12.dp), + displayName = displayName.copy( + secondaryDisplayName = displayName.secondaryDisplayName + ?: displayName.accountAddress.toShortenedAddress() + ), + canCopyable = false, + iconContent = { + when { + account.isContact -> { + ContactIcon(imageUri = account.imageUri, size = 40.dp) + } + account.iconDrawablePreview != null -> { + AccountIcon( + modifier = Modifier.size(40.dp), + iconDrawablePreview = account.iconDrawablePreview + ) + } + else -> ContactIcon(size = 40.dp) + } + }, + trailingContent = { + if (account.isContact) { + IconButton(onClick = onEditClick) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_pen), + contentDescription = stringResource(R.string.edit_contact), + tint = PeraTheme.colors.button.square.icon + ) + } + } else { + IconButton(onClick = onRemoveClick) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_trash), + contentDescription = stringResource(R.string.remove), + tint = PeraTheme.colors.text.gray + ) + } + } + } + ) +} + +interface CreateJointAccountScreenListener { + fun onBackClick() + fun onAddAccountClick() + fun onEditAccountClick(address: String) + fun onContinueClick() +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/createaccount/viewmodel/CreateJointAccountViewModel.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/createaccount/viewmodel/CreateJointAccountViewModel.kt new file mode 100644 index 000000000..89297f1cc --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/createaccount/viewmodel/CreateJointAccountViewModel.kt @@ -0,0 +1,83 @@ +/* + * 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.addaccount.joint.creation.ui.createaccount.viewmodel + +import androidx.lifecycle.ViewModel +import com.algorand.android.modules.addaccount.joint.creation.model.SelectedJointAccountItem +import com.algorand.wallet.viewmodel.StateDelegate +import com.algorand.wallet.viewmodel.StateViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class CreateJointAccountViewModel @Inject constructor( + private val stateDelegate: StateDelegate +) : ViewModel(), + StateViewModel by stateDelegate { + + init { + stateDelegate.setDefaultState(ViewState()) + } + + fun addSelectedAccount(account: SelectedJointAccountItem): Boolean { + val currentAccounts = state.value.selectedAccounts + val addressExists = currentAccounts.any { + it.accountDisplayName.accountAddress == account.accountDisplayName.accountAddress + } + if (addressExists) return false + + stateDelegate.updateState { currentState -> + currentState.copy(selectedAccounts = currentState.selectedAccounts + account) + } + return true + } + + fun updateAccountName(address: String, name: String) { + if (name.isBlank()) return + stateDelegate.updateState { currentState -> + val updatedList = currentState.selectedAccounts.map { item -> + if (item.accountDisplayName.accountAddress == address) { + val updatedDisplayName = item.accountDisplayName.copy(primaryDisplayName = name) + item.copy(accountDisplayName = updatedDisplayName) + } else { + item + } + } + currentState.copy(selectedAccounts = updatedList) + } + } + + fun removeSelectedAccount(address: String) { + stateDelegate.updateState { currentState -> + val updatedList = currentState.selectedAccounts.filterNot { + it.accountDisplayName.accountAddress == address + } + currentState.copy(selectedAccounts = updatedList) + } + } + + fun getParticipantAddresses(): Array = state.value.selectedAccounts + .map { it.accountDisplayName.accountAddress } + .toTypedArray() + + data class ViewState( + val selectedAccounts: List = emptyList() + ) { + val isContinueEnabled: Boolean + get() = selectedAccounts.size >= MIN_PARTICIPANTS_COUNT + } + + companion object { + private const val MIN_PARTICIPANTS_COUNT = 2 + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/editname/EditAccountNameFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/editname/EditAccountNameFragment.kt new file mode 100644 index 000000000..012ab6456 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/editname/EditAccountNameFragment.kt @@ -0,0 +1,83 @@ +/* + * 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.addaccount.joint.creation.ui.editname + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.algorand.android.core.DaggerBaseFragment +import com.algorand.android.models.FragmentConfiguration +import com.algorand.android.modules.addaccount.joint.creation.ui.editname.viewmodel.EditAccountNameViewModel +import com.algorand.android.modules.addaccount.joint.creation.ui.editname.viewmodel.EditAccountNameViewModel.ViewState +import com.algorand.android.ui.compose.extensions.createComposeView +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class EditAccountNameFragment : DaggerBaseFragment(0), EditAccountNameScreenListener { + + private val args: EditAccountNameFragmentArgs by navArgs() + private val viewModel: EditAccountNameViewModel by viewModels() + + override val fragmentConfiguration = FragmentConfiguration() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return createComposeView { + val viewState by viewModel.state.collectAsStateWithLifecycle() + when (val state = viewState) { + is ViewState.Loading -> Unit + is ViewState.Content -> { + EditAccountNameScreen( + account = state.account, + listener = this + ) + } + } + } + } + + override fun onBackClick() { + navBack() + } + + override fun onDoneClick(name: String) { + findNavController().previousBackStackEntry?.savedStateHandle?.apply { + set(RESULT_UPDATED_NAME, name) + set(RESULT_ADDRESS, args.accountAddress) + } + navBack() + } + + override fun onRemoveClick() { + findNavController().previousBackStackEntry?.savedStateHandle?.set( + RESULT_REMOVED_ADDRESS, + args.accountAddress + ) + navBack() + } + + companion object { + const val RESULT_UPDATED_NAME = "result_updated_name" + const val RESULT_ADDRESS = "result_address" + const val RESULT_REMOVED_ADDRESS = "result_removed_address" + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/editname/EditAccountNameScreen.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/editname/EditAccountNameScreen.kt new file mode 100644 index 000000000..91dc02a0f --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/editname/EditAccountNameScreen.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 + */ + +@file:Suppress("MagicNumber") + +package com.algorand.android.modules.addaccount.joint.creation.ui.editname + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.algorand.android.R +import com.algorand.android.modules.addaccount.joint.creation.model.SelectedJointAccountItem +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.ui.compose.widget.ContactIcon +import com.algorand.android.ui.compose.widget.PeraToolbar +import com.algorand.android.ui.compose.widget.PeraToolbarIcon +import com.algorand.android.ui.compose.widget.PeraToolbarTextButton +import com.algorand.android.ui.compose.widget.modifier.clickableNoRipple +import com.algorand.android.ui.compose.widget.textfield.PeraTextField +import com.algorand.android.ui.compose.widget.textfield.PeraTextFieldLabel + +@Composable +fun EditAccountNameScreen( + account: SelectedJointAccountItem, + listener: EditAccountNameScreenListener +) { + var name by remember { mutableStateOf(account.accountDisplayName.primaryDisplayName) } + + Column( + modifier = Modifier.fillMaxSize() + ) { + ToolbarSection( + name = name, + onBackClick = listener::onBackClick, + onDoneClick = { listener.onDoneClick(name) } + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(32.dp)) + + ContactIcon( + backgroundColor = PeraTheme.colors.layer.grayLighter, + iconTint = PeraTheme.colors.text.gray, + imageUri = account.imageUri, + size = 80.dp, + contentDescription = stringResource(R.string.contacts) + ) + + Spacer(modifier = Modifier.height(72.dp)) + + NameInputSection( + name = name, + onNameChange = { name = it } + ) + + Spacer(modifier = Modifier.height(24.dp)) + + AccountAddressSection(address = account.accountDisplayName.accountAddress) + + Spacer(modifier = Modifier.height(32.dp)) + + if (account.isContact) { + RemoveAddressButtonSection(onRemoveClick = listener::onRemoveClick) + } + } + } +} + +@Composable +private fun ToolbarSection( + name: String, + onBackClick: () -> Unit, + onDoneClick: () -> Unit +) { + PeraToolbar( + modifier = Modifier.padding(horizontal = 12.dp), + text = stringResource(R.string.edit_address), + startContainer = { + PeraToolbarIcon( + iconResId = R.drawable.ic_left_arrow, + modifier = Modifier.clickableNoRipple(onClick = onBackClick) + ) + }, + endContainer = { + PeraToolbarTextButton( + text = stringResource(R.string.done), + onClick = onDoneClick, + enabled = name.isNotBlank() + ) + } + ) +} + +@Composable +private fun NameInputSection( + name: String, + onNameChange: (String) -> Unit +) { + PeraTextField( + modifier = Modifier.fillMaxWidth(), + text = name, + onTextChanged = onNameChange, + label = { PeraTextFieldLabel(text = stringResource(R.string.nickname_optional)) }, + hint = stringResource(R.string.add_a_nickname) + ) +} + +@Composable +private fun AccountAddressSection(address: String) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.account_address), + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.grayLighter + ) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + text = address, + style = PeraTheme.typography.body.regular.sans, + color = PeraTheme.colors.text.gray + ) + } +} + +@Composable +private fun RemoveAddressButtonSection(onRemoveClick: () -> Unit) { + Button( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp) + .height(52.dp), + onClick = onRemoveClick, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors( + containerColor = PeraTheme.colors.helper.negativeLighter, + contentColor = PeraTheme.colors.helper.negative + ), + contentPadding = PaddingValues(16.dp) + ) { + Text( + text = stringResource(R.string.remove_address), + style = PeraTheme.typography.body.regular.sansMedium + ) + } +} + +interface EditAccountNameScreenListener { + fun onBackClick() + fun onDoneClick(name: String) + fun onRemoveClick() +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/editname/preview/EditAccountNameScreenPreview.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/editname/preview/EditAccountNameScreenPreview.kt new file mode 100644 index 000000000..4da584939 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/editname/preview/EditAccountNameScreenPreview.kt @@ -0,0 +1,117 @@ +@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 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.addaccount.joint.creation.ui.editname.preview + +import androidx.compose.runtime.Composable +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.addaccount.joint.creation.model.SelectedJointAccountItem +import com.algorand.android.modules.addaccount.joint.creation.ui.editname.EditAccountNameScreen +import com.algorand.android.modules.addaccount.joint.creation.ui.editname.EditAccountNameScreenListener +import com.algorand.android.ui.compose.preview.PeraPreviewLightDark +import com.algorand.android.ui.compose.theme.PeraTheme + +@PeraPreviewLightDark +@Composable +fun EditAccountNameScreenPreview() { + PeraTheme { + val listener = object : EditAccountNameScreenListener { + override fun onBackClick() {} + override fun onDoneClick(name: String) {} + override fun onRemoveClick() {} + } + EditAccountNameScreen( + account = getMockAccount(), + listener = listener + ) + } +} + +@PeraPreviewLightDark +@Composable +fun EditAccountNameScreenWithNamePreview() { + PeraTheme { + val listener = object : EditAccountNameScreenListener { + override fun onBackClick() {} + override fun onDoneClick(name: String) {} + override fun onRemoveClick() {} + } + EditAccountNameScreen( + account = getMockAccountWithName(), + listener = listener + ) + } +} + +@PeraPreviewLightDark +@Composable +fun EditAccountNameScreenContactPreview() { + PeraTheme { + val listener = object : EditAccountNameScreenListener { + override fun onBackClick() {} + override fun onDoneClick(name: String) {} + override fun onRemoveClick() {} + } + EditAccountNameScreen( + account = getMockContact(), + listener = listener + ) + } +} + +private fun getMockAccount(): SelectedJointAccountItem { + return SelectedJointAccountItem( + accountDisplayName = AccountDisplayName( + accountAddress = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890", + primaryDisplayName = "Account 1", + secondaryDisplayName = "ABCD...7890" + ), + iconDrawablePreview = AccountIconDrawablePreview( + backgroundColorResId = R.color.layer_gray_lighter, + iconTintResId = R.color.text_gray, + iconResId = AccountIconResource.CONTACT.iconResId + ), + isContact = false + ) +} + +private fun getMockAccountWithName(): SelectedJointAccountItem { + return SelectedJointAccountItem( + accountDisplayName = AccountDisplayName( + accountAddress = "BCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890A", + primaryDisplayName = "My Wallet", + secondaryDisplayName = "Ledger Account" + ), + iconDrawablePreview = AccountIconDrawablePreview( + backgroundColorResId = R.color.layer_gray_lighter, + iconTintResId = R.color.text_gray, + iconResId = AccountIconResource.CONTACT.iconResId + ), + isContact = false + ) +} + +private fun getMockContact(): SelectedJointAccountItem { + return SelectedJointAccountItem( + accountDisplayName = AccountDisplayName( + accountAddress = "CDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890AB", + primaryDisplayName = "John Doe", + secondaryDisplayName = "CDEF...90AB" + ), + imageUri = null, + isContact = true + ) +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/editname/viewmodel/EditAccountNameViewModel.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/editname/viewmodel/EditAccountNameViewModel.kt new file mode 100644 index 000000000..73730e72c --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/editname/viewmodel/EditAccountNameViewModel.kt @@ -0,0 +1,74 @@ +/* + * 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.addaccount.joint.creation.ui.editname.viewmodel + +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.algorand.android.modules.accountcore.ui.model.AccountDisplayName +import com.algorand.android.modules.accountcore.ui.usecase.GetAccountIconDrawablePreview +import com.algorand.android.modules.addaccount.joint.creation.model.SelectedJointAccountItem +import com.algorand.android.repository.ContactRepository +import com.algorand.android.utils.toShortenedAddress +import com.algorand.wallet.viewmodel.StateDelegate +import com.algorand.wallet.viewmodel.StateViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class EditAccountNameViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val stateDelegate: StateDelegate, + private val contactRepository: ContactRepository, + private val getAccountIconDrawablePreview: GetAccountIconDrawablePreview +) : ViewModel(), + StateViewModel by stateDelegate { + + private val accountAddress: String = savedStateHandle.get(ACCOUNT_ADDRESS_KEY).orEmpty() + + init { + stateDelegate.setDefaultState(ViewState.Loading) + loadAccountInfo() + } + + private fun loadAccountInfo() { + viewModelScope.launch { + val contact = contactRepository.getContactByAddress(accountAddress) + val imageUri = contact?.imageUriAsString?.let { Uri.parse(it) } + val iconDrawablePreview = getAccountIconDrawablePreview(accountAddress) + + val account = SelectedJointAccountItem( + accountDisplayName = AccountDisplayName( + accountAddress = accountAddress, + primaryDisplayName = contact?.name ?: accountAddress.toShortenedAddress(), + secondaryDisplayName = accountAddress.toShortenedAddress() + ), + iconDrawablePreview = iconDrawablePreview, + imageUri = imageUri, + isContact = contact != null + ) + stateDelegate.updateState { ViewState.Content(account) } + } + } + + sealed interface ViewState { + data object Loading : ViewState + data class Content(val account: SelectedJointAccountItem) : ViewState + } + + companion object { + private const val ACCOUNT_ADDRESS_KEY = "accountAddress" + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/namejointaccount/NameJointAccountFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/namejointaccount/NameJointAccountFragment.kt new file mode 100644 index 000000000..26f7631f2 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/namejointaccount/NameJointAccountFragment.kt @@ -0,0 +1,130 @@ +/* + * 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.addaccount.joint.creation.ui.namejointaccount + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.algorand.android.MainActivity +import com.algorand.android.R +import com.algorand.android.core.DaggerBaseFragment +import com.algorand.android.customviews.LoadingDialogFragment +import com.algorand.android.models.FragmentConfiguration +import com.algorand.android.modules.addaccount.joint.creation.ui.namejointaccount.viewmodel.NameJointAccountViewModel +import com.algorand.android.modules.addaccount.joint.creation.ui.namejointaccount.viewmodel.NameJointAccountViewModel.ViewEvent +import com.algorand.android.modules.addaccount.joint.creation.ui.namejointaccount.viewmodel.NameJointAccountViewModel.ViewState +import com.algorand.android.ui.compose.extensions.createComposeView +import com.algorand.android.utils.extensions.collectLatestOnLifecycle +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class NameJointAccountFragment : DaggerBaseFragment(0), NameJointAccountScreenListener { + + private val viewModel: NameJointAccountViewModel by viewModels() + private val args: NameJointAccountFragmentArgs by navArgs() + private var loadingDialogFragment: LoadingDialogFragment? = null + + override val fragmentConfiguration = FragmentConfiguration() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return createComposeView { + NameJointAccountScreen( + viewModel = viewModel, + listener = this + ) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initObservers() + } + + private fun initObservers() { + viewLifecycleOwner.collectLatestOnLifecycle( + flow = viewModel.state, + collection = ::handleViewState + ) + viewLifecycleOwner.collectLatestOnLifecycle( + flow = viewModel.viewEvent, + collection = ::handleViewEvent + ) + } + + private fun handleViewState(viewState: ViewState) { + when (viewState) { + is ViewState.Loading -> showLoadingDialog() + is ViewState.Success -> dismissLoadingDialog() + is ViewState.Idle, is ViewState.Error -> dismissLoadingDialog() + } + } + + private fun handleViewEvent(event: ViewEvent) { + when (event) { + is ViewEvent.AccountCreatedSuccessfully -> { + dismissLoadingDialog() + showSuccessMessage() + popBackToAccounts() + } + } + } + + private fun showSuccessMessage() { + val mainActivity = activity as? MainActivity + mainActivity?.showAlertSuccess( + title = getString(R.string.account_has_been_added), + description = null, + tag = this::class.simpleName.orEmpty() + ) + } + + private fun showLoadingDialog() { + if (loadingDialogFragment == null) { + loadingDialogFragment = LoadingDialogFragment.show( + childFragmentManager = childFragmentManager, + descriptionResId = R.string.creating_joint_account, + isCancellable = false + ) + } + } + + private fun dismissLoadingDialog() { + loadingDialogFragment?.dismissAllowingStateLoss() + loadingDialogFragment = null + } + + override fun onBackClick() { + navBack() + } + + override fun onFinishClick(accountName: String) { + val participantAddresses = args.participantAddresses?.toList() ?: emptyList() + viewModel.createJointAccount( + accountName = accountName, + threshold = args.threshold, + participantAddresses = participantAddresses + ) + } + + private fun popBackToAccounts() { + findNavController().popBackStack(R.id.accountsFragment, false) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/namejointaccount/NameJointAccountScreen.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/namejointaccount/NameJointAccountScreen.kt new file mode 100644 index 000000000..dd186736d --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/namejointaccount/NameJointAccountScreen.kt @@ -0,0 +1,186 @@ +/* + * 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.addaccount.joint.creation.ui.namejointaccount + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Column +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.algorand.android.R +import com.algorand.android.modules.addaccount.joint.creation.ui.namejointaccount.viewmodel.NameJointAccountViewModel +import com.algorand.android.modules.addaccount.joint.creation.ui.namejointaccount.viewmodel.NameJointAccountViewModel.ViewState +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.ui.compose.widget.PeraToolbar +import com.algorand.android.ui.compose.widget.PeraToolbarIcon +import com.algorand.android.ui.compose.widget.button.PeraButtonState +import com.algorand.android.ui.compose.widget.button.PeraPrimaryButton +import com.algorand.android.ui.compose.widget.modifier.clickableNoRipple +import com.algorand.android.ui.compose.widget.text.PeraBodyText +import com.algorand.android.ui.compose.widget.text.PeraWarningText +import com.algorand.android.ui.compose.widget.textfield.PeraTextField + +@Composable +fun NameJointAccountScreen( + viewModel: NameJointAccountViewModel, + listener: NameJointAccountScreenListener +) { + var accountName by remember { mutableStateOf("") } + val viewState by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + accountName = viewModel.getDefaultAccountName() + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + ToolbarSection(listener = listener) + + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) { + Spacer(modifier = Modifier.height(12.dp)) + TitleSection() + Spacer(modifier = Modifier.height(16.dp)) + DescriptionSection() + Spacer(modifier = Modifier.height(24.dp)) + AccountNameInputSection( + accountName = accountName, + onAccountNameChange = { accountName = it } + ) + ErrorMessageSection(viewState = viewState) + } + + FinishButtonSection( + accountName = accountName, + viewState = viewState, + onFinishClick = { listener.onFinishClick(accountName) } + ) + } +} + +@Composable +private fun ToolbarSection(listener: NameJointAccountScreenListener) { + PeraToolbar( + modifier = Modifier.padding(horizontal = 12.dp), + text = "", + startContainer = { + PeraToolbarIcon( + iconResId = R.drawable.ic_left_arrow, + modifier = Modifier.clickableNoRipple(onClick = listener::onBackClick) + ) + } + ) +} + +@Composable +private fun TitleSection() { + Text( + text = stringResource(R.string.name_your_account), + style = PeraTheme.typography.title.regular.sansMedium, + color = PeraTheme.colors.text.main + ) +} + +@Composable +private fun DescriptionSection() { + PeraBodyText( + text = stringResource(R.string.name_your_account_to), + color = PeraTheme.colors.text.gray + ) +} + +@Composable +private fun AccountNameInputSection( + accountName: String, + onAccountNameChange: (String) -> Unit +) { + PeraTextField( + modifier = Modifier.fillMaxWidth(), + text = accountName, + onTextChanged = onAccountNameChange, + hint = stringResource(R.string.joint_account) + ) +} + +@Composable +private fun ErrorMessageSection(viewState: ViewState) { + val errorMessage = when (viewState) { + is ViewState.Error -> stringResource(viewState.messageResId) + else -> null + } + ErrorText( + error = errorMessage ?: "", + isVisible = errorMessage != null + ) +} + +@Composable +private fun FinishButtonSection( + accountName: String, + viewState: ViewState, + onFinishClick: () -> Unit +) { + val buttonState = when (viewState) { + is ViewState.Loading -> PeraButtonState.PROGRESS + is ViewState.Success -> PeraButtonState.PROGRESS + is ViewState.Idle, is ViewState.Error -> { + if (accountName.isNotBlank()) PeraButtonState.ENABLED else PeraButtonState.DISABLED + } + } + + PeraPrimaryButton( + text = stringResource(R.string.finish_account_creation), + onClick = onFinishClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 24.dp), + state = buttonState + ) +} + +@Composable +private fun ErrorText(error: String, isVisible: Boolean) { + val alphaAnimation = animateFloatAsState(if (isVisible) 1f else 0f, label = "errorAlpha") + if (isVisible && error.isNotBlank()) { + PeraWarningText( + modifier = Modifier + .padding(horizontal = 0.dp, vertical = 8.dp) + .alpha(alphaAnimation.value), + text = error + ) + } +} + +interface NameJointAccountScreenListener { + fun onBackClick() + fun onFinishClick(accountName: String) +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/namejointaccount/viewmodel/DefaultNameJointAccountProcessor.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/namejointaccount/viewmodel/DefaultNameJointAccountProcessor.kt new file mode 100644 index 000000000..ad705d386 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/namejointaccount/viewmodel/DefaultNameJointAccountProcessor.kt @@ -0,0 +1,118 @@ +/* + * 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.addaccount.joint.creation.ui.namejointaccount.viewmodel + +import android.util.Log +import com.algorand.android.R +import com.algorand.android.deviceregistration.domain.usecase.DeviceIdUseCase +import com.algorand.android.modules.addaccount.joint.creation.domain.exception.JointAccountValidationException +import com.algorand.wallet.inbox.domain.usecase.DeleteInboxJointInvitationNotification +import com.algorand.wallet.account.core.domain.usecase.AddJointAccount +import com.algorand.wallet.account.custom.domain.usecase.GetAllAccountOrderIndexes +import com.algorand.wallet.jointaccount.domain.usecase.GetJointAccount +import java.io.IOException +import javax.inject.Inject + +internal class DefaultNameJointAccountProcessor @Inject constructor( + private val getAllAccountOrderIndexes: GetAllAccountOrderIndexes, + private val getJointAccount: GetJointAccount, + private val addJointAccount: AddJointAccount, + private val deviceIdUseCase: DeviceIdUseCase, + private val deleteInboxJointInvitationNotification: DeleteInboxJointInvitationNotification +) : NameJointAccountProcessor { + + override fun mapExceptionToErrorResId(exception: Throwable?): Int { + return when (exception) { + is JointAccountValidationException -> R.string.joint_account_validation_insufficient_participants + is IOException -> R.string.the_internet_connection + else -> R.string.an_error_occurred + } + } + + override suspend fun createLocalAccount( + jointAccountAddress: String, + participantAddresses: List, + threshold: Int, + version: Int, + accountName: String + ): NameJointAccountProcessor.CreateLocalAccountResult { + if (isAccountAlreadyExists(jointAccountAddress)) { + tryDeleteInboxNotification(jointAccountAddress) + return NameJointAccountProcessor.CreateLocalAccountResult.AlreadyExists + } + + return trySaveJointAccountLocally( + jointAccountAddress, + participantAddresses, + threshold, + version, + accountName + ) + } + + private suspend fun isAccountAlreadyExists(address: String): Boolean { + return getJointAccount(address) != null + } + + private suspend fun trySaveJointAccountLocally( + jointAccountAddress: String, + participantAddresses: List, + threshold: Int, + version: Int, + accountName: String + ): NameJointAccountProcessor.CreateLocalAccountResult { + return try { + addJointAccount( + address = jointAccountAddress, + participantAddresses = participantAddresses, + threshold = threshold, + version = version, + customName = accountName.takeIf { it.isNotBlank() }, + orderIndex = calculateNextOrderIndex() + ) + tryDeleteInboxNotification(jointAccountAddress) + NameJointAccountProcessor.CreateLocalAccountResult.Success + } catch (e: Exception) { + Log.e(TAG, "Failed to save joint account: ${e.message}", e) + NameJointAccountProcessor.CreateLocalAccountResult.Error(R.string.an_error_occurred) + } + } + + private suspend fun calculateNextOrderIndex(): Int { + val orderIndexes = getAllAccountOrderIndexes() + return if (orderIndexes.isEmpty()) 0 else (orderIndexes.maxOfOrNull { it.index } ?: -1) + 1 + } + + private suspend fun tryDeleteInboxNotification(jointAccountAddress: String) { + val deviceIdLong = getDeviceIdAsLongOrNull() ?: return + deleteInboxJointInvitationNotification(deviceIdLong, jointAccountAddress).use( + onSuccess = { }, + onFailed = { exception, _ -> + Log.w(TAG, "Failed to delete inbox notification: ${exception?.message}", exception) + } + ) + } + + private suspend fun getDeviceIdAsLongOrNull(): Long? { + return try { + deviceIdUseCase.getSelectedNodeDeviceId()?.toLong() + } catch (e: NumberFormatException) { + Log.e(TAG, "Device ID cannot be converted to Long: ${e.message}", e) + null + } + } + + companion object { + private const val TAG = "NameJointAccountProc" + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/namejointaccount/viewmodel/NameJointAccountProcessor.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/namejointaccount/viewmodel/NameJointAccountProcessor.kt new file mode 100644 index 000000000..c71421f9a --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/namejointaccount/viewmodel/NameJointAccountProcessor.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.addaccount.joint.creation.ui.namejointaccount.viewmodel + +interface NameJointAccountProcessor { + + fun mapExceptionToErrorResId(exception: Throwable?): Int + + suspend fun createLocalAccount( + jointAccountAddress: String, + participantAddresses: List, + threshold: Int, + version: Int, + accountName: String + ): CreateLocalAccountResult + + sealed interface CreateLocalAccountResult { + data object Success : CreateLocalAccountResult + data object AlreadyExists : CreateLocalAccountResult + data class Error(val messageResId: Int) : CreateLocalAccountResult + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/namejointaccount/viewmodel/NameJointAccountViewModel.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/namejointaccount/viewmodel/NameJointAccountViewModel.kt new file mode 100644 index 000000000..9fe071919 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/namejointaccount/viewmodel/NameJointAccountViewModel.kt @@ -0,0 +1,124 @@ +/* + * 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.addaccount.joint.creation.ui.namejointaccount.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.algorand.android.R +import com.algorand.android.modules.addaccount.joint.core.JointAccountConstants +import com.algorand.wallet.jointaccount.creation.domain.usecase.CreateJointAccount +import com.algorand.android.modules.addaccount.joint.creation.usecase.GetDefaultJointAccountName +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.launch +import javax.inject.Inject + +@HiltViewModel +class NameJointAccountViewModel @Inject constructor( + private val stateDelegate: StateDelegate, + private val eventDelegate: EventDelegate, + private val createJointAccount: CreateJointAccount, + private val getDefaultJointAccountName: GetDefaultJointAccountName, + private val processor: NameJointAccountProcessor +) : ViewModel(), + StateViewModel by stateDelegate, + EventViewModel by eventDelegate { + + init { + stateDelegate.setDefaultState(ViewState.Idle) + } + + suspend fun getDefaultAccountName(): String = getDefaultJointAccountName() + + fun createJointAccount(accountName: String, threshold: Int, participantAddresses: List) { + val trimmedName = accountName.trim() + if (!isValidAccountName(trimmedName)) { + stateDelegate.updateState { ViewState.Error(R.string.an_error_occurred) } + return + } + + viewModelScope.launch { + stateDelegate.updateState { ViewState.Loading } + + createJointAccount( + participantAddresses = participantAddresses, + threshold = threshold, + version = JointAccountConstants.CURRENT_VERSION + ).use( + onSuccess = { jointAccountDTO -> + handleJointAccountCreationSuccess( + jointAccountAddress = jointAccountDTO.address, + participantAddresses = participantAddresses, + threshold = threshold, + version = jointAccountDTO.version ?: JointAccountConstants.CURRENT_VERSION, + accountName = trimmedName + ) + }, + onFailed = { exception, _ -> + val errorResId = processor.mapExceptionToErrorResId(exception) + stateDelegate.updateState { ViewState.Error(errorResId) } + } + ) + } + } + + private fun isValidAccountName(name: String): Boolean { + return name.isNotBlank() + } + + private suspend fun handleJointAccountCreationSuccess( + jointAccountAddress: String?, + participantAddresses: List, + threshold: Int, + version: Int, + accountName: String + ) { + if (jointAccountAddress == null) { + stateDelegate.updateState { ViewState.Error(R.string.an_error_occurred) } + return + } + + when (val result = processor.createLocalAccount( + jointAccountAddress = jointAccountAddress, + participantAddresses = participantAddresses, + threshold = threshold, + version = version, + accountName = accountName + )) { + is NameJointAccountProcessor.CreateLocalAccountResult.Success -> { + stateDelegate.updateState { ViewState.Success } + eventDelegate.sendEvent(ViewEvent.AccountCreatedSuccessfully) + } + is NameJointAccountProcessor.CreateLocalAccountResult.AlreadyExists -> { + stateDelegate.updateState { ViewState.Error(R.string.this_account_already_exists) } + } + is NameJointAccountProcessor.CreateLocalAccountResult.Error -> { + stateDelegate.updateState { ViewState.Error(result.messageResId) } + } + } + } + + sealed interface ViewState { + data object Idle : ViewState + data object Loading : ViewState + data object Success : ViewState + data class Error(val messageResId: Int) : ViewState + } + + sealed interface ViewEvent { + data object AccountCreatedSuccessfully : ViewEvent + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/setthreshold/SetThresholdFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/setthreshold/SetThresholdFragment.kt new file mode 100644 index 000000000..e7ddf1a65 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/setthreshold/SetThresholdFragment.kt @@ -0,0 +1,62 @@ +/* + * 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.addaccount.joint.creation.ui.setthreshold + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.navigation.fragment.navArgs +import com.algorand.android.core.DaggerBaseFragment +import com.algorand.android.models.FragmentConfiguration +import com.algorand.android.ui.compose.extensions.createComposeView +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SetThresholdFragment : DaggerBaseFragment(0), SetThresholdScreenListener { + + private val args: SetThresholdFragmentArgs by navArgs() + + override val fragmentConfiguration = FragmentConfiguration() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return createComposeView { + SetThresholdScreen( + numberOfAccounts = args.participantAddresses.size, + listener = this + ) + } + } + + override fun onBackClick() { + navBack() + } + + override fun onContinueClick(threshold: Int) { + navToNameJointAccountFragment(threshold) + } + + private fun navToNameJointAccountFragment(threshold: Int) { + nav( + SetThresholdFragmentDirections + .actionSetThresholdFragmentToNameJointAccountFragment( + threshold = threshold, + participantAddresses = args.participantAddresses + ) + ) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/setthreshold/SetThresholdScreen.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/setthreshold/SetThresholdScreen.kt new file mode 100644 index 000000000..8a5396d0c --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/setthreshold/SetThresholdScreen.kt @@ -0,0 +1,304 @@ +/* + * 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 + */ +@file:Suppress("MagicNumber") + +package com.algorand.android.modules.addaccount.joint.creation.ui.setthreshold + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.algorand.android.R +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.modules.addaccount.joint.core.JointAccountConstants +import com.algorand.android.ui.compose.widget.PeraToolbarIcon +import com.algorand.android.ui.compose.widget.button.PeraButtonState +import com.algorand.android.ui.compose.widget.button.PeraPrimaryButton +import com.algorand.android.ui.compose.widget.modifier.clickableNoRipple + +@Composable +fun SetThresholdScreen( + numberOfAccounts: Int, + listener: SetThresholdScreenListener +) { + var threshold by remember { mutableIntStateOf(minOf(JointAccountConstants.MIN_THRESHOLD, numberOfAccounts)) } + + Column( + modifier = Modifier.fillMaxSize() + ) { + ToolbarSection(listener = listener) + + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) { + Spacer(modifier = Modifier.height(12.dp)) + DescriptionSection() + Spacer(modifier = Modifier.height(48.dp)) + NumberOfAccountsSection(numberOfAccounts = numberOfAccounts) + Spacer(modifier = Modifier.height(24.dp)) + ThresholdControlSection( + threshold = threshold, + numberOfAccounts = numberOfAccounts, + onThresholdChange = { threshold = it } + ) + } + + ContinueButtonSection( + numberOfAccounts = numberOfAccounts, + onContinueClick = { listener.onContinueClick(threshold) } + ) + } +} + +@Composable +private fun ToolbarSection(listener: SetThresholdScreenListener) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + ) { + PeraToolbarIcon( + iconResId = R.drawable.ic_left_arrow, + modifier = Modifier.clickableNoRipple(onClick = listener::onBackClick) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + modifier = Modifier.padding(horizontal = 12.dp), + text = stringResource(R.string.set_a_threshold), + style = PeraTheme.typography.title.regular.sansMedium, + color = PeraTheme.colors.text.main + ) + } +} + +@Composable +private fun DescriptionSection() { + Text( + text = stringResource(R.string.set_threshold_description), + style = PeraTheme.typography.body.regular.sans, + color = PeraTheme.colors.text.gray + ) +} + +@Composable +private fun NumberOfAccountsSection(numberOfAccounts: 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, numberOfAccounts), + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.gray + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + modifier = Modifier.size(32.dp), + painter = painterResource(R.drawable.ic_joint), + contentDescription = stringResource(R.string.joint_account), + tint = PeraTheme.colors.text.grayLighter + ) + Text( + text = numberOfAccounts.toString(), + style = PeraTheme.typography.title.small.sansMedium, + color = PeraTheme.colors.text.grayLighter + ) + } + } +} + +@Composable +private fun ThresholdControlSection( + threshold: Int, + numberOfAccounts: Int, + onThresholdChange: (Int) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.threshold), + style = PeraTheme.typography.body.regular.sans, + color = PeraTheme.colors.text.main + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + DecreaseThresholdButton( + threshold = threshold, + onDecrease = { onThresholdChange(threshold - 1) } + ) + ThresholdValueDisplay(threshold = threshold) + IncreaseThresholdButton( + threshold = threshold, + numberOfAccounts = numberOfAccounts, + onIncrease = { onThresholdChange(threshold + 1) } + ) + } + } +} + +@Composable +private fun DecreaseThresholdButton( + threshold: Int, + onDecrease: () -> Unit +) { + val isEnabled = threshold > JointAccountConstants.MIN_THRESHOLD + val decreaseDescription = stringResource(R.string.decrease) + Box( + modifier = Modifier + .size(40.dp) + .semantics { contentDescription = decreaseDescription } + .background( + color = if (isEnabled) { + PeraTheme.colors.helper.positiveLighter + } else { + PeraTheme.colors.layer.grayLighter + }, + shape = RoundedCornerShape(8.dp) + ) + .clickable( + enabled = isEnabled, + onClick = onDecrease, + interactionSource = remember { MutableInteractionSource() }, + indication = null + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "−", + style = PeraTheme.typography.title.regular.sansMedium, + color = if (isEnabled) { + PeraTheme.colors.helper.positive + } else { + PeraTheme.colors.text.gray + } + ) + } +} + +@Composable +private fun ThresholdValueDisplay(threshold: Int) { + Text( + text = threshold.toString(), + style = PeraTheme.typography.title.small.sansMedium, + color = PeraTheme.colors.text.main + ) +} + +@Composable +private fun IncreaseThresholdButton( + threshold: Int, + numberOfAccounts: Int, + onIncrease: () -> Unit +) { + val isEnabled = threshold < numberOfAccounts + Box( + modifier = Modifier + .size(40.dp) + .background( + color = if (isEnabled) { + PeraTheme.colors.helper.positiveLighter + } else { + PeraTheme.colors.layer.grayLighter + }, + shape = RoundedCornerShape(8.dp) + ) + .clickable( + enabled = isEnabled, + onClick = onIncrease, + interactionSource = remember { MutableInteractionSource() }, + indication = null + ), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_plus), + contentDescription = stringResource(R.string.increase), + tint = if (isEnabled) { + PeraTheme.colors.helper.positive + } else { + PeraTheme.colors.text.gray + } + ) + } +} + +@Composable +private fun ContinueButtonSection( + numberOfAccounts: Int, + onContinueClick: () -> Unit +) { + val buttonState = if (numberOfAccounts >= JointAccountConstants.MIN_THRESHOLD) { + PeraButtonState.ENABLED + } else { + PeraButtonState.DISABLED + } + + PeraPrimaryButton( + text = stringResource(R.string.continue_text), + onClick = onContinueClick, + state = buttonState, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 24.dp) + ) +} + +interface SetThresholdScreenListener { + fun onBackClick() + fun onContinueClick(threshold: Int) +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/setthreshold/preview/SetThresholdScreenPreview.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/setthreshold/preview/SetThresholdScreenPreview.kt new file mode 100644 index 000000000..d97d31c05 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/ui/setthreshold/preview/SetThresholdScreenPreview.kt @@ -0,0 +1,65 @@ +@file:Suppress("EmptyFunctionBlock") +/* + * 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.addaccount.joint.creation.ui.setthreshold.preview + +import androidx.compose.runtime.Composable +import com.algorand.android.modules.addaccount.joint.creation.ui.setthreshold.SetThresholdScreen +import com.algorand.android.modules.addaccount.joint.creation.ui.setthreshold.SetThresholdScreenListener +import com.algorand.android.ui.compose.preview.PeraPreviewLightDark +import com.algorand.android.ui.compose.theme.PeraTheme + +@PeraPreviewLightDark +@Composable +fun SetThresholdScreenPreview() { + PeraTheme { + val listener = object : SetThresholdScreenListener { + override fun onBackClick() {} + override fun onContinueClick(threshold: Int) {} + } + SetThresholdScreen( + numberOfAccounts = 3, + listener = listener + ) + } +} + +@PeraPreviewLightDark +@Composable +fun SetThresholdScreenWithTwoAccountsPreview() { + PeraTheme { + val listener = object : SetThresholdScreenListener { + override fun onBackClick() {} + override fun onContinueClick(threshold: Int) {} + } + SetThresholdScreen( + numberOfAccounts = 2, + listener = listener + ) + } +} + +@PeraPreviewLightDark +@Composable +fun SetThresholdScreenWithManyAccountsPreview() { + PeraTheme { + val listener = object : SetThresholdScreenListener { + override fun onBackClick() {} + override fun onContinueClick(threshold: Int) {} + } + SetThresholdScreen( + numberOfAccounts = 5, + listener = listener + ) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/usecase/AddJointAccountSelectionUseCase.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/usecase/AddJointAccountSelectionUseCase.kt new file mode 100644 index 000000000..bfdfc2f0b --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/usecase/AddJointAccountSelectionUseCase.kt @@ -0,0 +1,126 @@ +/* + * 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.addaccount.joint.creation.usecase + +import com.algorand.android.models.BaseAccountSelectionListItem.BaseAccountItem.AccountItem +import com.algorand.android.models.BaseAccountSelectionListItem.BaseAccountItem.ContactItem +import com.algorand.android.modules.accountcore.ui.accountselection.usecase.GetAccountSelectionAccountItems +import com.algorand.android.modules.accountcore.ui.accountselection.usecase.GetAccountSelectionContactItems +import com.algorand.android.modules.accountcore.ui.accountselection.usecase.GetAccountSelectionNameServiceItems +import com.algorand.android.modules.addaccount.joint.creation.mapper.JointAccountSelectionListItemMapper +import com.algorand.android.modules.addaccount.joint.creation.model.JointAccountSelectionListItem +import com.algorand.android.utils.isValidAddress +import com.algorand.android.utils.isValidNFTDomain +import com.algorand.android.utils.toShortenedAddress +import com.algorand.wallet.account.detail.domain.model.AccountType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class AddJointAccountSelectionUseCase @Inject constructor( + private val getAccountSelectionAccountItems: GetAccountSelectionAccountItems, + private val getAccountSelectionContactItems: GetAccountSelectionContactItems, + private val getAccountSelectionNameServiceItems: GetAccountSelectionNameServiceItems, + private val jointAccountSelectionListItemMapper: JointAccountSelectionListItemMapper +) { + + fun getAccountSelectionList( + query: String, + ): Flow> { + val accountList = fetchAccountList(query) + val contactList = fetchContactList(query) + val trimmedQuery = query.trim() + val nfdList = if (trimmedQuery.lowercase().isValidNFTDomain()) { + fetchNfdList(trimmedQuery) + } else { + flow { emit(emptyList()) } + } + val externalAddressList = fetchExternalAddressIfValid(query) + return combine(accountList, contactList, nfdList, externalAddressList) { accounts, contacts, nfds, external -> + val allAddresses = ( + accounts.map { it.address } + + contacts.map { it.address } + + nfds.map { it.address } + ).toSet() + val filteredExternal = external.filter { it.address !in allAddresses } + + buildList { + addAll(filteredExternal) + addAll(nfds) + addAll(accounts) + addAll(contacts) + } + } + } + + private fun fetchAccountList(query: String) = flow { + val accounts = getAccountSelectionAccountItems( + showHoldings = true, + showFailedAccounts = false + ) + val filteredAccounts = accounts + .filterIsInstance() + .filter { accountItem -> + val accountType = accountItem.accountListItem.itemConfiguration.accountType + accountType !is AccountType.Joint + } + .filter { accountItem -> + val displayName = accountItem.displayName + val address = accountItem.address + (displayName.contains(query, ignoreCase = true) || + address.contains(query, ignoreCase = true)) + } + .map { accountItem -> + jointAccountSelectionListItemMapper.mapToAccountItem(accountItem) + } + emit(filteredAccounts) + } + + private fun fetchContactList(query: String) = flow { + val contacts = getAccountSelectionContactItems() + val filteredContacts = contacts + .filterIsInstance() + .filter { contactItem -> + val displayName = contactItem.displayName + val address = contactItem.address + (displayName.contains(query, ignoreCase = true) || + address.contains(query, ignoreCase = true)) + } + .map { contactItem -> + jointAccountSelectionListItemMapper.mapToContactItem(contactItem) + } + emit(filteredContacts) + } + + private fun fetchNfdList(query: String) = flow { + val nfdAccounts = getAccountSelectionNameServiceItems(query) + val filteredNfds = nfdAccounts.map { nfdItem -> + jointAccountSelectionListItemMapper.mapToNfdItem(nfdItem) + } + emit(filteredNfds) + } + + private fun fetchExternalAddressIfValid(query: String) = flow { + val trimmedQuery = query.trim() + if (trimmedQuery.isValidAddress()) { + val externalAddressItem = jointAccountSelectionListItemMapper.mapToExternalAddressItem( + address = trimmedQuery, + shortenedAddress = trimmedQuery.toShortenedAddress() + ) + emit(listOf(externalAddressItem)) + } else { + emit(emptyList()) + } + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/usecase/CreateExternalAddressAsContact.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/usecase/CreateExternalAddressAsContact.kt new file mode 100644 index 000000000..86b24f393 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/usecase/CreateExternalAddressAsContact.kt @@ -0,0 +1,19 @@ +/* + * 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.addaccount.joint.creation.usecase + +import com.algorand.android.modules.addaccount.joint.creation.model.SelectedJointAccountItem + +interface CreateExternalAddressAsContact { + suspend operator fun invoke(address: String, shortenedAddress: String? = null): SelectedJointAccountItem? +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/usecase/CreateExternalAddressAsContactUseCase.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/usecase/CreateExternalAddressAsContactUseCase.kt new file mode 100644 index 000000000..9918a3cf8 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/usecase/CreateExternalAddressAsContactUseCase.kt @@ -0,0 +1,51 @@ +/* + * 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.addaccount.joint.creation.usecase + +import com.algorand.android.models.User +import com.algorand.android.modules.accountcore.ui.model.AccountDisplayName +import com.algorand.android.modules.addaccount.joint.creation.model.SelectedJointAccountItem +import com.algorand.android.repository.ContactRepository +import com.algorand.android.utils.toShortenedAddress +import javax.inject.Inject + +internal class CreateExternalAddressAsContactUseCase @Inject constructor( + private val contactRepository: ContactRepository +) : CreateExternalAddressAsContact { + + override suspend operator fun invoke( + address: String, + shortenedAddress: String? + ): SelectedJointAccountItem? { + val displayAddress = shortenedAddress ?: address.toShortenedAddress() + + val contact = User( + name = displayAddress, + publicKey = address, + imageUriAsString = null + ) + + return runCatching { + contactRepository.addContact(contact) + SelectedJointAccountItem( + accountDisplayName = AccountDisplayName( + accountAddress = address, + primaryDisplayName = displayAddress, + secondaryDisplayName = null + ), + iconDrawablePreview = null, + isContact = true + ) + }.getOrNull() + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/usecase/GetDefaultJointAccountName.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/usecase/GetDefaultJointAccountName.kt new file mode 100644 index 000000000..50ec79911 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/usecase/GetDefaultJointAccountName.kt @@ -0,0 +1,17 @@ +/* + * 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.addaccount.joint.creation.usecase + +fun interface GetDefaultJointAccountName { + suspend operator fun invoke(): String +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/usecase/GetDefaultJointAccountNameUseCase.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/usecase/GetDefaultJointAccountNameUseCase.kt new file mode 100644 index 000000000..c81a5adcc --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/creation/usecase/GetDefaultJointAccountNameUseCase.kt @@ -0,0 +1,28 @@ +/* + * 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.addaccount.joint.creation.usecase + +import com.algorand.wallet.account.local.domain.model.LocalAccount +import com.algorand.wallet.account.local.domain.usecase.GetLocalAccounts +import javax.inject.Inject + +internal class GetDefaultJointAccountNameUseCase @Inject constructor( + private val getLocalAccounts: GetLocalAccounts +) : GetDefaultJointAccountName { + + override suspend operator fun invoke(): String { + val localAccounts = getLocalAccounts() + val jointAccountCount = localAccounts.count { it is LocalAccount.Joint } + return "Joint Account #${jointAccountCount + 1}" + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/di/JointAccountUseCaseModule.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/di/JointAccountUseCaseModule.kt new file mode 100644 index 000000000..6d804982d --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/di/JointAccountUseCaseModule.kt @@ -0,0 +1,72 @@ +/* + * 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.addaccount.joint.di + +import com.algorand.android.modules.addaccount.joint.creation.usecase.CreateExternalAddressAsContact +import com.algorand.android.modules.addaccount.joint.creation.usecase.CreateExternalAddressAsContactUseCase +import com.algorand.android.modules.addaccount.joint.creation.usecase.GetDefaultJointAccountName +import com.algorand.android.modules.addaccount.joint.creation.usecase.GetDefaultJointAccountNameUseCase +import com.algorand.android.modules.addaccount.joint.transaction.domain.usecase.CalculateConvertedAlgoAmount +import com.algorand.android.modules.addaccount.joint.transaction.domain.usecase.CalculateConvertedAlgoAmountUseCase +import com.algorand.android.modules.addaccount.joint.transaction.domain.usecase.CreateSignerAccounts +import com.algorand.android.modules.addaccount.joint.transaction.domain.usecase.CreateSignerAccountsUseCase +import com.algorand.android.modules.addaccount.joint.transaction.domain.usecase.DeclineJointAccountSignRequest +import com.algorand.android.modules.addaccount.joint.transaction.domain.usecase.DeclineJointAccountSignRequestUseCase +import com.algorand.android.modules.addaccount.joint.transaction.domain.usecase.GetJointAccountTransactionPreview +import com.algorand.android.modules.addaccount.joint.transaction.domain.usecase.GetJointAccountTransactionPreviewUseCase +import com.algorand.android.modules.addaccount.joint.transaction.domain.usecase.SignAndSubmitJointAccountSignature +import com.algorand.android.modules.addaccount.joint.transaction.domain.usecase.SignAndSubmitJointAccountSignatureUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal object JointAccountUseCaseModule { + + @Provides + fun provideSignAndSubmitJointAccountSignature( + useCase: SignAndSubmitJointAccountSignatureUseCase + ): SignAndSubmitJointAccountSignature = useCase + + @Provides + fun provideGetDefaultJointAccountName( + useCase: GetDefaultJointAccountNameUseCase + ): GetDefaultJointAccountName = useCase + + @Provides + fun provideCreateExternalAddressAsContact( + useCase: CreateExternalAddressAsContactUseCase + ): CreateExternalAddressAsContact = useCase + + @Provides + fun provideCalculateConvertedAlgoAmount( + useCase: CalculateConvertedAlgoAmountUseCase + ): CalculateConvertedAlgoAmount = useCase + + @Provides + fun provideCreateSignerAccounts( + useCase: CreateSignerAccountsUseCase + ): CreateSignerAccounts = useCase + + @Provides + fun provideDeclineJointAccountSignRequest( + useCase: DeclineJointAccountSignRequestUseCase + ): DeclineJointAccountSignRequest = useCase + + @Provides + fun provideGetJointAccountTransactionPreview( + useCase: GetJointAccountTransactionPreviewUseCase + ): GetJointAccountTransactionPreview = useCase +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/di/JointAccountViewModelModule.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/di/JointAccountViewModelModule.kt new file mode 100644 index 000000000..84b2021a7 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/di/JointAccountViewModelModule.kt @@ -0,0 +1,37 @@ +/* + * 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.addaccount.joint.di + +import com.algorand.android.modules.addaccount.joint.creation.ui.namejointaccount.viewmodel.DefaultNameJointAccountProcessor +import com.algorand.android.modules.addaccount.joint.creation.ui.namejointaccount.viewmodel.NameJointAccountProcessor +import com.algorand.android.modules.addaccount.joint.transaction.viewmodel.DefaultJointAccountTransactionProcessor +import com.algorand.android.modules.addaccount.joint.transaction.viewmodel.JointAccountTransactionProcessor +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +internal object JointAccountViewModelModule { + + @Provides + fun provideJointAccountTransactionProcessor( + processor: DefaultJointAccountTransactionProcessor + ): JointAccountTransactionProcessor = processor + + @Provides + fun provideNameJointAccountProcessor( + processor: DefaultNameJointAccountProcessor + ): NameJointAccountProcessor = processor +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/info/ui/JointAccountInfoDialog.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/info/ui/JointAccountInfoDialog.kt new file mode 100644 index 000000000..70d1379c2 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/info/ui/JointAccountInfoDialog.kt @@ -0,0 +1,218 @@ +/* + * 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 + */ + +@file:Suppress("MagicNumber") + +package com.algorand.android.modules.addaccount.joint.info.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.ContentScale +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 androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.algorand.android.R +import com.algorand.android.ui.compose.theme.ColorPalette +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.ui.compose.widget.button.PeraPrimaryButton + +@Composable +fun JointAccountInfoDialog( + onContinueClick: () -> Unit, + onDismiss: () -> Unit +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false + ) + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = PeraTheme.colors.background.primary + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 8.dp + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + VisualHeader() + + TitleWithNewBadge() + + Spacer(modifier = Modifier.height(12.dp)) + + BulletPointsSection() + + Spacer(modifier = Modifier.height(32.dp)) + + PeraPrimaryButton( + text = stringResource(R.string.continue_text), + onClick = onContinueClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } + } +} + +@Composable +internal fun VisualHeader() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(222.dp), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.ic_joint_account_info_header), + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Fit + ) + } +} + +@Composable +internal fun TitleWithNewBadge() { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .background( + color = PeraTheme.colors.helper.positiveLighter, + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = stringResource(R.string.new_text).uppercase(), + style = PeraTheme.typography.caption.sansBold, + color = PeraTheme.colors.helper.positive + ) + } + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.joint_account), + style = PeraTheme.typography.body.large.sansMedium, + color = PeraTheme.colors.text.main, + textAlign = TextAlign.Center + ) + } +} + +@Composable +internal fun BulletPointsSection() { + BulletPoint( + number = stringResource(R.string.dialpad_1), + text = stringResource(R.string.joint_account_info_1) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + BulletPoint( + number = stringResource(R.string.dialpad_2), + text = stringResource(R.string.joint_account_info_2) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + BulletPoint( + number = stringResource(R.string.dialpad_3), + text = stringResource(R.string.joint_account_info_3) + ) +} + +@Composable +internal fun BulletPoint(number: String, text: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + Box( + modifier = Modifier + .size(32.dp) + .shadow( + elevation = 2.dp, + shape = CircleShape, + spotColor = ColorPalette.Gray.V900Alpha12, + ambientColor = ColorPalette.Gray.V900Alpha12 + ) + .background( + color = PeraTheme.colors.background.primary, + shape = CircleShape + ) + .border( + width = 1.dp, + color = PeraTheme.colors.layer.grayLighter, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = number, + style = PeraTheme.typography.body.regular.sans, + color = PeraTheme.colors.text.main, + textAlign = TextAlign.Center + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = text, + style = PeraTheme.typography.body.regular.sans, + color = PeraTheme.colors.text.gray, + modifier = Modifier + .weight(1f) + .padding(top = 4.dp) + ) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/info/ui/JointAccountInfoDialogDelegate.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/info/ui/JointAccountInfoDialogDelegate.kt new file mode 100644 index 000000000..d21d1e949 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/info/ui/JointAccountInfoDialogDelegate.kt @@ -0,0 +1,59 @@ +/* + * 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.addaccount.joint.info.ui + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.algorand.android.R +import com.algorand.android.ui.compose.theme.PeraTheme + +class JointAccountInfoDialogDelegate(private val onContinueClick: () -> Unit) { + + private var jointAccountInfoDialog: AlertDialog? = null + + fun show(context: Context) { + val dialogView = createJointAccountInfoView(context) + jointAccountInfoDialog = AlertDialog.Builder(context, R.style.FullScreenDialogStyle) + .setView(dialogView) + .setCancelable(true) + .create() + .apply { + window?.setBackgroundDrawableResource(android.R.color.transparent) + show() + } + } + + fun dismiss() { + jointAccountInfoDialog?.dismiss() + jointAccountInfoDialog = null + } + + private fun createJointAccountInfoView(context: Context): ComposeView { + return ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + PeraTheme { + JointAccountInfoDialog( + onContinueClick = { + dismiss() + onContinueClick() + }, + onDismiss = ::dismiss + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/info/ui/preview/JointAccountInfoDialogPreview.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/info/ui/preview/JointAccountInfoDialogPreview.kt new file mode 100644 index 000000000..e6a31fdd6 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/info/ui/preview/JointAccountInfoDialogPreview.kt @@ -0,0 +1,91 @@ +@file:Suppress("EmptyFunctionBlock") +/* + * 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.addaccount.joint.info.ui.preview + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.algorand.android.R +import com.algorand.android.modules.addaccount.joint.info.ui.BulletPointsSection +import com.algorand.android.modules.addaccount.joint.info.ui.TitleWithNewBadge +import com.algorand.android.modules.addaccount.joint.info.ui.VisualHeader +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 com.algorand.android.ui.compose.widget.button.PeraPrimaryButton + +@PeraPreviewLightDark +@Composable +fun JointAccountInfoDialogPreview() { + PeraTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(ColorPalette.Black.Alpha64) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = PeraTheme.colors.background.primary + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 8.dp + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + VisualHeader() + + TitleWithNewBadge() + + Spacer(modifier = Modifier.height(12.dp)) + + BulletPointsSection() + + Spacer(modifier = Modifier.height(32.dp)) + + PeraPrimaryButton( + text = stringResource(R.string.continue_text), + onClick = {}, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/preview/JointAccountInfoDialogPreview.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/preview/JointAccountInfoDialogPreview.kt new file mode 100644 index 000000000..d4dc1b6fe --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/preview/JointAccountInfoDialogPreview.kt @@ -0,0 +1,91 @@ +@file:Suppress("EmptyFunctionBlock") +/* + * 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.addaccount.joint.preview + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.algorand.android.R +import com.algorand.android.modules.addaccount.joint.info.ui.BulletPointsSection +import com.algorand.android.modules.addaccount.joint.info.ui.TitleWithNewBadge +import com.algorand.android.modules.addaccount.joint.info.ui.VisualHeader +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 com.algorand.android.ui.compose.widget.button.PeraPrimaryButton + +@PeraPreviewLightDark +@Composable +fun JointAccountInfoDialogPreview() { + PeraTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(ColorPalette.Black.Alpha64) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = PeraTheme.colors.background.primary + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 8.dp + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + VisualHeader() + + TitleWithNewBadge() + + Spacer(modifier = Modifier.height(12.dp)) + + BulletPointsSection() + + Spacer(modifier = Modifier.height(32.dp)) + + PeraPrimaryButton( + text = stringResource(R.string.continue_text), + onClick = {}, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/exception/JointAccountSigningException.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/exception/JointAccountSigningException.kt new file mode 100644 index 000000000..dad307e74 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/exception/JointAccountSigningException.kt @@ -0,0 +1,21 @@ +/* + * 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.addaccount.joint.transaction.domain.exception + +sealed class JointAccountSigningException(message: String) : Exception(message) { + data object TransactionDecodeFailed : JointAccountSigningException("Failed to decode transaction") + data object SigningFailed : JointAccountSigningException("Failed to sign transaction") + data object AccountNotFound : JointAccountSigningException("Local account not found") + data object SecretKeyNotFound : JointAccountSigningException("Secret key not found for account") + data object UnsupportedAccountType : JointAccountSigningException("Account type does not support signing") +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/CalculateConvertedAlgoAmount.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/CalculateConvertedAlgoAmount.kt new file mode 100644 index 000000000..a07023a81 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/CalculateConvertedAlgoAmount.kt @@ -0,0 +1,19 @@ +/* + * 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.addaccount.joint.transaction.domain.usecase + +import java.math.BigDecimal + +fun interface CalculateConvertedAlgoAmount { + operator fun invoke(algoAmount: BigDecimal): String +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/CalculateConvertedAlgoAmountUseCase.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/CalculateConvertedAlgoAmountUseCase.kt new file mode 100644 index 000000000..943e1059a --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/CalculateConvertedAlgoAmountUseCase.kt @@ -0,0 +1,35 @@ +/* + * 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.addaccount.joint.transaction.domain.usecase + +import com.algorand.android.modules.currency.domain.usecase.CurrencyUseCase +import com.algorand.android.modules.parity.domain.usecase.ParityUseCase +import com.algorand.android.utils.formatAsCurrency +import java.math.BigDecimal +import javax.inject.Inject + +internal class CalculateConvertedAlgoAmountUseCase @Inject constructor( + private val parityUseCase: ParityUseCase, + private val currencyUseCase: CurrencyUseCase +) : CalculateConvertedAlgoAmount { + + override operator fun invoke(algoAmount: BigDecimal): String { + val rate = if (currencyUseCase.isPrimaryCurrencyAlgo()) { + parityUseCase.getAlgoToUsdConversionRate() + } else { + parityUseCase.getAlgoToPrimaryCurrencyConversionRate() + } + val symbol = parityUseCase.getDisplayedCurrencySymbol() + return algoAmount.multiply(rate).formatAsCurrency(symbol, isFiat = true) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/CreateSignerAccounts.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/CreateSignerAccounts.kt new file mode 100644 index 000000000..1ce28b425 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/CreateSignerAccounts.kt @@ -0,0 +1,25 @@ +/* + * 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.addaccount.joint.transaction.domain.usecase + +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountSignerItem +import com.algorand.wallet.jointaccount.transaction.domain.model.ParticipantSignature + +interface CreateSignerAccounts { + suspend operator fun invoke( + participantAddresses: List, + responses: List + ): List + + suspend fun hasSigningCapableLocalAccount(address: String): Boolean +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/CreateSignerAccountsUseCase.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/CreateSignerAccountsUseCase.kt new file mode 100644 index 000000000..bcd511ca8 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/CreateSignerAccountsUseCase.kt @@ -0,0 +1,73 @@ +/* + * 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.addaccount.joint.transaction.domain.usecase + +import android.net.Uri +import com.algorand.android.modules.accountcore.ui.usecase.GetAccountDisplayName +import com.algorand.android.modules.accountcore.ui.usecase.GetAccountIconDrawablePreview +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountSignatureStatus +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountSignerItem +import com.algorand.android.repository.ContactRepository +import com.algorand.wallet.account.local.domain.model.LocalAccount +import com.algorand.wallet.account.local.domain.usecase.GetLocalAccount +import com.algorand.wallet.jointaccount.transaction.domain.model.ParticipantSignature +import com.algorand.wallet.jointaccount.transaction.domain.model.SignRequestResponseType +import javax.inject.Inject + +internal class CreateSignerAccountsUseCase @Inject constructor( + private val getAccountDisplayName: GetAccountDisplayName, + private val getAccountIconDrawablePreview: GetAccountIconDrawablePreview, + private val getLocalAccount: GetLocalAccount, + private val contactRepository: ContactRepository +) : CreateSignerAccounts { + + override suspend operator fun invoke( + participantAddresses: List, + responses: List + ): List { + val responseMap = responses.associateBy { it.address } + return participantAddresses.map { address -> + createSignerItem(address, responseMap[address]) + } + } + + override suspend fun hasSigningCapableLocalAccount(address: String): Boolean { + val account = getLocalAccount(address) + return account != null && account !is LocalAccount.NoAuth + } + + private suspend fun createSignerItem( + address: String, + response: ParticipantSignature? + ): JointAccountSignerItem { + val status = when (response?.type) { + SignRequestResponseType.SIGNED -> JointAccountSignatureStatus.Signed + SignRequestResponseType.REJECTED -> JointAccountSignatureStatus.Rejected + else -> JointAccountSignatureStatus.Pending + } + val contact = contactRepository.getContactByAddress(address) + val localAccount = getLocalAccount(address) + val isLedger = localAccount is LocalAccount.LedgerBle + + return JointAccountSignerItem( + accountAddress = address, + accountDisplayName = getAccountDisplayName(address), + accountIconDrawablePreview = getAccountIconDrawablePreview(address), + imageUri = contact?.imageUriAsString?.let { Uri.parse(it) }, + signatureStatus = status, + isLedgerAccount = isLedger, + ledgerBluetoothAddress = (localAccount as? LocalAccount.LedgerBle)?.deviceMacAddress, + ledgerAccountIndex = (localAccount as? LocalAccount.LedgerBle)?.indexInLedger + ) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/DeclineJointAccountSignRequest.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/DeclineJointAccountSignRequest.kt new file mode 100644 index 000000000..98c129e86 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/DeclineJointAccountSignRequest.kt @@ -0,0 +1,20 @@ +/* + * 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.addaccount.joint.transaction.domain.usecase + +import com.algorand.wallet.foundation.PeraResult +import com.algorand.wallet.jointaccount.transaction.domain.model.JointSignRequest + +fun interface DeclineJointAccountSignRequest { + suspend operator fun invoke(signRequestId: String, participantAddress: String): PeraResult +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/DeclineJointAccountSignRequestUseCase.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/DeclineJointAccountSignRequestUseCase.kt new file mode 100644 index 000000000..594aec1a7 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/DeclineJointAccountSignRequestUseCase.kt @@ -0,0 +1,40 @@ +/* + * 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.addaccount.joint.transaction.domain.usecase + +import com.algorand.android.deviceregistration.domain.usecase.DeviceIdUseCase +import com.algorand.wallet.foundation.PeraResult +import com.algorand.wallet.jointaccount.transaction.domain.model.AddSignatureInput +import com.algorand.wallet.jointaccount.transaction.domain.model.JointSignRequest +import com.algorand.wallet.jointaccount.transaction.domain.model.SignRequestResponseType +import com.algorand.wallet.jointaccount.transaction.domain.usecase.AddJointAccountSignature +import javax.inject.Inject + +internal class DeclineJointAccountSignRequestUseCase @Inject constructor( + private val addJointAccountSignature: AddJointAccountSignature, + private val deviceIdUseCase: DeviceIdUseCase +) : DeclineJointAccountSignRequest { + + override suspend operator fun invoke( + signRequestId: String, + participantAddress: String + ): PeraResult { + val declineRequest = AddSignatureInput( + address = participantAddress, + response = SignRequestResponseType.DECLINED, + signatures = null, + deviceId = deviceIdUseCase.getSelectedNodeDeviceId() + ) + return addJointAccountSignature(signRequestId, declineRequest) + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/GetJointAccountTransactionPreview.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/GetJointAccountTransactionPreview.kt new file mode 100644 index 000000000..2d301d440 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/GetJointAccountTransactionPreview.kt @@ -0,0 +1,20 @@ +/* + * 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.addaccount.joint.transaction.domain.usecase + +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountTransactionPreview +import com.algorand.wallet.foundation.PeraResult + +fun interface GetJointAccountTransactionPreview { + suspend operator fun invoke(signRequestId: String): PeraResult +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/GetJointAccountTransactionPreviewUseCase.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/GetJointAccountTransactionPreviewUseCase.kt new file mode 100644 index 000000000..dfc852896 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/GetJointAccountTransactionPreviewUseCase.kt @@ -0,0 +1,288 @@ +/* + * 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.addaccount.joint.transaction.domain.usecase + +import android.text.format.DateUtils +import com.algorand.android.deviceregistration.domain.usecase.DeviceIdUseCase +import com.algorand.android.modules.accountcore.ui.usecase.GetAccountDisplayName +import com.algorand.android.modules.accountcore.ui.usecase.GetAccountIconDrawablePreview +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountSignerItem +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountTransactionPreview +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountTransactionState +import com.algorand.android.utils.ALGO_DECIMALS +import com.algorand.android.utils.decodeBase64 +import com.algorand.android.utils.formatAsAlgoAmount +import com.algorand.android.utils.formatAsAlgoString +import com.algorand.android.utils.getAlgorandMobileDateFormatter +import com.algorand.android.utils.parseFormattedDate +import com.algorand.android.utils.toShortenedAddress +import com.algorand.wallet.account.local.domain.model.LocalAccount +import com.algorand.wallet.account.local.domain.usecase.GetLocalAccounts +import com.algorand.wallet.account.local.domain.usecase.GetLocalAccountsAddresses +import com.algorand.wallet.algosdk.transaction.model.RawTransactionType +import com.algorand.wallet.algosdk.transaction.usecase.ParseTransactionMessagePack +import com.algorand.wallet.foundation.PeraResult +import com.algorand.wallet.jointaccount.transaction.domain.model.SignRequestResponseType +import com.algorand.wallet.jointaccount.transaction.domain.model.SignRequestStatus +import com.algorand.wallet.jointaccount.transaction.domain.model.SignRequestWithFullSignature +import com.algorand.wallet.jointaccount.transaction.domain.model.TransactionListWithFullSignature +import com.algorand.wallet.jointaccount.transaction.domain.usecase.GetSignRequestWithSignatures +import java.math.BigDecimal +import java.math.BigInteger +import java.time.ZonedDateTime +import javax.inject.Inject + +internal class GetJointAccountTransactionPreviewUseCase @Inject constructor( + private val getSignRequestWithSignatures: GetSignRequestWithSignatures, + private val parseTransactionMessagePack: ParseTransactionMessagePack, + private val getAccountDisplayName: GetAccountDisplayName, + private val getAccountIconDrawablePreview: GetAccountIconDrawablePreview, + private val deviceIdUseCase: DeviceIdUseCase, + private val getLocalAccountsAddresses: GetLocalAccountsAddresses, + private val getLocalAccounts: GetLocalAccounts, + private val createSignerAccounts: CreateSignerAccounts, + private val calculateConvertedAlgoAmount: CalculateConvertedAlgoAmount +) : GetJointAccountTransactionPreview { + + override suspend operator fun invoke(signRequestId: String): PeraResult { + val deviceId = deviceIdUseCase.getSelectedNodeDeviceId()?.toLongOrNull() + ?: return PeraResult.Error(Exception("Device ID not available")) + + return when (val result = getSignRequestWithSignatures(deviceId, signRequestId)) { + is PeraResult.Success -> createPreview(result.data) + is PeraResult.Error -> result + } + } + + private suspend fun createPreview( + signRequest: SignRequestWithFullSignature + ): PeraResult { + val jointAccount = signRequest.jointAccount + ?: return PeraResult.Error(Exception("Joint account is null")) + val jointAccountAddress = jointAccount.address + ?: return PeraResult.Error(Exception("Joint account address is null")) + val transactionLists = signRequest.transactionLists + ?: return PeraResult.Error(Exception("Transaction lists is null")) + val participantAddresses = jointAccount.participantAddresses.orEmpty() + + val transactionData = extractTransactionData(transactionLists) + val participantData = buildParticipantData(participantAddresses, transactionLists, signRequest) + val expirationData = buildExpirationData(signRequest) + + return PeraResult.Success( + buildPreview( + signRequest = signRequest, + jointAccountAddress = jointAccountAddress, + threshold = jointAccount.threshold ?: 0, + transactionData = transactionData, + participantData = participantData, + expirationData = expirationData, + rawTransactions = transactionLists.firstOrNull()?.rawTransactions.orEmpty() + ) + ) + } + + private suspend fun buildParticipantData( + participantAddresses: List, + transactionLists: List, + signRequest: SignRequestWithFullSignature + ): ParticipantData { + val responses = transactionLists.firstOrNull()?.responses.orEmpty() + val responseMap = responses.associateBy { it.address } + val localAccountAddresses = getLocalAccountsAddresses() + val allLocalAccounts = getLocalAccounts() + + val localParticipants = participantAddresses.filter { it in localAccountAddresses } + val respondedAddresses = responses + .filter { it.type == SignRequestResponseType.SIGNED || it.type == SignRequestResponseType.REJECTED } + .mapNotNull { it.address } + .toSet() + + val unsignedLocal = localParticipants + .filterNot { it in respondedAddresses } + .filter { address -> + val account = allLocalAccounts.find { it.algoAddress == address } + account is LocalAccount.Algo25 || account is LocalAccount.HdKey + } + + val unsignedLedger = localParticipants + .filterNot { it in respondedAddresses } + .filter { address -> + allLocalAccounts.find { it.algoAddress == address } is LocalAccount.LedgerBle + } + + val signedCount = participantAddresses.count { responseMap[it]?.type == SignRequestResponseType.SIGNED } + val hasProposer = signRequest.proposerAddress?.let { + createSignerAccounts.hasSigningCapableLocalAccount(it) + } ?: false + + return ParticipantData( + signerAccounts = createSignerAccounts(participantAddresses, responses), + signedCount = signedCount, + localParticipants = localParticipants, + unsignedLocal = unsignedLocal, + unsignedLedger = unsignedLedger, + hasProposer = hasProposer, + currentUserAddress = localParticipants.firstOrNull() + ) + } + + private fun buildExpirationData(signRequest: SignRequestWithFullSignature): ExpirationData { + val status = signRequest.status + val isExpiredByStatus = status == SignRequestStatus.EXPIRED || status?.isFinalized() == true + val isExpiredByTime = isSignRequestExpiredByTime(signRequest) + val isExpired = isExpiredByStatus || isExpiredByTime + val canBeSigned = status?.isWaiting() == true && !isExpiredByTime + val timeRemaining = calculateTimeRemaining(signRequest, isExpired) + + return ExpirationData(isExpired, canBeSigned, timeRemaining) + } + + private suspend fun buildPreview( + signRequest: SignRequestWithFullSignature, + jointAccountAddress: String, + threshold: Int, + transactionData: TransactionData, + participantData: ParticipantData, + expirationData: ExpirationData, + rawTransactions: List + ): JointAccountTransactionPreview { + val hasAlreadySigned = participantData.unsignedLocal.isEmpty() && + participantData.localParticipants.isNotEmpty() + val hasUnsigned = participantData.unsignedLocal.isNotEmpty() || + participantData.unsignedLedger.isNotEmpty() + val showPendingDirectly = !expirationData.canBeSigned || + participantData.localParticipants.isEmpty() || + !hasUnsigned + + return JointAccountTransactionPreview( + jointAccountDisplayName = getAccountDisplayName(jointAccountAddress), + jointAccountIconPreview = getAccountIconDrawablePreview(jointAccountAddress), + recipientAddress = transactionData.recipientAddress, + recipientShortAddress = transactionData.recipientAddress.toShortenedAddress(), + amount = transactionData.amountFormatted.formatAsAlgoAmount(), + convertedAmount = transactionData.convertedAmount, + transactionFee = transactionData.feeFormatted.formatAsAlgoAmount(transactionSign = "-"), + transactionState = JointAccountTransactionState.AwaitingConfirmation, + signerAccounts = participantData.signerAccounts, + signedCount = participantData.signedCount, + requiredSignatureCount = threshold, + timeRemaining = expirationData.timeRemaining, + transactionId = signRequest.id?.toString(), + jointAccountAddress = jointAccountAddress, + currentUserParticipantAddress = participantData.currentUserAddress, + isExpired = expirationData.isExpired, + hasCurrentUserAlreadySigned = hasAlreadySigned, + shouldShowPendingSignaturesDirectly = showPendingDirectly, + rawTransactions = rawTransactions, + allLocalParticipantAddresses = participantData.localParticipants, + unsignedLocalParticipantAddresses = participantData.unsignedLocal, + unsignedLedgerParticipantAddresses = participantData.unsignedLedger, + hasProposerAddress = participantData.hasProposer + ) + } + + private fun isSignRequestExpiredByTime(signRequest: SignRequestWithFullSignature): Boolean { + val expireDatetime = signRequest.lastValidExpectedDatetime + ?: signRequest.transactionLists?.firstOrNull()?.lastValidExpectedDatetime + ?: return false + + val expireDateTime = expireDatetime.parseFormattedDate(getAlgorandMobileDateFormatter()) + return expireDateTime != null && ZonedDateTime.now().isAfter(expireDateTime) + } + + private fun calculateTimeRemaining( + signRequest: SignRequestWithFullSignature, + isExpired: Boolean + ): String? { + if (isExpired) return "0m" + + val expireDatetime = signRequest.lastValidExpectedDatetime + ?: signRequest.transactionLists?.firstOrNull()?.lastValidExpectedDatetime + ?: return null + + val expireDateTime = expireDatetime.parseFormattedDate(getAlgorandMobileDateFormatter()) + ?: return null + + val millis = expireDateTime.toInstant().toEpochMilli() - ZonedDateTime.now().toInstant().toEpochMilli() + return formatTimeLeft(millis) + } + + private fun formatTimeLeft(millis: Long): String = when { + millis <= 0 -> "0m" + millis < DateUtils.MINUTE_IN_MILLIS -> "1m" + millis < DateUtils.HOUR_IN_MILLIS -> "${millis / DateUtils.MINUTE_IN_MILLIS}m" + millis < DateUtils.DAY_IN_MILLIS -> "${millis / DateUtils.HOUR_IN_MILLIS}h" + else -> "${millis / DateUtils.DAY_IN_MILLIS}d" + } + + private fun extractTransactionData( + transactionLists: List + ): TransactionData { + var totalAmount = BigInteger.ZERO + var recipientAddress = "" + var totalFee = 0L + + transactionLists.forEach { list -> + list.rawTransactions?.forEach { raw -> + val bytes = raw.decodeBase64() ?: return@forEach + val transaction = parseTransactionMessagePack(bytes) ?: return@forEach + + if (transaction.transactionType == RawTransactionType.PAY_TRANSACTION) { + transaction.amount?.toBigIntegerOrNull()?.let { totalAmount = totalAmount.add(it) } + if (recipientAddress.isEmpty()) { + recipientAddress = transaction.receiverAddress?.decodedAddress.orEmpty() + } + } + transaction.fee?.let { totalFee += it } + } + } + + val amountDecimal = totalAmount.toBigDecimal().movePointLeft(ALGO_DECIMALS) + val feeDecimal = BigDecimal.valueOf(totalFee, ALGO_DECIMALS) + + return TransactionData( + recipientAddress = recipientAddress, + amountFormatted = amountDecimal.formatAsAlgoString(), + feeFormatted = feeDecimal.formatAsAlgoString(), + convertedAmount = calculateConvertedAmount(amountDecimal) + ) + } + + private fun calculateConvertedAmount(algoAmount: BigDecimal): String { + return calculateConvertedAlgoAmount(algoAmount) + } + + private data class TransactionData( + val recipientAddress: String, + val amountFormatted: String, + val feeFormatted: String, + val convertedAmount: String + ) + + private data class ParticipantData( + val signerAccounts: List, + val signedCount: Int, + val localParticipants: List, + val unsignedLocal: List, + val unsignedLedger: List, + val hasProposer: Boolean, + val currentUserAddress: String? + ) + + private data class ExpirationData( + val isExpired: Boolean, + val canBeSigned: Boolean, + val timeRemaining: String? + ) +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/JointAccountLedgerSignHelper.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/JointAccountLedgerSignHelper.kt new file mode 100644 index 000000000..d09833dd9 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/JointAccountLedgerSignHelper.kt @@ -0,0 +1,310 @@ +/* + * 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.addaccount.joint.transaction.domain.usecase + +import android.bluetooth.BluetoothDevice +import androidx.lifecycle.Lifecycle +import com.algorand.algosdk.transaction.SignedTransaction +import com.algorand.algosdk.util.Encoder +import com.algorand.android.R +import com.algorand.android.ledger.CustomScanCallback +import com.algorand.android.ledger.LedgerBleOperationManager +import com.algorand.android.ledger.LedgerBleSearchManager +import com.algorand.android.ledger.operations.ExternalTransaction +import com.algorand.android.ledger.operations.ExternalTransactionOperation +import com.algorand.android.models.LedgerBleResult +import com.algorand.android.utils.Event +import com.algorand.android.utils.LifecycleScopedCoroutineOwner +import com.algorand.wallet.jointaccount.transaction.domain.model.AddSignatureInput +import com.algorand.wallet.jointaccount.transaction.domain.model.SignRequestResponseType +import com.algorand.wallet.jointaccount.transaction.domain.usecase.AddJointAccountSignature +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +class JointAccountLedgerSignHelper @Inject constructor( + private val ledgerBleSearchManager: LedgerBleSearchManager, + private val ledgerBleOperationManager: LedgerBleOperationManager, + private val addJointAccountSignature: AddJointAccountSignature +) : LifecycleScopedCoroutineOwner() { + + private val _signResultFlow = MutableStateFlow(LedgerSignResult.Idle) + val signResultFlow: StateFlow = _signResultFlow + + private var currentSignRequest: SignRequest? = null + private var currentTransactionIndex = 0 + private var signedTransactions = mutableListOf() + + private val scanCallback = object : CustomScanCallback() { + override fun onLedgerScanned( + device: BluetoothDevice, + currentTransactionIndex: Int?, + totalTransactionCount: Int? + ) { + ledgerBleSearchManager.stop() + currentScope.launch { + currentSignRequest?.let { request -> + val rawTxBytes = request.rawTransactions.getOrNull( + this@JointAccountLedgerSignHelper.currentTransactionIndex + ) + if (rawTxBytes != null) { + val transaction = JointAccountExternalTransaction( + transactionByteArray = rawTxBytes, + accountAddress = request.accountAddress, + ) + ledgerBleOperationManager.startLedgerOperation( + ExternalTransactionOperation(device, transaction), + this@JointAccountLedgerSignHelper.currentTransactionIndex, + request.rawTransactions.size + ) + } + } + } + } + + override fun onScanError(errorMessageResId: Int, titleResId: Int) { + _signResultFlow.value = LedgerSignResult.Error(errorMessageResId) + } + } + + private val operationManagerCollectorAction: (suspend (Event?) -> Unit) = { event -> + event?.consume()?.let { ledgerBleResult -> + when (ledgerBleResult) { + is LedgerBleResult.SignedTransactionResult -> { + handleSignedTransaction(ledgerBleResult.transactionByteArray) + } + + is LedgerBleResult.LedgerWaitingForApproval -> { + _signResultFlow.value = LedgerSignResult.WaitingForApproval( + ledgerBleResult.bluetoothName, + ledgerBleResult.currentTransactionIndex, + ledgerBleResult.totalTransactionCount + ) + } + + is LedgerBleResult.AppErrorResult -> { + _signResultFlow.value = LedgerSignResult.Error(ledgerBleResult.errorMessageId) + } + + is LedgerBleResult.LedgerErrorResult -> { + _signResultFlow.value = LedgerSignResult.Error(R.string.an_error_occurred) + } + + is LedgerBleResult.OperationCancelledResult -> { + _signResultFlow.value = LedgerSignResult.Cancelled + } + + else -> Unit + } + } + } + + fun setup(lifecycle: Lifecycle) { + assignToLifecycle(lifecycle) + ledgerBleOperationManager.setup(lifecycle) + currentScope.launch { + ledgerBleOperationManager.ledgerBleResultFlow.collect(operationManagerCollectorAction) + } + } + + fun signWithLedger( + signRequestId: String, + accountAddress: String, + rawTransactionsBase64: List, + ledgerBluetoothAddress: String, + ledgerAccountIndex: Int + ) { + resetSigningState() + + val rawTransactions = decodeBase64Transactions(rawTransactionsBase64) + if (rawTransactions.isEmpty()) { + _signResultFlow.value = LedgerSignResult.Error(R.string.an_error_occurred) + return + } + + currentSignRequest = createSignRequest( + signRequestId, accountAddress, rawTransactions, ledgerBluetoothAddress, ledgerAccountIndex + ) + _signResultFlow.value = LedgerSignResult.Scanning + + startLedgerConnection(ledgerBluetoothAddress, rawTransactions.size) + } + + private fun resetSigningState() { + currentTransactionIndex = 0 + signedTransactions.clear() + _signResultFlow.value = LedgerSignResult.Idle + } + + private fun decodeBase64Transactions(rawTransactionsBase64: List): List { + return rawTransactionsBase64.mapNotNull { base64 -> + runCatching { android.util.Base64.decode(base64, android.util.Base64.DEFAULT) }.getOrNull() + } + } + + private fun createSignRequest( + signRequestId: String, + accountAddress: String, + rawTransactions: List, + ledgerBluetoothAddress: String, + ledgerAccountIndex: Int + ): SignRequest { + return SignRequest( + signRequestId = signRequestId, + accountAddress = accountAddress, + rawTransactions = rawTransactions, + ledgerBluetoothAddress = ledgerBluetoothAddress, + ledgerAccountIndex = ledgerAccountIndex + ) + } + + private fun startLedgerConnection(ledgerBluetoothAddress: String, transactionCount: Int) { + val currentConnectedDevice = ledgerBleOperationManager.connectedBluetoothDevice + val isAlreadyConnected = currentConnectedDevice != null && + currentConnectedDevice.address == ledgerBluetoothAddress + + if (isAlreadyConnected) { + scanCallback.onLedgerScanned(currentConnectedDevice!!, 0, transactionCount) + } else { + ledgerBleSearchManager.scan( + newScanCallback = scanCallback, + filteredAddress = ledgerBluetoothAddress, + coroutineScope = currentScope + ) + } + } + + private fun handleSignedTransaction(signedTransactionData: ByteArray) { + signedTransactions.add(signedTransactionData) + currentTransactionIndex++ + + currentSignRequest?.let { request -> + if (currentTransactionIndex < request.rawTransactions.size) { + val currentConnectedDevice = ledgerBleOperationManager.connectedBluetoothDevice + if (currentConnectedDevice != null) { + scanCallback.onLedgerScanned( + currentConnectedDevice, + currentTransactionIndex, + request.rawTransactions.size + ) + } else { + _signResultFlow.value = LedgerSignResult.Error(R.string.an_error_occurred) + } + } else { + submitSignatures(request) + } + } + } + + private fun submitSignatures(request: SignRequest) { + currentScope.launch { + _signResultFlow.value = LedgerSignResult.Submitting + + val signatures = signedTransactions.mapNotNull { signedTx -> + extractSignatureFromSignedTransaction(signedTx) + } + + if (signatures.size != request.rawTransactions.size) { + _signResultFlow.value = LedgerSignResult.Error(R.string.an_error_occurred) + return@launch + } + + val addSignatureInput = AddSignatureInput( + address = request.accountAddress, + response = SignRequestResponseType.SIGNED, + signatures = listOf(signatures.map { Encoder.encodeToBase64(it) }) + ) + + addJointAccountSignature( + signRequestId = request.signRequestId, + addSignatureInput = addSignatureInput + ).use( + onSuccess = { + _signResultFlow.value = LedgerSignResult.Success + }, + onFailed = { _, _ -> + _signResultFlow.value = LedgerSignResult.Error(R.string.an_error_occurred) + } + ) + } + } + + private fun extractSignatureFromSignedTransaction(signedTx: ByteArray): ByteArray? { + return try { + // The signed transaction contains the signature in the first 64 bytes after the 'sig' key + // We use Encoder to decode and extract the signature + val signedTransaction = Encoder.decodeFromMsgPack( + signedTx, + SignedTransaction::class.java + ) + signedTransaction.sig?.bytes + } catch (e: Exception) { + null + } + } + + fun cancel() { + ledgerBleSearchManager.stop() + ledgerBleOperationManager.manualStopAllProcess() + _signResultFlow.value = LedgerSignResult.Cancelled + } + + /** + * Reset the state to Idle. Should be called after handling Success or Error results. + */ + fun resetState() { + _signResultFlow.value = LedgerSignResult.Idle + } + + override fun stopAllResources() { + ledgerBleSearchManager.stop() + ledgerBleOperationManager.manualStopAllProcess() + currentSignRequest = null + signedTransactions.clear() + } + + private data class SignRequest( + val signRequestId: String, + val accountAddress: String, + val rawTransactions: List, + val ledgerBluetoothAddress: String, + val ledgerAccountIndex: Int + ) + + /** + * Implementation of ExternalTransaction for joint account Ledger signing + */ + private class JointAccountExternalTransaction( + override val transactionByteArray: ByteArray, + override val accountAddress: String, + ) : ExternalTransaction { + override val isRekeyedToAnotherAccount: Boolean = false + override val accountAuthAddress: String? = null + } + + sealed class LedgerSignResult { + data object Idle : LedgerSignResult() + data object Scanning : LedgerSignResult() + data class WaitingForApproval( + val bluetoothName: String?, + val currentTransactionIndex: Int?, + val totalTransactionCount: Int? + ) : LedgerSignResult() + + data object Submitting : LedgerSignResult() + data object Success : LedgerSignResult() + data object Cancelled : LedgerSignResult() + data class Error(val errorMessageResId: Int) : LedgerSignResult() + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/SignAndSubmitJointAccountSignatureUseCase.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/SignAndSubmitJointAccountSignatureUseCase.kt new file mode 100644 index 000000000..4474c3e1d --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/domain/usecase/SignAndSubmitJointAccountSignatureUseCase.kt @@ -0,0 +1,123 @@ +/* + * 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.addaccount.joint.transaction.domain.usecase + +import android.util.Base64 +import com.algorand.algosdk.transaction.SignedTransaction +import com.algorand.algosdk.util.Encoder +import com.algorand.android.modules.addaccount.joint.transaction.domain.exception.JointAccountSigningException +import com.algorand.android.utils.decodeBase64 +import com.algorand.android.utils.signTx +import com.algorand.wallet.account.local.domain.model.LocalAccount +import com.algorand.wallet.account.local.domain.usecase.GetAlgo25SecretKey +import com.algorand.wallet.account.local.domain.usecase.GetHdSeed +import com.algorand.wallet.account.local.domain.usecase.GetLocalAccount +import com.algorand.wallet.algosdk.transaction.sdk.SignHdKeyTransaction +import com.algorand.wallet.encryption.domain.utils.clearFromMemory +import com.algorand.wallet.foundation.PeraResult +import com.algorand.wallet.jointaccount.transaction.domain.model.AddSignatureInput +import com.algorand.wallet.jointaccount.transaction.domain.model.JointSignRequest +import com.algorand.wallet.jointaccount.transaction.domain.model.SignRequestResponseType +import com.algorand.wallet.jointaccount.transaction.domain.usecase.AddJointAccountSignature +import javax.inject.Inject + +fun interface SignAndSubmitJointAccountSignature { + suspend operator fun invoke( + signRequestId: String, + participantAddress: String, + rawTransactions: List + ): PeraResult +} + +internal class SignAndSubmitJointAccountSignatureUseCase @Inject constructor( + private val addJointAccountSignature: AddJointAccountSignature, + private val getLocalAccount: GetLocalAccount, + private val getAlgo25SecretKey: GetAlgo25SecretKey, + private val getHdSeed: GetHdSeed, + private val signHdKeyTransaction: SignHdKeyTransaction +) : SignAndSubmitJointAccountSignature { + + override suspend fun invoke( + signRequestId: String, + participantAddress: String, + rawTransactions: List + ): PeraResult { + val signatures = mutableListOf() + + for (rawTransaction in rawTransactions) { + val transactionBytes = rawTransaction.decodeBase64() + ?: return PeraResult.Error(JointAccountSigningException.TransactionDecodeFailed) + + val signatureBytes = signTransaction(transactionBytes, participantAddress) + ?: return PeraResult.Error(JointAccountSigningException.SigningFailed) + + signatures.add(Base64.encodeToString(signatureBytes, Base64.NO_WRAP)) + } + + val addSignatureInput = AddSignatureInput( + address = participantAddress, + response = SignRequestResponseType.SIGNED, + signatures = listOf(signatures), + deviceId = null + ) + + return addJointAccountSignature(signRequestId, addSignatureInput) + } + + private suspend fun signTransaction(transactionBytes: ByteArray, signerAddress: String): ByteArray? { + val localAccount = getLocalAccount(signerAddress) ?: return null + + return when (localAccount) { + is LocalAccount.Algo25 -> signAlgo25Transaction(transactionBytes, signerAddress) + is LocalAccount.HdKey -> signHdKeyTransaction(transactionBytes, localAccount) + else -> null + } + } + + private suspend fun signAlgo25Transaction(transactionBytes: ByteArray, signerAddress: String): ByteArray? { + val secretKey = getAlgo25SecretKey(signerAddress) ?: return null + return try { + val signedTransaction = runCatching { transactionBytes.signTx(secretKey) }.getOrNull() + ?.takeIf { it.isNotEmpty() } ?: return null + extractSignatureFromSignedTransaction(signedTransaction) + } finally { + secretKey.clearFromMemory() + } + } + + private suspend fun signHdKeyTransaction( + transactionBytes: ByteArray, + hdKeyAccount: LocalAccount.HdKey + ): ByteArray? { + val seed = getHdSeed(seedId = hdKeyAccount.seedId) ?: return null + return try { + signHdKeyTransaction.signTransactionReturnSignature( + transactionBytes, + seed, + hdKeyAccount.account, + hdKeyAccount.change, + hdKeyAccount.keyIndex + ) + } finally { + seed.clearFromMemory() + } + } + + private fun extractSignatureFromSignedTransaction(signedTransactionBytes: ByteArray): ByteArray? { + if (signedTransactionBytes.isEmpty()) return null + return runCatching { + val signedTransaction = Encoder.decodeFromMsgPack(signedTransactionBytes, SignedTransaction::class.java) + signedTransaction.sig?.bytes?.takeIf { it.isNotEmpty() } + }.getOrNull() + } +} diff --git a/app/src/main/kotlin/com/algorand/android/decider/RegisterIntroPreviewDecider.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/model/JointAccountSignatureStatus.kt similarity index 63% rename from app/src/main/kotlin/com/algorand/android/decider/RegisterIntroPreviewDecider.kt rename to app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/model/JointAccountSignatureStatus.kt index 1ee46a562..a5bcb4fef 100644 --- a/app/src/main/kotlin/com/algorand/android/decider/RegisterIntroPreviewDecider.kt +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/model/JointAccountSignatureStatus.kt @@ -7,17 +7,15 @@ * 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 + * limitations under the License */ -package com.algorand.android.decider +package com.algorand.android.modules.addaccount.joint.transaction.model -import com.algorand.android.R -import javax.inject.Inject +sealed class JointAccountSignatureStatus { + data object Signed : JointAccountSignatureStatus() -class RegisterIntroPreviewDecider @Inject constructor() { + data object Pending : JointAccountSignatureStatus() - fun decideTitleRes(hasAccount: Boolean): Int { - return if (hasAccount) R.string.add_an_account else R.string.welcome_to_pera - } + data object Rejected : JointAccountSignatureStatus() } diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/model/JointAccountSignerItem.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/model/JointAccountSignerItem.kt new file mode 100644 index 000000000..bcfde53e8 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/model/JointAccountSignerItem.kt @@ -0,0 +1,41 @@ +/* + * 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.addaccount.joint.transaction.model + +import android.net.Uri +import com.algorand.android.modules.accountcore.ui.model.AccountDisplayName +import com.algorand.android.modules.accounticon.ui.model.AccountIconDrawablePreview + +/** + * Represents a signer account in a joint account transaction + */ +data class JointAccountSignerItem( + val accountAddress: String, + val accountDisplayName: AccountDisplayName, + val accountIconDrawablePreview: AccountIconDrawablePreview, + val imageUri: Uri?, // For contacts + val signatureStatus: JointAccountSignatureStatus, + val isLedgerAccount: Boolean = false, + val ledgerBluetoothAddress: String? = null, + val ledgerAccountIndex: Int? = null +) { + /** + * Returns true if this signer can be signed with Ledger + * (is a Ledger account AND has all required data AND hasn't signed/rejected yet) + */ + val canSignWithLedger: Boolean + get() = isLedgerAccount && + ledgerBluetoothAddress != null && + ledgerAccountIndex != null && + signatureStatus == JointAccountSignatureStatus.Pending +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/model/JointAccountTransactionPreview.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/model/JointAccountTransactionPreview.kt new file mode 100644 index 000000000..7ac9fbe90 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/model/JointAccountTransactionPreview.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.addaccount.joint.transaction.model + +import com.algorand.android.modules.accountcore.ui.model.AccountDisplayName +import com.algorand.android.modules.accounticon.ui.model.AccountIconDrawablePreview + +data class JointAccountTransactionPreview( + val jointAccountDisplayName: AccountDisplayName, + val jointAccountIconPreview: AccountIconDrawablePreview, + val recipientAddress: String, + val recipientShortAddress: String, + val amount: String, + val convertedAmount: String, + val transactionFee: String, + val transactionState: JointAccountTransactionState, + val signerAccounts: List, + val signedCount: Int, + val requiredSignatureCount: Int, + val timeRemaining: String? = null, + val transactionId: String? = null, + val jointAccountAddress: String? = null, + val currentUserParticipantAddress: String? = null, + val isExpired: Boolean = false, + val hasCurrentUserAlreadySigned: Boolean = false, + val shouldShowPendingSignaturesDirectly: Boolean = false, + val rawTransactions: List = emptyList(), + val allLocalParticipantAddresses: List = emptyList(), + val unsignedLocalParticipantAddresses: List = emptyList(), + val unsignedLedgerParticipantAddresses: List = emptyList(), + val hasProposerAddress: Boolean = false +) diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/model/JointAccountTransactionState.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/model/JointAccountTransactionState.kt new file mode 100644 index 000000000..2cb2725aa --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/model/JointAccountTransactionState.kt @@ -0,0 +1,23 @@ +/* + * 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.addaccount.joint.transaction.model + +sealed class JointAccountTransactionState { + data object AwaitingConfirmation : JointAccountTransactionState() + + data object PendingSignatures : JointAccountTransactionState() + + data object Canceled : JointAccountTransactionState() + + data object Completed : JointAccountTransactionState() +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/JointAccountSignRequestFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/JointAccountSignRequestFragment.kt new file mode 100644 index 000000000..87749ab16 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/JointAccountSignRequestFragment.kt @@ -0,0 +1,215 @@ +/* + * 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.addaccount.joint.transaction.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.compose.runtime.mutableStateOf +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.navigation.fragment.findNavController +import com.algorand.android.R +import com.algorand.android.core.DaggerBaseFragment +import com.algorand.android.customviews.LedgerLoadingDialog +import com.algorand.android.models.FragmentConfiguration +import com.algorand.android.modules.addaccount.joint.transaction.domain.usecase.JointAccountLedgerSignHelper +import com.algorand.android.modules.addaccount.joint.transaction.viewmodel.JointAccountTransactionViewModel +import com.algorand.android.modules.addaccount.joint.transaction.viewmodel.JointAccountTransactionViewModel.ViewEvent +import com.algorand.android.ui.compose.extensions.createComposeView +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.utils.copyToClipboard +import com.algorand.android.utils.extensions.collectLatestOnLifecycle +import com.algorand.android.utils.extensions.collectOnLifecycle +import com.algorand.android.utils.showWithStateCheck +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class JointAccountSignRequestFragment : DaggerBaseFragment(0), + JointAccountSignRequestScreenListener { + + override val fragmentConfiguration = FragmentConfiguration() + + private val viewModel: JointAccountTransactionViewModel by viewModels() + + @Inject + lateinit var ledgerSignHelper: JointAccountLedgerSignHelper + + private var ledgerLoadingDialog: LedgerLoadingDialog? = null + private val shouldShowPendingSignatures = mutableStateOf(false) + + private val ledgerLoadingDialogListener = LedgerLoadingDialog.Listener { shouldStopResources -> + hideLedgerLoading() + if (shouldStopResources) { + ledgerSignHelper.cancel() + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return createComposeView { + PeraTheme { + JointAccountSignRequestScreen( + viewModel = viewModel, + listener = this, + showPendingSignatures = shouldShowPendingSignatures.value, + onPendingSignaturesShown = { shouldShowPendingSignatures.value = false } + ) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupLedgerSignHelper() + initObservers() + } + + private fun setupLedgerSignHelper() { + ledgerSignHelper.setup(viewLifecycleOwner.lifecycle) + } + + private fun initObservers() { + viewLifecycleOwner.collectLatestOnLifecycle( + flow = viewModel.viewEvent, + collection = ::handleViewEvent + ) + observeLedgerSignResult() + } + + private fun handleViewEvent(event: ViewEvent) { + when (event) { + is ViewEvent.NavigateBack -> navBack() + is ViewEvent.ShowError -> showGlobalError(getString(event.messageResId)) + is ViewEvent.ShowPendingSignaturesBottomSheet, + is ViewEvent.ShowPendingSignaturesDirectly -> { + shouldShowPendingSignatures.value = true + } + is ViewEvent.StartLedgerSigning -> { + ledgerSignHelper.signWithLedger( + signRequestId = event.data.signRequestId, + accountAddress = event.data.accountAddress, + rawTransactionsBase64 = event.data.rawTransactions, + ledgerBluetoothAddress = event.data.ledgerBluetoothAddress, + ledgerAccountIndex = event.data.ledgerAccountIndex + ) + } + is ViewEvent.CopyAddress -> { + context?.copyToClipboard(event.address) + } + } + } + + private fun observeLedgerSignResult() { + viewLifecycleOwner.collectOnLifecycle( + flow = ledgerSignHelper.signResultFlow, + collection = ::handleLedgerSignResult, + state = Lifecycle.State.STARTED + ) + } + + private fun handleLedgerSignResult(result: JointAccountLedgerSignHelper.LedgerSignResult) { + when (result) { + is JointAccountLedgerSignHelper.LedgerSignResult.Scanning -> { + showLedgerLoading(getString(R.string.searching_for_ledger)) + } + is JointAccountLedgerSignHelper.LedgerSignResult.WaitingForApproval -> { + showLedgerLoading( + result.bluetoothName ?: getString(R.string.ledger), + result.currentTransactionIndex, + result.totalTransactionCount + ) + } + is JointAccountLedgerSignHelper.LedgerSignResult.Submitting -> Unit + is JointAccountLedgerSignHelper.LedgerSignResult.Success -> { + hideLedgerLoading() + ledgerSignHelper.resetState() + viewModel.onLedgerSignSuccess() + Toast.makeText( + requireContext(), + R.string.signature_submitted_successfully, + Toast.LENGTH_SHORT + ).show() + } + is JointAccountLedgerSignHelper.LedgerSignResult.Error -> { + hideLedgerLoading() + ledgerSignHelper.resetState() + viewModel.onLedgerSignError(result.errorMessageResId) + } + is JointAccountLedgerSignHelper.LedgerSignResult.Cancelled -> { + hideLedgerLoading() + ledgerSignHelper.resetState() + } + is JointAccountLedgerSignHelper.LedgerSignResult.Idle -> Unit + } + } + + private fun showLedgerLoading( + ledgerName: String, + currentTransactionIndex: Int? = null, + totalTransactionCount: Int? = null + ) { + val isTransactionIndicatorVisible = currentTransactionIndex != null && totalTransactionCount != null + + if (ledgerLoadingDialog == null) { + ledgerLoadingDialog = LedgerLoadingDialog.createLedgerLoadingDialog( + ledgerName = ledgerName, + listener = ledgerLoadingDialogListener, + currentTransactionIndex = currentTransactionIndex, + totalTransactionCount = totalTransactionCount, + isTransactionIndicatorVisible = isTransactionIndicatorVisible + ) + ledgerLoadingDialog?.showWithStateCheck(childFragmentManager, LEDGER_LOADING_TAG) + } else { + currentTransactionIndex?.let { + ledgerLoadingDialog?.updateTransactionIndicator(it) + } + } + } + + private fun hideLedgerLoading() { + ledgerLoadingDialog?.dismissAllowingStateLoss() + ledgerLoadingDialog = null + } + + override fun onCloseClick() { + navBack() + } + + override fun onCopyAddressClick() { + viewModel.onCopyAddressClick() + } + + override fun onDeclineClick() { + viewModel.declineSignRequest() + } + + override fun onNavigateToHome() { + findNavController().popBackStack(R.id.accountsFragment, false) + } + + override fun onDestroyView() { + super.onDestroyView() + hideLedgerLoading() + } + + companion object { + private const val LEDGER_LOADING_TAG = "ledger_loading" + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/JointAccountSignRequestScreen.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/JointAccountSignRequestScreen.kt new file mode 100644 index 000000000..8d8bdf559 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/JointAccountSignRequestScreen.kt @@ -0,0 +1,421 @@ +/* + * 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 + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.algorand.android.modules.addaccount.joint.transaction.ui + +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.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.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.algorand.android.R +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountTransactionPreview +import com.algorand.android.modules.addaccount.joint.transaction.viewmodel.JointAccountTransactionViewModel +import com.algorand.android.modules.addaccount.joint.transaction.viewmodel.JointAccountTransactionViewModel.ViewState +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.slidetoconfirm.SlideToConfirm +import com.algorand.android.ui.compose.widget.button.slidetoconfirm.SlideToConfirmButton +import com.algorand.android.ui.compose.widget.modifier.clickableNoRipple +import com.algorand.android.ui.compose.widget.progress.PeraCircularProgressIndicator +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun JointAccountSignRequestScreen( + viewModel: JointAccountTransactionViewModel, + listener: JointAccountSignRequestScreenListener, + showPendingSignatures: Boolean = false, + onPendingSignaturesShown: () -> Unit = {} +) { + val viewState by viewModel.state.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showBottomSheet by remember { mutableStateOf(false) } + + LaunchedEffect(showPendingSignatures) { + if (showPendingSignatures) { + showBottomSheet = true + onPendingSignaturesShown() + } + } + + when (val state = viewState) { + is ViewState.Loading -> LoadingState() + is ViewState.Content -> { + TransactionContent( + preview = state.preview, + listener = listener, + onShowBottomSheet = { showBottomSheet = true }, + onConfirm = { viewModel.onConfirmTransaction() } + ) + + if (showBottomSheet) { + BottomSheetContent( + scope = scope, + sheetState = sheetState, + preview = state.preview, + onHideSheet = { showBottomSheet = false }, + onCancel = { viewModel.onCancel() }, + onCloseForNow = listener::onCloseClick, + onCloseCompleted = listener::onNavigateToHome + ) + } + } + + is ViewState.Error -> { + ErrorState(messageResId = state.messageResId) + } + } +} + +@Composable +private fun LoadingState() { + Box( + modifier = Modifier + .fillMaxSize() + .background(PeraTheme.colors.background.primary), + contentAlignment = Alignment.Center + ) { + PeraCircularProgressIndicator() + } +} + +@Composable +private fun ErrorState(messageResId: Int) { + Box( + modifier = Modifier + .fillMaxSize() + .background(PeraTheme.colors.background.primary), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(messageResId), + style = PeraTheme.typography.body.regular.sans, + color = PeraTheme.colors.text.gray, + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +private fun TransactionContent( + preview: JointAccountTransactionPreview, + listener: JointAccountSignRequestScreenListener, + onShowBottomSheet: () -> Unit, + onConfirm: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(PeraTheme.colors.background.primary) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 180.dp) + ) { + ToolbarSection(preview = preview, listener = listener) + Spacer(modifier = Modifier.height(48.dp)) + JointAccountIconSection() + Spacer(modifier = Modifier.height(24.dp)) + TransferToSection( + recipientAddress = preview.recipientShortAddress, + onCopyClick = listener::onCopyAddressClick + ) + Spacer(modifier = Modifier.height(12.dp)) + AmountSection(preview = preview) + } + BottomSection( + modifier = Modifier.align(Alignment.BottomCenter), + preview = preview, + onShowTransactionDetailsClick = onShowBottomSheet, + onSlideToConfirm = onConfirm, + listener = listener + ) + } +} + +@Composable +private fun BottomSheetContent( + scope: CoroutineScope, + sheetState: SheetState, + preview: JointAccountTransactionPreview, + onHideSheet: () -> Unit, + onCancel: () -> Unit, + onCloseForNow: () -> Unit, + onCloseCompleted: () -> Unit +) { + fun hideSheetAndExecute(action: () -> Unit) { + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + onHideSheet() + action() + } + } + } + + PendingSignaturesBottomSheet( + sheetState = sheetState, + transactionPreview = preview, + onDismiss = { hideSheetAndExecute {} }, + onCancel = { hideSheetAndExecute(onCancel) }, + onCloseForNow = { hideSheetAndExecute(onCloseForNow) }, + onCloseCompleted = { hideSheetAndExecute(onCloseCompleted) } + ) +} + +@Composable +private fun ToolbarSection( + preview: JointAccountTransactionPreview, + listener: JointAccountSignRequestScreenListener +) { + Column { + PeraToolbar( + modifier = Modifier.padding(horizontal = 12.dp), + text = stringResource(R.string.review_transaction), + startContainer = { + PeraToolbarIcon( + iconResId = R.drawable.ic_close, + modifier = Modifier.clickableNoRipple(onClick = listener::onCloseClick) + ) + } + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + AccountIcon(modifier = Modifier.size(20.dp), iconDrawablePreview = preview.jointAccountIconPreview) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = preview.jointAccountDisplayName.primaryDisplayName, + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.gray + ) + } + } +} + +@Composable +private fun JointAccountIconSection() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(80.dp) + .background(color = PeraTheme.colors.layer.grayLighter, shape = CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(48.dp), + painter = painterResource(R.drawable.ic_joint), + contentDescription = null, + tint = PeraTheme.colors.text.gray + ) + } + } +} + +@Composable +private fun TransferToSection(recipientAddress: String, onCopyClick: () -> Unit) { + val transferTo = stringResource(R.string.transfer_to) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = PeraTheme.colors.text.gray)) { append(transferTo) } + withStyle( + SpanStyle( + color = PeraTheme.colors.text.main, + fontWeight = PeraTheme.typography.body.regular.sansMedium.fontWeight + ) + ) { append(recipientAddress) } + }, + style = PeraTheme.typography.body.regular.sans + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton(onClick = onCopyClick, modifier = Modifier.size(16.dp)) { + Icon( + modifier = Modifier + .size(16.dp) + .padding(1.dp), + painter = painterResource(R.drawable.ic_copy), + contentDescription = stringResource(R.string.copy), + tint = PeraTheme.colors.text.gray + ) + } + } +} + +@Composable +private fun AmountSection(preview: JointAccountTransactionPreview) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = preview.amount, + style = PeraTheme.typography.title.regular.sansMedium, + color = PeraTheme.colors.text.main + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = preview.convertedAmount, + style = PeraTheme.typography.body.regular.sans, + color = PeraTheme.colors.text.main, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun BottomSection( + modifier: Modifier = Modifier, + preview: JointAccountTransactionPreview, + onShowTransactionDetailsClick: () -> Unit, + onSlideToConfirm: () -> Unit, + listener: JointAccountSignRequestScreenListener +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(PeraTheme.colors.background.primary) + ) { + HorizontalDivider(color = PeraTheme.colors.layer.grayLighter, thickness = 1.dp) + Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) { + TransactionFeeRow(fee = preview.transactionFee) + Spacer(modifier = Modifier.height(8.dp)) + ShowDetailsLink(onClick = onShowTransactionDetailsClick) + Spacer(modifier = Modifier.height(16.dp)) + ConfirmButton(onConfirm = onSlideToConfirm) + Spacer(modifier = Modifier.height(12.dp)) + DeclineButton(onClick = listener::onDeclineClick) + } + } +} + +@Composable +private fun TransactionFeeRow(fee: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.transacting_fee), + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.gray + ) + Text( + text = fee, + style = PeraTheme.typography.body.regular.sansMedium, + color = PeraTheme.colors.status.negative + ) + } +} + +@Composable +private fun ShowDetailsLink(onClick: () -> Unit) { + Row(modifier = Modifier.clickable(onClick = onClick), verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.show_transaction_details), + style = PeraTheme.typography.footnote.sansMedium, + color = PeraTheme.colors.link.primary + ) + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_right_arrow), + contentDescription = null, + tint = PeraTheme.colors.link.primary + ) + } +} + +@Composable +private fun ConfirmButton(onConfirm: () -> Unit) { + val buttonState = remember { mutableStateOf(SlideToConfirm.ButtonState.Idle) } + SlideToConfirmButton( + modifier = Modifier.fillMaxWidth(), + buttonState = buttonState, + onConfirmed = onConfirm + ) +} + +@Composable +private fun DeclineButton(onClick: () -> Unit) { + Text( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 14.dp), + text = stringResource(R.string.decline), + style = PeraTheme.typography.body.regular.sansMedium, + color = PeraTheme.colors.status.negative, + textAlign = TextAlign.Center + ) +} + +interface JointAccountSignRequestScreenListener { + fun onCloseClick() + fun onCopyAddressClick() + fun onDeclineClick() + fun onNavigateToHome() +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/PendingSignaturesBottomSheet.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/PendingSignaturesBottomSheet.kt new file mode 100644 index 000000000..3ec066ef9 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/PendingSignaturesBottomSheet.kt @@ -0,0 +1,526 @@ +/* + * 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 + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.algorand.android.modules.addaccount.joint.transaction.ui + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.addaccount.joint.transaction.model.JointAccountSignatureStatus +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountSignerItem +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountTransactionPreview +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountTransactionState +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.ui.compose.widget.AccountIcon +import com.algorand.android.ui.compose.widget.ContactIcon +import com.algorand.android.ui.compose.widget.bottomsheet.PeraBottomSheetDragIndicator +import com.algorand.android.ui.compose.widget.progress.PeraCircularProgressIndicator +import com.algorand.android.utils.toShortenedAddress + +@Composable +fun PendingSignaturesBottomSheet( + sheetState: SheetState, + transactionPreview: JointAccountTransactionPreview, + onDismiss: () -> Unit, + onCancel: () -> Unit, + onCloseForNow: () -> Unit, + onCloseCompleted: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = PeraTheme.colors.background.primary, + dragHandle = null // Drag indicator is inside PendingSignaturesContent + ) { + PendingSignaturesContent( + transactionPreview = transactionPreview, + onCancel = onCancel, + onCloseForNow = onCloseForNow, + onCloseCompleted = onCloseCompleted + ) + } +} + +@Composable +fun PendingSignaturesContent( + transactionPreview: JointAccountTransactionPreview, + onCancel: () -> Unit, + onCloseForNow: () -> Unit, + onCloseCompleted: () -> Unit = onCloseForNow +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = PeraTheme.colors.background.primary, + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + ) + .padding(bottom = 16.dp) + ) { + PeraBottomSheetDragIndicator( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 12.dp, bottom = 8.dp) + ) + + TitleSection(title = stringResource(R.string.pending_signatures)) + + Spacer(modifier = Modifier.height(16.dp)) + + StatusBadgesSection( + signedCount = transactionPreview.signedCount, + requiredCount = transactionPreview.requiredSignatureCount, + timeRemaining = transactionPreview.timeRemaining, + transactionState = transactionPreview.transactionState + ) + + Spacer(modifier = Modifier.height(16.dp)) + + AccountsSectionHeader(threshold = transactionPreview.requiredSignatureCount) + + Spacer(modifier = Modifier.height(16.dp)) + + SignersListSection( + modifier = Modifier.padding(horizontal = 24.dp), + signers = transactionPreview.signerAccounts + ) + + Spacer(modifier = Modifier.height(24.dp)) + + ActionButtonsSection( + modifier = Modifier.padding(horizontal = 24.dp), + transactionState = transactionPreview.transactionState, + hasProposerAddress = transactionPreview.hasProposerAddress, + onCancel = onCancel, + onCloseForNow = onCloseForNow, + onCloseCompleted = onCloseCompleted + ) + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun TitleSection(title: String) { + Text( + modifier = Modifier.fillMaxWidth(), + text = title, + style = PeraTheme.typography.body.regular.sansMedium, + color = PeraTheme.colors.text.main, + textAlign = TextAlign.Center + ) +} + +@Composable +private fun StatusBadgesSection( + signedCount: Int, + requiredCount: Int, + timeRemaining: String?, + transactionState: JointAccountTransactionState +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + when (transactionState) { + JointAccountTransactionState.Canceled -> { + ErrorBadge(message = stringResource(R.string.transaction_canceled)) + } + + JointAccountTransactionState.Completed -> { + SuccessBadge(message = stringResource(R.string.transaction_successfully_completed)) + } + + else -> { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SignedCountBadge( + signedCount = signedCount, + requiredCount = requiredCount + ) + if (timeRemaining != null) { + TimeRemainingBadge(timeRemaining = timeRemaining) + } + } + } + } + } +} + +@Composable +private fun SignedCountBadge( + signedCount: Int, + requiredCount: Int +) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(PeraTheme.colors.layer.grayLighter) + .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 = null, + tint = PeraTheme.colors.text.main + ) + Text( + text = stringResource(R.string.of_signed, signedCount, requiredCount), + style = PeraTheme.typography.footnote.sansMedium, + color = PeraTheme.colors.text.main + ) + } +} + +@Composable +private fun TimeRemainingBadge(timeRemaining: String) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(PeraTheme.colors.layer.grayLighter) + .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 = null, + tint = PeraTheme.colors.text.main + ) + Text( + text = stringResource(R.string.time_left, timeRemaining), + style = PeraTheme.typography.footnote.sansMedium, + color = PeraTheme.colors.text.main + ) + } +} + +@Composable +private fun ErrorBadge(message: String) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(PeraTheme.colors.helper.negativeLighter) + .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_error), + contentDescription = null, + tint = PeraTheme.colors.helper.negative + ) + Text( + text = message, + style = PeraTheme.typography.footnote.sansMedium, + color = PeraTheme.colors.helper.negative + ) + } +} + +@Composable +private fun SuccessBadge(message: String) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(PeraTheme.colors.helper.positiveLighter) + .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_check), + contentDescription = null, + tint = PeraTheme.colors.helper.positive + ) + Text( + text = message, + style = PeraTheme.typography.footnote.sansMedium, + color = PeraTheme.colors.helper.positive + ) + } +} + +@Composable +private fun AccountsSectionHeader(threshold: Int) { + Column(modifier = Modifier.padding(horizontal = 24.dp)) { + Text( + text = stringResource(R.string.accounts), + style = PeraTheme.typography.body.regular.sansMedium, + color = PeraTheme.colors.text.main + ) + Text( + text = stringResource(R.string.you_need_at_least_accounts_to_sign, threshold), + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.gray + ) + } +} + +@Composable +private fun SignersListSection( + modifier: Modifier = Modifier, + signers: List +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + signers.forEach { signer -> + SignerItem(signer = signer) + } + } +} + +@Composable +private fun SignerItem(signer: JointAccountSignerItem) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(60.dp) + .clip(RoundedCornerShape(12.dp)) + .border( + width = 1.dp, + color = PeraTheme.colors.layer.gray, + shape = RoundedCornerShape(12.dp) + ) + .background(PeraTheme.colors.background.primary) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SignerIcon( + iconDrawablePreview = signer.accountIconDrawablePreview, + imageUri = signer.imageUri + ) + SignerInfo( + displayName = signer.accountDisplayName, + signatureStatus = signer.signatureStatus + ) + } + + SignerStatusIcon(signatureStatus = signer.signatureStatus) + } +} + +@Composable +private fun SignerIcon( + iconDrawablePreview: AccountIconDrawablePreview, + imageUri: Uri? +) { + if (imageUri != null) { + ContactIcon(imageUri = imageUri, size = 20.dp) + } else { + AccountIcon( + modifier = Modifier.size(20.dp), + iconDrawablePreview = iconDrawablePreview + ) + } +} + +@Composable +private fun SignerInfo( + displayName: AccountDisplayName, + signatureStatus: JointAccountSignatureStatus +) { + val primaryName = displayName.primaryDisplayName + val secondaryName = displayName.secondaryDisplayName + ?: displayName.accountAddress.toShortenedAddress() + + Column { + Text( + text = primaryName, + style = PeraTheme.typography.body.regular.sans, + color = when (signatureStatus) { + JointAccountSignatureStatus.Rejected -> PeraTheme.colors.helper.negative + else -> PeraTheme.colors.text.main + } + ) + if (primaryName != secondaryName) { + Text( + text = secondaryName, + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.grayLighter + ) + } + } +} + +@Composable +private fun SignerStatusIcon(signatureStatus: JointAccountSignatureStatus) { + when (signatureStatus) { + JointAccountSignatureStatus.Signed -> { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_check), + contentDescription = stringResource(R.string.signed), + tint = PeraTheme.colors.helper.positive + ) + } + + JointAccountSignatureStatus.Rejected -> { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_close), + contentDescription = stringResource(R.string.rejected), + tint = PeraTheme.colors.helper.negative + ) + } + + JointAccountSignatureStatus.Pending -> { + PeraCircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + } +} + +@Composable +private fun ActionButtonsSection( + modifier: Modifier = Modifier, + transactionState: JointAccountTransactionState, + hasProposerAddress: Boolean, + onCancel: () -> Unit, + onCloseForNow: () -> Unit, + onCloseCompleted: () -> Unit +) { + val isCompleted = transactionState == JointAccountTransactionState.Completed || + transactionState == JointAccountTransactionState.Canceled + val showSingleCloseButton = isCompleted || !hasProposerAddress + + if (showSingleCloseButton) { + SingleCloseButton( + modifier = modifier, + onClick = if (isCompleted) onCloseCompleted else onCloseForNow + ) + } else { + ProposerActionButtons( + modifier = modifier, + onCancel = onCancel, + onCloseForNow = onCloseForNow + ) + } +} + +@Composable +private fun SingleCloseButton( + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Button( + modifier = modifier + .fillMaxWidth() + .height(52.dp), + onClick = onClick, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors( + containerColor = PeraTheme.colors.layer.grayLighter, + contentColor = PeraTheme.colors.text.main + ), + contentPadding = PaddingValues(16.dp) + ) { + Text( + text = stringResource(R.string.close), + style = PeraTheme.typography.body.regular.sansMedium + ) + } +} + +@Composable +private fun ProposerActionButtons( + modifier: Modifier = Modifier, + onCancel: () -> Unit, + onCloseForNow: () -> Unit +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + modifier = Modifier + .weight(1f) + .height(52.dp), + onClick = onCancel, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors( + containerColor = PeraTheme.colors.layer.grayLighter, + contentColor = PeraTheme.colors.text.main + ), + contentPadding = PaddingValues(16.dp) + ) { + Text( + text = stringResource(R.string.cancel), + style = PeraTheme.typography.body.regular.sansMedium + ) + } + + Button( + modifier = Modifier + .weight(1f) + .height(52.dp), + onClick = onCloseForNow, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors( + containerColor = PeraTheme.colors.button.primary.background, + contentColor = PeraTheme.colors.button.primary.text + ), + contentPadding = PaddingValues(16.dp) + ) { + Text( + text = stringResource(R.string.close_for_now), + style = PeraTheme.typography.body.regular.sansMedium + ) + } + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/PendingSignaturesDialogFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/PendingSignaturesDialogFragment.kt new file mode 100644 index 000000000..93602ac13 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/PendingSignaturesDialogFragment.kt @@ -0,0 +1,163 @@ +/* + * 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.addaccount.joint.transaction.ui + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.algorand.android.R +import com.algorand.android.modules.addaccount.joint.transaction.viewmodel.JointAccountTransactionViewModel +import com.algorand.android.modules.addaccount.joint.transaction.viewmodel.JointAccountTransactionViewModel.ViewEvent +import com.algorand.android.modules.addaccount.joint.transaction.viewmodel.JointAccountTransactionViewModel.ViewState +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 com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class PendingSignaturesDialogFragment : BottomSheetDialogFragment() { + + private val viewModel: JointAccountTransactionViewModel by viewModels() + + override fun getTheme(): Int = R.style.BottomSheetDialogTheme_Primary + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + observeViewEvents() + } + + private fun observeViewEvents() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.viewEvent.collect { event -> + when (event) { + is ViewEvent.ShowError -> { + Toast.makeText(requireContext(), event.messageResId, Toast.LENGTH_SHORT).show() + } + is ViewEvent.NavigateBack -> dismiss() + else -> { /* Other events handled elsewhere */ } + } + } + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return createComposeView { + val viewState by viewModel.state.collectAsStateWithLifecycle() + + when (val state = viewState) { + is ViewState.Loading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = PeraTheme.colors.background.primary, + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + ) + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center + ) { + PeraCircularProgressIndicator() + } + } + is ViewState.Content -> { + PendingSignaturesContent( + transactionPreview = state.preview, + onCancel = { viewModel.declineSignRequest() }, + onCloseForNow = { dismiss() }, + onCloseCompleted = { + dismiss() + activity?.let { activity -> + val navController = androidx.navigation.Navigation.findNavController( + activity, + R.id.navigationHostFragment + ) + navController.popBackStack(R.id.accountsFragment, false) + } + } + ) + } + is ViewState.Error -> { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = PeraTheme.colors.background.primary, + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + ) + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center + ) { + PeraCircularProgressIndicator() + } + } + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).apply { + setOnShowListener { + val bottomSheet = findViewById( + com.google.android.material.R.id.design_bottom_sheet + ) + bottomSheet?.let { + it.setBackgroundResource(R.drawable.bottom_sheet_dialog_fragment_primary_background) + BottomSheetBehavior.from(it).apply { + skipCollapsed = true + state = BottomSheetBehavior.STATE_EXPANDED + } + } + } + } + } + + companion object { + const val TAG = "PendingSignaturesDialogFragment" + const val SIGN_REQUEST_ID_KEY = "signRequestId" + + fun newInstance(signRequestId: String): PendingSignaturesDialogFragment { + return PendingSignaturesDialogFragment().apply { + arguments = Bundle().apply { + putString(SIGN_REQUEST_ID_KEY, signRequestId) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/preview/JointAccountSignRequestScreenPreview.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/preview/JointAccountSignRequestScreenPreview.kt new file mode 100644 index 000000000..67e8ebde6 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/preview/JointAccountSignRequestScreenPreview.kt @@ -0,0 +1,276 @@ +@file:Suppress("EmptyFunctionBlock", "Unused", "MagicNumber") +/* + * 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.addaccount.joint.transaction.ui.preview + +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.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.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.algorand.android.R +import com.algorand.android.modules.accountcore.ui.usecase.AccountIconDrawablePreviews +import com.algorand.android.ui.compose.preview.PeraPreviewLightDark +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.slidetoconfirm.SlideToConfirm +import com.algorand.android.ui.compose.widget.button.slidetoconfirm.SlideToConfirmButton +import com.algorand.android.ui.compose.widget.modifier.clickableNoRipple +import com.algorand.android.ui.compose.widget.progress.PeraCircularProgressIndicator + +@PeraPreviewLightDark +@Composable +fun JointAccountSignRequestScreenPreview() { + PeraTheme { + JointAccountSignRequestContentPreview() + } +} + +@PeraPreviewLightDark +@Composable +fun JointAccountSignRequestScreenLoadingPreview() { + PeraTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(PeraTheme.colors.background.primary), + contentAlignment = Alignment.Center + ) { + PeraCircularProgressIndicator() + } + } +} + +@Composable +private fun JointAccountSignRequestContentPreview() { + Box( + modifier = Modifier + .fillMaxSize() + .background(PeraTheme.colors.background.primary) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 180.dp) + ) { + ToolbarSectionPreview() + Spacer(modifier = Modifier.height(48.dp)) + JointAccountIconSectionPreview() + Spacer(modifier = Modifier.height(24.dp)) + TransferToSectionPreview() + Spacer(modifier = Modifier.height(12.dp)) + AmountSectionPreview() + } + BottomSectionPreview(modifier = Modifier.align(Alignment.BottomCenter)) + } +} + +@Composable +private fun ToolbarSectionPreview() { + Column { + PeraToolbar( + modifier = Modifier.padding(horizontal = 12.dp), + text = stringResource(R.string.review_transaction), + startContainer = { + PeraToolbarIcon( + iconResId = R.drawable.ic_close, + modifier = Modifier.clickableNoRipple(onClick = {}) + ) + } + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + AccountIcon( + modifier = Modifier.size(20.dp), + iconDrawablePreview = AccountIconDrawablePreviews.getJointDrawable() + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Joint Account #1", + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.gray + ) + } + } +} + +@Composable +private fun JointAccountIconSectionPreview() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(80.dp) + .background(color = PeraTheme.colors.layer.grayLighter, shape = CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(48.dp), + painter = painterResource(R.drawable.ic_joint), + contentDescription = null, + tint = PeraTheme.colors.text.gray + ) + } + } +} + +@Composable +private fun TransferToSectionPreview() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = PeraTheme.colors.text.gray)) { append("Transfer to ") } + withStyle( + SpanStyle( + color = PeraTheme.colors.text.main, + fontWeight = PeraTheme.typography.body.regular.sansMedium.fontWeight + ) + ) { append("JDM35...XJD3M") } + }, + style = PeraTheme.typography.body.regular.sans + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton(onClick = {}, modifier = Modifier.size(16.dp)) { + Icon( + modifier = Modifier + .size(16.dp) + .padding(1.dp), + painter = painterResource(R.drawable.ic_copy), + contentDescription = stringResource(R.string.copy), + tint = PeraTheme.colors.text.gray + ) + } + } +} + +@Composable +private fun AmountSectionPreview() { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "₳21.6500", + style = PeraTheme.typography.title.regular.sansMedium, + color = PeraTheme.colors.text.main + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "$6.24", + style = PeraTheme.typography.body.regular.sans, + color = PeraTheme.colors.text.main, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun BottomSectionPreview(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .background(PeraTheme.colors.background.primary) + ) { + HorizontalDivider(color = PeraTheme.colors.layer.grayLighter, thickness = 1.dp) + Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.transacting_fee), + style = PeraTheme.typography.footnote.sans, + color = PeraTheme.colors.text.gray + ) + Text( + text = "-₳0.002", + style = PeraTheme.typography.body.regular.sansMedium, + color = PeraTheme.colors.status.negative + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.clickable(onClick = {}), verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.show_transaction_details), + style = PeraTheme.typography.footnote.sansMedium, + color = PeraTheme.colors.link.primary + ) + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_right_arrow), + contentDescription = null, + tint = PeraTheme.colors.link.primary + ) + } + Spacer(modifier = Modifier.height(16.dp)) + val buttonState = remember { mutableStateOf(SlideToConfirm.ButtonState.Idle) } + SlideToConfirmButton( + modifier = Modifier.fillMaxWidth(), + buttonState = buttonState, + onConfirmed = {} + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = {}) + .padding(vertical = 14.dp), + text = stringResource(R.string.decline), + style = PeraTheme.typography.body.regular.sansMedium, + color = PeraTheme.colors.status.negative, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/preview/PendingSignaturesBottomSheetPreview.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/preview/PendingSignaturesBottomSheetPreview.kt new file mode 100644 index 000000000..68d278bf9 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/ui/preview/PendingSignaturesBottomSheetPreview.kt @@ -0,0 +1,242 @@ +@file:Suppress("MagicNumber") +/* + * 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.addaccount.joint.transaction.ui.preview + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.algorand.android.modules.accountcore.ui.model.AccountDisplayName +import com.algorand.android.modules.accountcore.ui.usecase.AccountIconDrawablePreviews +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountSignatureStatus +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountSignerItem +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountTransactionPreview +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountTransactionState +import com.algorand.android.modules.addaccount.joint.transaction.ui.PendingSignaturesBottomSheet +import com.algorand.android.ui.compose.preview.PeraPreviewLightDark +import com.algorand.android.ui.compose.theme.PeraTheme +import com.algorand.android.ui.compose.widget.bottomsheet.PeraBottomSheetDragIndicator + +@OptIn(ExperimentalMaterial3Api::class) +@PeraPreviewLightDark +@Composable +fun PendingSignaturesBottomSheetPreview() { + PeraTheme { + PendingSignaturesBottomSheet( + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + transactionPreview = createMockPreviewPending(), + onDismiss = {}, + onCancel = {}, + onCloseForNow = {}, + onCloseCompleted = {} + ) + } +} + +@PeraPreviewLightDark +@Composable +fun PendingSignaturesContentPreview() { + PeraTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + .background(PeraTheme.colors.background.primary) + .padding(bottom = 16.dp) + ) { + PeraBottomSheetDragIndicator(modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) + PendingSignaturesContentInternal(preview = createMockPreviewPending()) + } + } +} + +@PeraPreviewLightDark +@Composable +fun PendingSignaturesCanceledPreview() { + PeraTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + .background(PeraTheme.colors.background.primary) + .padding(bottom = 16.dp) + ) { + PeraBottomSheetDragIndicator(modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) + PendingSignaturesContentInternal(preview = createMockPreviewCanceled()) + } + } +} + +@PeraPreviewLightDark +@Composable +fun PendingSignaturesCompletedPreview() { + PeraTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + .background(PeraTheme.colors.background.primary) + .padding(bottom = 16.dp) + ) { + PeraBottomSheetDragIndicator(modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) + PendingSignaturesContentInternal(preview = createMockPreviewCompleted()) + } + } +} + +@Composable +private fun PendingSignaturesContentInternal(preview: JointAccountTransactionPreview) { + // This is a simplified version for preview purposes + // The actual content is rendered by PendingSignaturesBottomSheet + Spacer(modifier = Modifier.height(400.dp)) +} + +private fun createMockPreviewPending(): JointAccountTransactionPreview { + val jointIcon = AccountIconDrawablePreviews.getJointDrawable() + return JointAccountTransactionPreview( + jointAccountDisplayName = AccountDisplayName( + accountAddress = "HZQ73CXUPMVKRB4LNGJAGVZQCFPQDCCPSDZZE", + primaryDisplayName = "Joint Account #1", + secondaryDisplayName = "HZQ7...DZZE" + ), + jointAccountIconPreview = jointIcon, + recipientAddress = "JDM35UAJXUCQY4XKXGHDWZQSVXJD3M", + recipientShortAddress = "JDM35...XJD3M", + amount = "₳21.6500", + convertedAmount = "$6.24", + transactionFee = "-₳0.002", + transactionState = JointAccountTransactionState.PendingSignatures, + signerAccounts = listOf( + JointAccountSignerItem( + accountAddress = "HZQ73CXUPMVKRB4LNGJAGVZQCFPQDCCPSDZZE", + accountDisplayName = AccountDisplayName( + accountAddress = "HZQ73CXUPMVKRB4LNGJAGVZQCFPQDCCPSDZZE", + primaryDisplayName = "HZQ73C...PSDZZE", + secondaryDisplayName = null + ), + accountIconDrawablePreview = jointIcon, + imageUri = null, + signatureStatus = JointAccountSignatureStatus.Signed + ), + JointAccountSignerItem( + accountAddress = "DUA4XLTFPBPWDDCH47SGDNZ5IJ52DFXG7X2N2ETI", + accountDisplayName = AccountDisplayName( + accountAddress = "DUA4XLTFPBPWDDCH47SGDNZ5IJ52DFXG7X2N2ETI", + primaryDisplayName = "tahir.algo", + secondaryDisplayName = "DUA4...2ETI" + ), + accountIconDrawablePreview = jointIcon, + imageUri = null, + signatureStatus = JointAccountSignatureStatus.Pending + ), + JointAccountSignerItem( + accountAddress = "S93KZQHV4XLTFPBPWDDCH47SGNSK2", + accountDisplayName = AccountDisplayName( + accountAddress = "S93KZQHV4XLTFPBPWDDCH47SGNSK2", + primaryDisplayName = "Katie Rochester", + secondaryDisplayName = "S93K...NSK2" + ), + accountIconDrawablePreview = jointIcon, + imageUri = null, + signatureStatus = JointAccountSignatureStatus.Pending + ) + ), + signedCount = 1, + requiredSignatureCount = 3, + timeRemaining = "≈ 52m" + ) +} + +private fun createMockPreviewCanceled(): JointAccountTransactionPreview { + val jointIcon = AccountIconDrawablePreviews.getJointDrawable() + return createMockPreviewPending().copy( + transactionState = JointAccountTransactionState.Canceled, + signerAccounts = listOf( + JointAccountSignerItem( + accountAddress = "HZQ73CXUPMVKRB4LNGJAGVZQCFPQDCCPSDZZE", + accountDisplayName = AccountDisplayName( + accountAddress = "HZQ73CXUPMVKRB4LNGJAGVZQCFPQDCCPSDZZE", + primaryDisplayName = "HZQ73C...PSDZZE", + secondaryDisplayName = null + ), + accountIconDrawablePreview = jointIcon, + imageUri = null, + signatureStatus = JointAccountSignatureStatus.Signed + ), + JointAccountSignerItem( + accountAddress = "DUA4XLTFPBPWDDCH47SGDNZ5IJ52DFXG7X2N2ETI", + accountDisplayName = AccountDisplayName( + accountAddress = "DUA4XLTFPBPWDDCH47SGDNZ5IJ52DFXG7X2N2ETI", + primaryDisplayName = "tahir.algo", + secondaryDisplayName = "DUA4...2ETI" + ), + accountIconDrawablePreview = jointIcon, + imageUri = null, + signatureStatus = JointAccountSignatureStatus.Rejected + ) + ) + ) +} + +private fun createMockPreviewCompleted(): JointAccountTransactionPreview { + val jointIcon = AccountIconDrawablePreviews.getJointDrawable() + return createMockPreviewPending().copy( + transactionState = JointAccountTransactionState.Completed, + signedCount = 3, + signerAccounts = listOf( + JointAccountSignerItem( + accountAddress = "HZQ73CXUPMVKRB4LNGJAGVZQCFPQDCCPSDZZE", + accountDisplayName = AccountDisplayName( + accountAddress = "HZQ73CXUPMVKRB4LNGJAGVZQCFPQDCCPSDZZE", + primaryDisplayName = "HZQ73C...PSDZZE", + secondaryDisplayName = null + ), + accountIconDrawablePreview = jointIcon, + imageUri = null, + signatureStatus = JointAccountSignatureStatus.Signed + ), + JointAccountSignerItem( + accountAddress = "DUA4XLTFPBPWDDCH47SGDNZ5IJ52DFXG7X2N2ETI", + accountDisplayName = AccountDisplayName( + accountAddress = "DUA4XLTFPBPWDDCH47SGDNZ5IJ52DFXG7X2N2ETI", + primaryDisplayName = "tahir.algo", + secondaryDisplayName = "DUA4...2ETI" + ), + accountIconDrawablePreview = jointIcon, + imageUri = null, + signatureStatus = JointAccountSignatureStatus.Signed + ), + JointAccountSignerItem( + accountAddress = "S93KZQHV4XLTFPBPWDDCH47SGNSK2", + accountDisplayName = AccountDisplayName( + accountAddress = "S93KZQHV4XLTFPBPWDDCH47SGNSK2", + primaryDisplayName = "Katie Rochester", + secondaryDisplayName = "S93K...NSK2" + ), + accountIconDrawablePreview = jointIcon, + imageUri = null, + signatureStatus = JointAccountSignatureStatus.Signed + ) + ) + ) +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/viewmodel/DefaultJointAccountTransactionProcessor.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/viewmodel/DefaultJointAccountTransactionProcessor.kt new file mode 100644 index 000000000..10acf217a --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/viewmodel/DefaultJointAccountTransactionProcessor.kt @@ -0,0 +1,173 @@ +/* + * 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.addaccount.joint.transaction.viewmodel + +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountSignatureStatus +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountSignerItem +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountTransactionPreview +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountTransactionState +import javax.inject.Inject + +internal class DefaultJointAccountTransactionProcessor @Inject constructor() : + JointAccountTransactionProcessor { + + override fun validateConfirmTransaction( + preview: JointAccountTransactionPreview, + signRequestId: String? + ): JointAccountTransactionProcessor.ConfirmTransactionData? { + val requestId = signRequestId ?: return null + if (preview.rawTransactions.isEmpty()) return null + + val hasUnsignedLocalAccounts = preview.unsignedLocalParticipantAddresses.isNotEmpty() + val hasUnsignedLedgerAccounts = preview.unsignedLedgerParticipantAddresses.isNotEmpty() + if (!hasUnsignedLocalAccounts && !hasUnsignedLedgerAccounts) return null + + return JointAccountTransactionProcessor.ConfirmTransactionData( + requestId = requestId, + preview = preview, + hasUnsignedLocalAccounts = hasUnsignedLocalAccounts, + hasUnsignedLedgerAccounts = hasUnsignedLedgerAccounts + ) + } + + override fun createUpdatedPreviewAfterSigning( + preview: JointAccountTransactionPreview, + signedAddresses: List + ): JointAccountTransactionPreview { + val newSignedCount = preview.signedCount + signedAddresses.size + val isCompleted = isTransactionCompleted(newSignedCount, preview.requiredSignatureCount) + + return preview.copy( + transactionState = getTransactionStateForCompletion(isCompleted), + signedCount = newSignedCount, + signerAccounts = markSignersAsSigned(preview.signerAccounts, signedAddresses), + hasCurrentUserAlreadySigned = signedAddresses.isNotEmpty() || preview.hasCurrentUserAlreadySigned, + unsignedLocalParticipantAddresses = emptyList() + ) + } + + override fun createLedgerSignData( + signRequestId: String, + rawTransactions: List, + preview: JointAccountTransactionPreview + ): JointAccountTransactionProcessor.LedgerSignData? { + val ledgerSigner = findFirstAvailableLedgerSigner(preview.signerAccounts) ?: return null + val bluetoothAddress = ledgerSigner.ledgerBluetoothAddress ?: return null + val accountIndex = ledgerSigner.ledgerAccountIndex ?: return null + + return JointAccountTransactionProcessor.LedgerSignData( + signRequestId = signRequestId, + accountAddress = ledgerSigner.accountAddress, + rawTransactions = rawTransactions, + ledgerBluetoothAddress = bluetoothAddress, + ledgerAccountIndex = accountIndex + ) + } + + override fun processLoadedPreview(preview: JointAccountTransactionPreview): JointAccountTransactionPreview { + val isCompleted = isTransactionCompleted(preview.signedCount, preview.requiredSignatureCount) + return if (isCompleted) { + preview.copy(transactionState = JointAccountTransactionState.Completed) + } else { + preview + } + } + + override fun findDeclineParticipantAddress(preview: JointAccountTransactionPreview): String? { + return preview.unsignedLocalParticipantAddresses.firstOrNull() + ?: preview.unsignedLedgerParticipantAddresses.firstOrNull() + } + + override fun determinePostSigningAction( + data: JointAccountTransactionProcessor.ConfirmTransactionData, + updatedPreview: JointAccountTransactionPreview, + signRequestId: String? + ): JointAccountTransactionProcessor.PostSigningAction { + val isCompleted = isTransactionCompleted( + updatedPreview.signedCount, + updatedPreview.requiredSignatureCount + ) + + if (!isCompleted && data.hasUnsignedLedgerAccounts && signRequestId != null) { + val ledgerData = createLedgerSignData(signRequestId, data.preview.rawTransactions, updatedPreview) + if (ledgerData != null) { + return JointAccountTransactionProcessor.PostSigningAction.TriggerLedgerSigning(ledgerData) + } + } + return JointAccountTransactionProcessor.PostSigningAction.ShowPendingSignatures + } + + override fun determineLedgerSuccessAction( + preview: JointAccountTransactionPreview, + signRequestId: String? + ): JointAccountTransactionProcessor.PostSigningAction { + val isCompleted = isTransactionCompleted(preview.signedCount, preview.requiredSignatureCount) + val shouldTrigger = shouldTriggerLedgerSigning( + isCompleted = isCompleted, + hasUnsignedLedgerAccounts = preview.unsignedLedgerParticipantAddresses.isNotEmpty(), + signRequestId = signRequestId, + rawTransactions = preview.rawTransactions + ) + + if (shouldTrigger && signRequestId != null) { + val ledgerData = createLedgerSignData(signRequestId, preview.rawTransactions, preview) + if (ledgerData != null) { + return JointAccountTransactionProcessor.PostSigningAction.TriggerLedgerSigning(ledgerData) + } + } + return JointAccountTransactionProcessor.PostSigningAction.ShowPendingSignatures + } + + private fun findFirstAvailableLedgerSigner( + signerAccounts: List + ): JointAccountSignerItem? { + return signerAccounts.firstOrNull { it.canSignWithLedger } + } + + private fun isTransactionCompleted(signedCount: Int, requiredSignatureCount: Int): Boolean { + return signedCount >= requiredSignatureCount + } + + private fun shouldTriggerLedgerSigning( + isCompleted: Boolean, + hasUnsignedLedgerAccounts: Boolean, + signRequestId: String?, + rawTransactions: List + ): Boolean { + return !isCompleted && + hasUnsignedLedgerAccounts && + signRequestId != null && + rawTransactions.isNotEmpty() + } + + private fun markSignersAsSigned( + signerAccounts: List, + signedAddresses: List + ): List { + return signerAccounts.map { signer -> + if (signedAddresses.contains(signer.accountAddress)) { + signer.copy(signatureStatus = JointAccountSignatureStatus.Signed) + } else { + signer + } + } + } + + private fun getTransactionStateForCompletion(isCompleted: Boolean): JointAccountTransactionState { + return if (isCompleted) { + JointAccountTransactionState.Completed + } else { + JointAccountTransactionState.PendingSignatures + } + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/viewmodel/JointAccountTransactionProcessor.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/viewmodel/JointAccountTransactionProcessor.kt new file mode 100644 index 000000000..8bfd470fb --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/viewmodel/JointAccountTransactionProcessor.kt @@ -0,0 +1,69 @@ +/* + * 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.addaccount.joint.transaction.viewmodel + +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountTransactionPreview + +interface JointAccountTransactionProcessor { + + fun validateConfirmTransaction( + preview: JointAccountTransactionPreview, + signRequestId: String? + ): ConfirmTransactionData? + + fun createUpdatedPreviewAfterSigning( + preview: JointAccountTransactionPreview, + signedAddresses: List + ): JointAccountTransactionPreview + + fun createLedgerSignData( + signRequestId: String, + rawTransactions: List, + preview: JointAccountTransactionPreview + ): LedgerSignData? + + fun processLoadedPreview(preview: JointAccountTransactionPreview): JointAccountTransactionPreview + + fun findDeclineParticipantAddress(preview: JointAccountTransactionPreview): String? + + fun determinePostSigningAction( + data: ConfirmTransactionData, + updatedPreview: JointAccountTransactionPreview, + signRequestId: String? + ): PostSigningAction + + fun determineLedgerSuccessAction( + preview: JointAccountTransactionPreview, + signRequestId: String? + ): PostSigningAction + + data class ConfirmTransactionData( + val requestId: String, + val preview: JointAccountTransactionPreview, + val hasUnsignedLocalAccounts: Boolean, + val hasUnsignedLedgerAccounts: Boolean + ) + + data class LedgerSignData( + val signRequestId: String, + val accountAddress: String, + val rawTransactions: List, + val ledgerBluetoothAddress: String, + val ledgerAccountIndex: Int + ) + + sealed interface PostSigningAction { + data class TriggerLedgerSigning(val data: LedgerSignData) : PostSigningAction + data object ShowPendingSignatures : PostSigningAction + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/viewmodel/JointAccountTransactionViewModel.kt b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/viewmodel/JointAccountTransactionViewModel.kt new file mode 100644 index 000000000..fc35bfe38 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/addaccount/joint/transaction/viewmodel/JointAccountTransactionViewModel.kt @@ -0,0 +1,230 @@ +/* + * 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.addaccount.joint.transaction.viewmodel + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.algorand.android.R +import com.algorand.android.modules.addaccount.joint.transaction.domain.usecase.DeclineJointAccountSignRequest +import com.algorand.android.modules.addaccount.joint.transaction.domain.usecase.GetJointAccountTransactionPreview +import com.algorand.android.modules.addaccount.joint.transaction.domain.usecase.SignAndSubmitJointAccountSignature +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountTransactionPreview +import com.algorand.android.modules.addaccount.joint.transaction.model.JointAccountTransactionState +import com.algorand.wallet.inbox.domain.usecase.RefreshInboxCache +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.launch +import javax.inject.Inject + +@HiltViewModel +class JointAccountTransactionViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val stateDelegate: StateDelegate, + private val eventDelegate: EventDelegate, + private val getJointAccountTransactionPreview: GetJointAccountTransactionPreview, + private val declineJointAccountSignRequest: DeclineJointAccountSignRequest, + private val signAndSubmitJointAccountSignature: SignAndSubmitJointAccountSignature, + private val refreshInboxCache: RefreshInboxCache, + private val processor: JointAccountTransactionProcessor +) : ViewModel(), + StateViewModel by stateDelegate, + EventViewModel by eventDelegate { + + private val signRequestId: String? = savedStateHandle.get("signRequestId") + + init { + stateDelegate.setDefaultState(ViewState.Loading) + initializeViewModel() + } + + fun onConfirmTransaction() { + stateDelegate.onState { contentState -> + val validationResult = processor.validateConfirmTransaction(contentState.preview, signRequestId) + if (validationResult == null) { + emitError(R.string.an_error_occurred) + return@onState + } + viewModelScope.launch { + stateDelegate.updateState { ViewState.Loading } + processConfirmTransaction(validationResult) + } + } + } + + fun onCancel() { + stateDelegate.onState { contentState -> + val updatedPreview = contentState.preview.copy( + transactionState = JointAccountTransactionState.Canceled + ) + stateDelegate.updateState { ViewState.Content(updatedPreview) } + } + } + + fun declineSignRequest() { + stateDelegate.onState { contentState -> + val requestId = signRequestId ?: return@onState emitError(R.string.an_error_occurred) + val participantAddress = processor.findDeclineParticipantAddress(contentState.preview) + ?: return@onState emitError(R.string.an_error_occurred) + + viewModelScope.launch { + stateDelegate.updateState { ViewState.Loading } + executeDeclineRequest(requestId, participantAddress, contentState.preview) + } + } + } + + fun onLedgerSignSuccess() { + viewModelScope.launch { + refreshInboxCache() + loadTransactionPreview() + handleLedgerSignSuccessAction() + } + } + + fun onLedgerSignError(errorMessageResId: Int) { + emitError(errorMessageResId) + } + + fun onCopyAddressClick() { + stateDelegate.onState { contentState -> + viewModelScope.launch { + eventDelegate.sendEvent(ViewEvent.CopyAddress(contentState.preview.recipientAddress)) + } + } + } + + private fun initializeViewModel() { + if (!signRequestId.isNullOrBlank()) { + loadTransactionPreview() + } else { + Log.e(TAG, "signRequestId is null or blank") + emitError(R.string.an_error_occurred) + emitNavigateBack() + } + } + + private suspend fun processConfirmTransaction(data: JointAccountTransactionProcessor.ConfirmTransactionData) { + val signedAddresses = signLocalAccounts(data) + if (signedAddresses.isNotEmpty()) refreshInboxCache() + + val updatedPreview = processor.createUpdatedPreviewAfterSigning(data.preview, signedAddresses) + stateDelegate.updateState { ViewState.Content(updatedPreview) } + + handlePostSigningAction(processor.determinePostSigningAction(data, updatedPreview, signRequestId)) + } + + private suspend fun signLocalAccounts( + data: JointAccountTransactionProcessor.ConfirmTransactionData + ): List { + if (!data.hasUnsignedLocalAccounts) return emptyList() + + val signedAddresses = mutableListOf() + data.preview.unsignedLocalParticipantAddresses.forEach { participantAddress -> + signAndSubmitJointAccountSignature( + signRequestId = data.requestId, + participantAddress = participantAddress, + rawTransactions = data.preview.rawTransactions + ).use( + onSuccess = { signedAddresses.add(participantAddress) }, + onFailed = { _, _ -> } + ) + } + return signedAddresses + } + + private suspend fun executeDeclineRequest( + requestId: String, + participantAddress: String, + preview: JointAccountTransactionPreview + ) { + declineJointAccountSignRequest(requestId, participantAddress).use( + onSuccess = { + refreshInboxCache() + emitNavigateBack() + }, + onFailed = { _, _ -> + stateDelegate.updateState { ViewState.Content(preview) } + emitError(R.string.an_error_occurred) + } + ) + } + + private fun loadTransactionPreview() { + viewModelScope.launch { + stateDelegate.updateState { ViewState.Loading } + getJointAccountTransactionPreview(signRequestId!!).use( + onSuccess = { preview -> + val updatedPreview = processor.processLoadedPreview(preview) + stateDelegate.updateState { ViewState.Content(updatedPreview) } + if (updatedPreview.shouldShowPendingSignaturesDirectly) { + eventDelegate.sendEvent(ViewEvent.ShowPendingSignaturesDirectly) + } + }, + onFailed = { exception, code -> + Log.e(TAG, "Failed to load preview: code=$code, exception=$exception") + stateDelegate.updateState { ViewState.Error(R.string.sign_request_not_available) } + } + ) + } + } + + private fun handleLedgerSignSuccessAction() { + stateDelegate.onState { contentState -> + val action = processor.determineLedgerSuccessAction(contentState.preview, signRequestId) + viewModelScope.launch { handlePostSigningAction(action) } + } + } + + private suspend fun handlePostSigningAction(action: JointAccountTransactionProcessor.PostSigningAction) { + when (action) { + is JointAccountTransactionProcessor.PostSigningAction.TriggerLedgerSigning -> { + eventDelegate.sendEvent(ViewEvent.StartLedgerSigning(action.data)) + } + is JointAccountTransactionProcessor.PostSigningAction.ShowPendingSignatures -> { + eventDelegate.sendEvent(ViewEvent.ShowPendingSignaturesBottomSheet) + } + } + } + + private fun emitError(errorMessageResId: Int) { + viewModelScope.launch { eventDelegate.sendEvent(ViewEvent.ShowError(errorMessageResId)) } + } + + private fun emitNavigateBack() { + viewModelScope.launch { eventDelegate.sendEvent(ViewEvent.NavigateBack) } + } + + sealed interface ViewState { + data object Loading : ViewState + data class Content(val preview: JointAccountTransactionPreview) : ViewState + data class Error(val messageResId: Int) : ViewState + } + + sealed interface ViewEvent { + data object NavigateBack : ViewEvent + data class ShowError(val messageResId: Int) : ViewEvent + data object ShowPendingSignaturesBottomSheet : ViewEvent + data object ShowPendingSignaturesDirectly : ViewEvent + data class StartLedgerSigning(val data: JointAccountTransactionProcessor.LedgerSignData) : ViewEvent + data class CopyAddress(val address: String) : ViewEvent + } + + companion object { + private const val TAG = "JointAccountTxVM" + } +} diff --git a/app/src/main/kotlin/com/algorand/android/modules/assetinbox/send/summary/ui/Arc59SendSummaryFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/assetinbox/send/summary/ui/Arc59SendSummaryFragment.kt index 0afccc434..789538b28 100644 --- a/app/src/main/kotlin/com/algorand/android/modules/assetinbox/send/summary/ui/Arc59SendSummaryFragment.kt +++ b/app/src/main/kotlin/com/algorand/android/modules/assetinbox/send/summary/ui/Arc59SendSummaryFragment.kt @@ -135,15 +135,11 @@ class Arc59SendSummaryFragment : BaseFragment(R.layout.fragment_arc59_send_summa super.onViewCreated(view, savedInstanceState) initUi() initObservers() + initSavedStateListener() arc59SendSummaryViewModel.initializePreview() arc59SendTransactionSignManager.setup(viewLifecycleOwner.lifecycle) } - override fun onResume() { - super.onResume() - initSavedStateListener() - } - private fun initUi() { with(binding) { sendButton.setOnClickListener { navToArc59SendWarningBottomSheet() } diff --git a/app/src/main/kotlin/com/algorand/android/modules/inapppin/pin/ui/InAppPinFragment.kt b/app/src/main/kotlin/com/algorand/android/modules/inapppin/pin/ui/InAppPinFragment.kt index c43a8a54d..65dab2b83 100644 --- a/app/src/main/kotlin/com/algorand/android/modules/inapppin/pin/ui/InAppPinFragment.kt +++ b/app/src/main/kotlin/com/algorand/android/modules/inapppin/pin/ui/InAppPinFragment.kt @@ -114,10 +114,6 @@ class InAppPinFragment : BaseFragment(R.layout.fragment_in_app_pin) { activity?.onBackPressedDispatcher?.addCallback(viewLifecycleOwner, onBackPressedCallback) initObservers() initUi() - } - - override fun onResume() { - super.onResume() initSavedStateListener() } 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 new file mode 100644 index 000000000..6a1bb2861 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/JointAccountInvitationDetailFragment.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.jointaccountinvitation.ui + +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 com.algorand.android.ui.compose.theme.PeraTheme +import dagger.hilt.android.AndroidEntryPoint + +// TODO: Implement JointAccountInvitationDetailFragment +@AndroidEntryPoint +class JointAccountInvitationDetailFragment : Fragment() { + + 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") + } + } + } + } +} 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 new file mode 100644 index 000000000..89069f4d2 --- /dev/null +++ b/app/src/main/kotlin/com/algorand/android/modules/inbox/jointaccountinvitation/ui/model/JointAccountInvitationDetailNavArgs.kt @@ -0,0 +1,24 @@ +/* + * 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 android.os.Parcelable +import kotlinx.parcelize.Parcelize + +// TODO: Implement JointAccountInvitationDetailNavArgs +@Parcelize +data class JointAccountInvitationDetailNavArgs( + val accountAddress: String, + val threshold: Int, + val participantAddresses: List +) : Parcelable diff --git a/app/src/main/kotlin/com/algorand/android/ui/accountoptions/AccountOptionsBottomSheet.kt b/app/src/main/kotlin/com/algorand/android/ui/accountoptions/AccountOptionsBottomSheet.kt index 3bb4b7218..dc882c50d 100644 --- a/app/src/main/kotlin/com/algorand/android/ui/accountoptions/AccountOptionsBottomSheet.kt +++ b/app/src/main/kotlin/com/algorand/android/ui/accountoptions/AccountOptionsBottomSheet.kt @@ -182,7 +182,12 @@ class AccountOptionsBottomSheet : DaggerBaseBottomSheet( } private fun onExportShareAccountClick() { - TODO("Implement this") + nav( + AccountOptionsBottomSheetDirections + .actionAccountOptionsBottomSheetToExportShareAccountNavigation( + accountOptionsViewModel.accountAddress + ) + ) } private fun setupRenameAccountButton() { diff --git a/app/src/main/kotlin/com/algorand/android/ui/accountoptions/ExportShareAccountFragment.kt b/app/src/main/kotlin/com/algorand/android/ui/accountoptions/ExportShareAccountFragment.kt index 4c87d0431..2e62b291c 100644 --- a/app/src/main/kotlin/com/algorand/android/ui/accountoptions/ExportShareAccountFragment.kt +++ b/app/src/main/kotlin/com/algorand/android/ui/accountoptions/ExportShareAccountFragment.kt @@ -14,6 +14,7 @@ package com.algorand.android.ui.accountoptions import android.os.Bundle import android.view.View +import androidx.navigation.fragment.navArgs import com.algorand.android.R import com.algorand.android.core.DaggerBaseFragment import com.algorand.android.databinding.FragmentExportShareAccountBinding @@ -43,6 +44,8 @@ class ExportShareAccountFragment : DaggerBaseFragment(R.layout.fragment_export_s private val binding by viewBinding(FragmentExportShareAccountBinding::bind) + private val args: ExportShareAccountFragmentArgs by navArgs() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) getAppToolbar()?.changeTitle(getString(R.string.export_share_account)) @@ -55,7 +58,7 @@ class ExportShareAccountFragment : DaggerBaseFragment(R.layout.fragment_export_s } private fun getExportUrl(): String { - TODO("Implement this") + return "perawallet://joint-account-import?address=${args.accountAddress}" } private fun onCopyUrlClick() { diff --git a/app/src/main/kotlin/com/algorand/android/ui/contacts/ContactInfoFragment.kt b/app/src/main/kotlin/com/algorand/android/ui/contacts/ContactInfoFragment.kt index 0f3063f7e..f799fbe7b 100644 --- a/app/src/main/kotlin/com/algorand/android/ui/contacts/ContactInfoFragment.kt +++ b/app/src/main/kotlin/com/algorand/android/ui/contacts/ContactInfoFragment.kt @@ -73,7 +73,6 @@ class ContactInfoFragment : DaggerBaseFragment(R.layout.fragment_contact_info) { override fun onResume() { super.onResume() - initSavedStateListener() } private fun customizeToolbar() { diff --git a/app/src/main/kotlin/com/algorand/android/ui/send/transferpreview/AssetTransferPreviewFragment.kt b/app/src/main/kotlin/com/algorand/android/ui/send/transferpreview/AssetTransferPreviewFragment.kt index 6fda5f5f2..ad1780358 100644 --- a/app/src/main/kotlin/com/algorand/android/ui/send/transferpreview/AssetTransferPreviewFragment.kt +++ b/app/src/main/kotlin/com/algorand/android/ui/send/transferpreview/AssetTransferPreviewFragment.kt @@ -33,6 +33,7 @@ import com.algorand.android.models.TargetUser import com.algorand.android.models.ToolbarConfiguration import com.algorand.android.models.TransactionSignData import com.algorand.android.modules.accounticon.ui.model.AccountIconDrawablePreview +import com.algorand.android.modules.addaccount.joint.transaction.ui.PendingSignaturesDialogFragment import com.algorand.android.ui.send.shared.AddNoteBottomSheet import com.algorand.android.utils.Event import com.algorand.android.utils.Resource @@ -397,7 +398,8 @@ class AssetTransferPreviewFragment : TransactionSignBaseFragment(R.layout.fragme override fun onJointAccountSignRequestCreated(signRequestId: String) { // Show pending signatures bottom sheet directly instead of navigating to full screen - TODO("Implement this") + val dialog = PendingSignaturesDialogFragment.newInstance(signRequestId) + dialog.show(childFragmentManager, PendingSignaturesDialogFragment.TAG) } companion object { diff --git a/app/src/main/res/navigation/account_options_navigation.xml b/app/src/main/res/navigation/account_options_navigation.xml index 63f969190..0e5a4f1f2 100644 --- a/app/src/main/res/navigation/account_options_navigation.xml +++ b/app/src/main/res/navigation/account_options_navigation.xml @@ -35,6 +35,8 @@ + + + + + + diff --git a/app/src/main/res/navigation/asset_inbox_all_accounts_navigation.xml b/app/src/main/res/navigation/asset_inbox_all_accounts_navigation.xml index ec683e639..70a5476b3 100644 --- a/app/src/main/res/navigation/asset_inbox_all_accounts_navigation.xml +++ b/app/src/main/res/navigation/asset_inbox_all_accounts_navigation.xml @@ -15,26 +15,36 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/assetInboxAllAccountsNavigation" - app:startDestination="@id/assetInboxAllAccountsFragment"> + app:startDestination="@id/inboxFragment"> + + diff --git a/app/src/main/res/navigation/export_share_account_navigation.xml b/app/src/main/res/navigation/export_share_account_navigation.xml new file mode 100644 index 000000000..b263e546e --- /dev/null +++ b/app/src/main/res/navigation/export_share_account_navigation.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/app/src/main/res/navigation/home_navigation.xml b/app/src/main/res/navigation/home_navigation.xml index ea1eb6d8a..0fc532709 100644 --- a/app/src/main/res/navigation/home_navigation.xml +++ b/app/src/main/res/navigation/home_navigation.xml @@ -292,6 +292,30 @@ app:argType="boolean" /> + + + + + + + @@ -1082,6 +1106,22 @@ android:name="assetInboxOneAccountNavArgs" app:argType="com.algorand.android.modules.assetinbox.assetinboxoneaccount.ui.model.AssetInboxOneAccountNavArgs" /> + + + + + + @@ -1690,6 +1730,7 @@ android:id="@+id/overrideFeatureFlagsFragment" android:name="com.algorand.android.ui.settings.developeroptions.featureflags.view.OverrideFeatureFlagsFragment" android:label="OverrideFeatureFlagsFragment" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/login_navigation.xml b/app/src/main/res/navigation/login_navigation.xml index 73fe41339..342d2e8bf 100644 --- a/app/src/main/res/navigation/login_navigation.xml +++ b/app/src/main/res/navigation/login_navigation.xml @@ -34,7 +34,12 @@ android:id="@+id/action_global_to_homeNavigation" app:destination="@id/homeNavigation" app:popUpTo="@id/mainNavigation" - app:popUpToInclusive="true" /> + app:popUpToInclusive="true"> + + + android:name="com.algorand.android.modules.addaccount.intro.view.AddAccountIntroFragment" + android:label="AddAccountIntroFragment"> @@ -87,6 +92,80 @@ app:argType="com.algorand.android.models.AccountCreation" app:nullable="true" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +} diff --git a/common-sdk/src/main/kotlin/com/algorand/wallet/inbox/domain/usecase/DeleteInboxJointInvitationNotificationUseCase.kt b/common-sdk/src/main/kotlin/com/algorand/wallet/inbox/domain/usecase/DeleteInboxJointInvitationNotificationUseCase.kt new file mode 100644 index 000000000..ee180a985 --- /dev/null +++ b/common-sdk/src/main/kotlin/com/algorand/wallet/inbox/domain/usecase/DeleteInboxJointInvitationNotificationUseCase.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.wallet.inbox.domain.usecase + +import com.algorand.wallet.foundation.PeraResult +import com.algorand.wallet.inbox.domain.repository.InboxApiRepository +import javax.inject.Inject + +internal class DeleteInboxJointInvitationNotificationUseCase @Inject constructor( + private val inboxApiRepository: InboxApiRepository +) : DeleteInboxJointInvitationNotification { + + override suspend fun invoke(deviceId: Long, jointAddress: String): PeraResult { + return inboxApiRepository.deleteJointInvitationNotification(deviceId, jointAddress) + } +} diff --git a/common-sdk/src/main/kotlin/com/algorand/wallet/inbox/domain/usecase/FetchInboxMessages.kt b/common-sdk/src/main/kotlin/com/algorand/wallet/inbox/domain/usecase/FetchInboxMessages.kt new file mode 100644 index 000000000..bce859eb6 --- /dev/null +++ b/common-sdk/src/main/kotlin/com/algorand/wallet/inbox/domain/usecase/FetchInboxMessages.kt @@ -0,0 +1,23 @@ +/* + * 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.wallet.inbox.domain.usecase + +import com.algorand.wallet.foundation.PeraResult +import com.algorand.wallet.inbox.domain.model.InboxMessages + +fun interface FetchInboxMessages { + suspend operator fun invoke( + deviceId: Long, + addresses: List + ): PeraResult +} diff --git a/common-sdk/src/main/kotlin/com/algorand/wallet/inbox/domain/usecase/FetchInboxMessagesUseCase.kt b/common-sdk/src/main/kotlin/com/algorand/wallet/inbox/domain/usecase/FetchInboxMessagesUseCase.kt new file mode 100644 index 000000000..bc1a6a89a --- /dev/null +++ b/common-sdk/src/main/kotlin/com/algorand/wallet/inbox/domain/usecase/FetchInboxMessagesUseCase.kt @@ -0,0 +1,28 @@ +/* + * 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.wallet.inbox.domain.usecase + +import com.algorand.wallet.foundation.PeraResult +import com.algorand.wallet.inbox.domain.model.InboxMessages +import com.algorand.wallet.inbox.domain.model.InboxSearchInput +import com.algorand.wallet.inbox.domain.repository.InboxApiRepository +import javax.inject.Inject + +internal class FetchInboxMessagesUseCase @Inject constructor( + private val inboxApiRepository: InboxApiRepository +) : FetchInboxMessages { + + override suspend fun invoke(deviceId: Long, addresses: List): PeraResult { + return inboxApiRepository.getInboxMessages(deviceId, InboxSearchInput(addresses)) + } +} diff --git a/common-sdk/src/main/kotlin/com/algorand/wallet/jointaccount/creation/domain/usecase/CreateJointAccount.kt b/common-sdk/src/main/kotlin/com/algorand/wallet/jointaccount/creation/domain/usecase/CreateJointAccount.kt new file mode 100644 index 000000000..cbefa4b0c --- /dev/null +++ b/common-sdk/src/main/kotlin/com/algorand/wallet/jointaccount/creation/domain/usecase/CreateJointAccount.kt @@ -0,0 +1,24 @@ +/* + * 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.wallet.jointaccount.creation.domain.usecase + +import com.algorand.wallet.foundation.PeraResult +import com.algorand.wallet.jointaccount.creation.domain.model.JointAccount + +fun interface CreateJointAccount { + suspend operator fun invoke( + participantAddresses: List, + threshold: Int, + version: Int + ): PeraResult +} diff --git a/common-sdk/src/main/kotlin/com/algorand/wallet/jointaccount/creation/domain/usecase/CreateJointAccountUseCase.kt b/common-sdk/src/main/kotlin/com/algorand/wallet/jointaccount/creation/domain/usecase/CreateJointAccountUseCase.kt new file mode 100644 index 000000000..9b372c1bd --- /dev/null +++ b/common-sdk/src/main/kotlin/com/algorand/wallet/jointaccount/creation/domain/usecase/CreateJointAccountUseCase.kt @@ -0,0 +1,39 @@ +/* + * 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.wallet.jointaccount.creation.domain.usecase + +import com.algorand.wallet.foundation.PeraResult +import com.algorand.wallet.jointaccount.creation.domain.model.CreateJointAccountInput +import com.algorand.wallet.jointaccount.creation.domain.model.JointAccount +import com.algorand.wallet.jointaccount.domain.repository.JointAccountRepository +import javax.inject.Inject +import javax.inject.Named + +internal class CreateJointAccountUseCase @Inject constructor( + @param:Named(JointAccountRepository.INJECTION_NAME) + private val jointAccountRepository: JointAccountRepository +) : CreateJointAccount { + + override suspend fun invoke( + participantAddresses: List, + threshold: Int, + version: Int + ): PeraResult { + val input = CreateJointAccountInput( + participantAddresses = participantAddresses, + threshold = threshold, + version = version + ) + return jointAccountRepository.createJointAccount(input) + } +} diff --git a/common-sdk/src/main/kotlin/com/algorand/wallet/jointaccount/di/JointAccountModule.kt b/common-sdk/src/main/kotlin/com/algorand/wallet/jointaccount/di/JointAccountModule.kt index f66c80d0c..be31cc740 100644 --- a/common-sdk/src/main/kotlin/com/algorand/wallet/jointaccount/di/JointAccountModule.kt +++ b/common-sdk/src/main/kotlin/com/algorand/wallet/jointaccount/di/JointAccountModule.kt @@ -12,14 +12,20 @@ package com.algorand.wallet.jointaccount.di +import com.algorand.wallet.account.local.domain.repository.JointAccountRepository as LocalJointAccountRepository +import com.algorand.wallet.inbox.domain.usecase.DeleteInboxJointInvitationNotification +import com.algorand.wallet.inbox.domain.usecase.DeleteInboxJointInvitationNotificationUseCase +import com.algorand.wallet.inbox.domain.usecase.FetchInboxMessages +import com.algorand.wallet.inbox.domain.usecase.FetchInboxMessagesUseCase import com.algorand.wallet.jointaccount.creation.data.mapper.CreateJointAccountDTOMapper import com.algorand.wallet.jointaccount.creation.data.mapper.CreateJointAccountDTOMapperImpl import com.algorand.wallet.jointaccount.creation.data.mapper.JointAccountDTOMapper import com.algorand.wallet.jointaccount.creation.data.mapper.JointAccountDTOMapperImpl +import com.algorand.wallet.jointaccount.creation.domain.usecase.CreateJointAccount +import com.algorand.wallet.jointaccount.creation.domain.usecase.CreateJointAccountUseCase import com.algorand.wallet.jointaccount.data.repository.JointAccountRepositoryImpl import com.algorand.wallet.jointaccount.data.service.JointAccountApiService import com.algorand.wallet.jointaccount.domain.repository.JointAccountRepository -import com.algorand.wallet.account.local.domain.repository.JointAccountRepository as LocalJointAccountRepository import com.algorand.wallet.jointaccount.domain.usecase.GetJointAccount import com.algorand.wallet.jointaccount.domain.usecase.GetJointAccountParticipantCount import com.algorand.wallet.jointaccount.domain.usecase.GetJointAccountProposerAddress @@ -108,4 +114,19 @@ internal object JointAccountModule { fun provideGetJointAccountParticipantCount( repository: LocalJointAccountRepository ): GetJointAccountParticipantCount = GetJointAccountParticipantCount(repository::getParticipantCount) + + @Provides + fun provideCreateJointAccount( + useCase: CreateJointAccountUseCase + ): CreateJointAccount = useCase + + @Provides + fun provideFetchInboxMessages( + useCase: FetchInboxMessagesUseCase + ): FetchInboxMessages = useCase + + @Provides + fun provideDeleteInboxJointInvitationNotification( + useCase: DeleteInboxJointInvitationNotificationUseCase + ): DeleteInboxJointInvitationNotification = useCase } diff --git a/common-sdk/src/main/kotlin/com/algorand/wallet/jointaccount/domain/repository/JointAccountRepository.kt b/common-sdk/src/main/kotlin/com/algorand/wallet/jointaccount/domain/repository/JointAccountRepository.kt index e6fce266a..bca72e40c 100644 --- a/common-sdk/src/main/kotlin/com/algorand/wallet/jointaccount/domain/repository/JointAccountRepository.kt +++ b/common-sdk/src/main/kotlin/com/algorand/wallet/jointaccount/domain/repository/JointAccountRepository.kt @@ -20,7 +20,7 @@ import com.algorand.wallet.jointaccount.transaction.domain.model.CreateSignReque import com.algorand.wallet.jointaccount.transaction.domain.model.JointSignRequest import com.algorand.wallet.jointaccount.transaction.domain.model.SignRequestWithFullSignature -interface JointAccountRepository { +internal interface JointAccountRepository { suspend fun createJointAccount( createJointAccount: CreateJointAccountInput