diff --git a/.gitignore b/.gitignore index bda94ab75..25f01562c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,33 @@ local.properties release opencode.json .kotlin/ + +# AI coding tool config (keep local, never commit) +.agents/ +.claude/ +.codebuddy/ +.commandcode/ +.continue/ +.crush/ +.factory/ +.junie/ +.kilocode/ +.kiro/ +.kode/ +.mcpjam/ +.mux/ +.neovate/ +.openhands/ .opencode/ +.pi/ +.pochi/ +.qoder/ +.qwen/ +.roo/ +.trae/ +.windsurf/ +.zencoder/ +.goosehints +.impeccable.md +skills/ +skills-lock.json diff --git a/.goosehints b/.goosehints deleted file mode 100644 index 8ce437dcc..000000000 --- a/.goosehints +++ /dev/null @@ -1,7 +0,0 @@ -- always compile the project after you're done to make sure that everything still works. -- reuse as much code as you can, avoid code duplication. -- if files get too long (longer than 300ish lines) tend to create new files when you write code with a new responsibility. we want to keep the files relatively short and keep the code easier to maintain. -- design all features so that it looks incredibly good, Apple-like high quality app design with great UX and professional and useful design that makes it easy to use as a person working at a checkout. Apple-like UI design patterns. -- unless for logging purposes, avoid the adding hard-coded string literals to the codebase: create localized labels instead. -- always provide documentation for any and all new or modified methods and classes -- do not use FQN (Fully Qualified Names). always prefer imports on top of the file and -if necessary- aliases. \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b6baa1925..7ca92098c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,6 @@ plugins { id("com.android.application") - id("org.jetbrains.kotlin.android") - id("kotlin-parcelize") + id("org.jetbrains.kotlin.plugin.parcelize") id("jacoco") } @@ -50,8 +49,8 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" + kotlin { + jvmToolchain(17) } buildFeatures { @@ -114,7 +113,7 @@ dependencies { androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") // Project specific dependencies - implementation("org.bouncycastle:bcprov-jdk15on:1.70") + implementation("org.bouncycastle:bcprov-jdk18on:1.80") implementation("com.squareup.okhttp3:okhttp:4.12.0") diff --git a/app/src/main/java/com/electricdreams/numo/PaymentFailureActivity.kt b/app/src/main/java/com/electricdreams/numo/PaymentFailureActivity.kt index 6c0b0aded..23c35ea16 100644 --- a/app/src/main/java/com/electricdreams/numo/PaymentFailureActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/PaymentFailureActivity.kt @@ -59,7 +59,7 @@ class PaymentFailureActivity : AppCompatActivity() { window.isStatusBarContrastEnforced = false } - val backgroundColor = ContextCompat.getColor(this, R.color.color_error) + val backgroundColor = ContextCompat.getColor(this, R.color.color_bg_white) window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(backgroundColor)) val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) diff --git a/app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt b/app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt index 6872db0c2..9169620c3 100644 --- a/app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt @@ -175,33 +175,35 @@ class PaymentRequestActivity : AppCompatActivity() { window.isNavigationBarContrastEnforced = false window.isStatusBarContrastEnforced = false } - - // Apply window insets to handle edge-to-edge correctly without squishing the NFC overlay + + // Apply window insets to handle edge-to-edge correctly without squishing the NFC overlay. + // The root itself is not padded — individual chrome views have their margins adjusted + // so the NFC animation container stays full-bleed edge-to-edge. ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.payment_request_root)) { v, windowInsets -> val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - + val density = resources.displayMetrics.density val topMarginPx = (16 * density).toInt() val bottomMarginPx = (24 * density).toInt() - findViewById(R.id.close_button).layoutParams = + findViewById(R.id.close_button).layoutParams = (findViewById(R.id.close_button).layoutParams as MarginLayoutParams).apply { topMargin = insets.top + topMarginPx } - - findViewById(R.id.share_button).layoutParams = + + findViewById(R.id.share_button).layoutParams = (findViewById(R.id.share_button).layoutParams as MarginLayoutParams).apply { topMargin = insets.top + topMarginPx } val switchContainer = findViewById(R.id.lightning_cashu_switch_container) if (switchContainer != null) { - switchContainer.layoutParams = + switchContainer.layoutParams = (switchContainer.layoutParams as MarginLayoutParams).apply { bottomMargin = insets.bottom + bottomMarginPx } } - + windowInsets } diff --git a/app/src/main/java/com/electricdreams/numo/feature/autowithdraw/AutoWithdrawSettingsActivity.kt b/app/src/main/java/com/electricdreams/numo/feature/autowithdraw/AutoWithdrawSettingsActivity.kt index 49d6edaeb..2ffd857f0 100644 --- a/app/src/main/java/com/electricdreams/numo/feature/autowithdraw/AutoWithdrawSettingsActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/feature/autowithdraw/AutoWithdrawSettingsActivity.kt @@ -2,7 +2,6 @@ package com.electricdreams.numo.feature.autowithdraw import android.animation.AnimatorSet import android.animation.ObjectAnimator -import android.animation.ValueAnimator import android.content.Intent import android.os.Bundle import android.text.Editable @@ -33,6 +32,8 @@ import com.electricdreams.numo.core.cashu.CashuWalletManager import com.electricdreams.numo.core.model.Amount import com.electricdreams.numo.core.util.MintManager import com.electricdreams.numo.feature.settings.WithdrawLightningActivity +import com.electricdreams.numo.ui.components.EmptyStateHelper +import com.electricdreams.numo.ui.components.LightningStrikeView import com.electricdreams.numo.ui.components.MintSelectionBottomSheet import com.electricdreams.numo.ui.util.DialogHelper import com.google.android.material.slider.Slider @@ -56,12 +57,15 @@ class AutoWithdrawSettingsActivity : AppCompatActivity() { private lateinit var autoWithdrawManager: AutoWithdrawManager // Hero section - private lateinit var heroIcon: ImageView - private lateinit var heroIconContainer: FrameLayout + private lateinit var heroBg: FrameLayout + private lateinit var heroBolt: ImageView + private lateinit var heroBoltFade: View private lateinit var statusContainer: LinearLayout private lateinit var statusDot: View private lateinit var statusText: TextView + // Toggle icon + // Settings controls private lateinit var enableSwitch: SwitchCompat private lateinit var enableToggleRow: LinearLayout @@ -72,10 +76,13 @@ class AutoWithdrawSettingsActivity : AppCompatActivity() { // History section private lateinit var historyCard: CardView - private lateinit var historyEmptyContainer: LinearLayout + private lateinit var historyEmptyContainer: View private lateinit var historyRecyclerView: RecyclerView private lateinit var seeAllButton: TextView + // Auto-withdraw config container (Destination + Trigger Settings) + private lateinit var configContainer: LinearLayout + // Manual withdraw private lateinit var manualWithdrawRow: LinearLayout @@ -83,7 +90,6 @@ class AutoWithdrawSettingsActivity : AppCompatActivity() { private lateinit var mintManager: MintManager private var isUpdatingUI = false - private var iconAnimator: ObjectAnimator? = null // Current threshold value (in sats) private var currentThreshold: Long = AutoWithdrawSettingsManager.DEFAULT_THRESHOLD_SATS @@ -103,24 +109,23 @@ class AutoWithdrawSettingsActivity : AppCompatActivity() { setupListeners() loadSettings() loadHistory() - - // Start entrance animations - startEntranceAnimations() } private fun initViews() { - // Back button - use standard chevron pattern for consistency - findViewById(R.id.back_button).setOnClickListener { + findViewById(R.id.top_bar).onNavClick { onBackPressedDispatcher.onBackPressed() } // Hero section - heroIcon = findViewById(R.id.hero_icon) - heroIconContainer = findViewById(R.id.icon_container) + heroBg = findViewById(R.id.hero_bg) + heroBolt = findViewById(R.id.hero_bolt) + heroBoltFade = findViewById(R.id.hero_bolt_fade) statusContainer = findViewById(R.id.status_container) statusDot = findViewById(R.id.status_dot) statusText = findViewById(R.id.status_text) + // Toggle icon + // Main toggle enableSwitch = findViewById(R.id.enable_switch) enableToggleRow = findViewById(R.id.enable_toggle_row) @@ -137,6 +142,9 @@ class AutoWithdrawSettingsActivity : AppCompatActivity() { historyRecyclerView = findViewById(R.id.history_recycler_view) seeAllButton = findViewById(R.id.see_all_button) + // Config container (Destination + Trigger Settings) + configContainer = findViewById(R.id.auto_withdraw_config_container) + // Manual withdraw manualWithdrawRow = findViewById(R.id.manual_withdraw_row) @@ -154,8 +162,12 @@ class AutoWithdrawSettingsActivity : AppCompatActivity() { if (!isUpdatingUI) { settingsManager.setGloballyEnabled(isChecked) updateStatusIndicator(isChecked) - updateConfigFieldsEnabled(isChecked) + updateHeroGradient(isChecked, animate = true) + animateConfigContainer(isChecked) animateStatusChange(isChecked) + if (isChecked) { + playLightningStrike() + } } } @@ -284,7 +296,8 @@ class AutoWithdrawSettingsActivity : AppCompatActivity() { val enabled = settingsManager.isGloballyEnabled() enableSwitch.isChecked = enabled updateStatusIndicator(enabled) - updateConfigFieldsEnabled(enabled) + updateHeroGradient(enabled, animate = false) + configContainer.visibility = if (enabled) View.VISIBLE else View.GONE lightningAddressInput.setText(settingsManager.getDefaultLightningAddress()) @@ -308,10 +321,31 @@ class AutoWithdrawSettingsActivity : AppCompatActivity() { statusDot.backgroundTintList = ContextCompat.getColorStateList(this, R.color.color_text_tertiary) statusText.text = getString(R.string.auto_withdraw_status_inactive) statusText.setTextColor(ContextCompat.getColor(this, R.color.color_text_tertiary)) - statusContainer.background = ContextCompat.getDrawable(this, R.drawable.bg_input_pill) + statusContainer.background = ContextCompat.getDrawable(this, R.drawable.bg_pill_badge) } } + private fun updateHeroGradient(enabled: Boolean, animate: Boolean) { + val gradientRes = if (enabled) R.drawable.bg_hero_gradient_active else R.drawable.bg_hero_gradient_green + val fadeRes = if (enabled) R.drawable.bg_hero_bolt_fade_active else R.drawable.bg_hero_bolt_fade_green + + heroBg.setBackgroundResource(gradientRes) + val boltColor = if (enabled) R.color.color_bitcoin_orange else R.color.color_success_green + heroBolt.setColorFilter(ContextCompat.getColor(this, boltColor)) + heroBoltFade.setBackgroundResource(fadeRes) + + } + + private fun playLightningStrike() { + val root = findViewById(R.id.root_layout) + val strike = LightningStrikeView(this) + root.addView(strike, ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )) + strike.strike() + } + private fun animateStatusChange(enabled: Boolean) { // Pulse animation on status container val scaleX = ObjectAnimator.ofFloat(statusContainer, "scaleX", 1f, 1.1f, 1f) @@ -324,50 +358,43 @@ class AutoWithdrawSettingsActivity : AppCompatActivity() { start() } - // Icon pulse - if (enabled) { - startIconPulseAnimation() - } else { - stopIconPulseAnimation() - } } - private fun startIconPulseAnimation() { - iconAnimator?.cancel() - - iconAnimator = ObjectAnimator.ofFloat(heroIconContainer, "alpha", 1f, 0.6f, 1f).apply { - duration = 1500 - repeatCount = ValueAnimator.INFINITE - interpolator = AccelerateDecelerateInterpolator() - start() + private fun animateConfigContainer(show: Boolean) { + if (show) { + configContainer.visibility = View.VISIBLE + configContainer.alpha = 0f + configContainer.translationY = -20f + configContainer.animate() + .alpha(1f) + .translationY(0f) + .setDuration(250) + .setInterpolator(AccelerateDecelerateInterpolator()) + .start() + } else { + configContainer.animate() + .alpha(0f) + .translationY(-20f) + .setDuration(200) + .setInterpolator(AccelerateDecelerateInterpolator()) + .withEndAction { + configContainer.visibility = View.GONE + } + .start() } } - private fun stopIconPulseAnimation() { - iconAnimator?.cancel() - heroIconContainer.alpha = 1f - } - - private fun updateConfigFieldsEnabled(enabled: Boolean) { - val alpha = if (enabled) 1f else 0.5f - - // Animate alpha change - lightningAddressInput.animate().alpha(alpha).setDuration(200).start() - thresholdDisplay.animate().alpha(alpha).setDuration(200).start() - percentageSlider.animate().alpha(alpha).setDuration(200).start() - percentageBadge.animate().alpha(alpha).setDuration(200).start() - - lightningAddressInput.isEnabled = enabled - thresholdDisplay.isEnabled = enabled - thresholdDisplay.isClickable = enabled - percentageSlider.isEnabled = enabled - } - private fun loadHistory() { val history = autoWithdrawManager.getHistory() if (history.isEmpty()) { historyEmptyContainer.visibility = View.VISIBLE + EmptyStateHelper.bind( + historyEmptyContainer, + R.drawable.ic_history, + getString(R.string.auto_withdraw_history_empty_title), + getString(R.string.auto_withdraw_history_empty_subtitle) + ) historyRecyclerView.visibility = View.GONE seeAllButton.visibility = View.GONE } else { @@ -381,62 +408,6 @@ class AutoWithdrawSettingsActivity : AppCompatActivity() { } } - private fun startEntranceAnimations() { - // Hero card slide in - val heroCard: CardView = findViewById(R.id.hero_card) - heroCard.alpha = 0f - heroCard.translationY = -50f - heroCard.animate() - .alpha(1f) - .translationY(0f) - .setDuration(400) - .setInterpolator(AccelerateDecelerateInterpolator()) - .start() - - // Icon bounce - heroIconContainer.scaleX = 0f - heroIconContainer.scaleY = 0f - heroIconContainer.animate() - .scaleX(1f) - .scaleY(1f) - .setStartDelay(200) - .setDuration(500) - .setInterpolator(OvershootInterpolator(2f)) - .start() - - // Status pill fade - statusContainer.alpha = 0f - statusContainer.animate() - .alpha(1f) - .setStartDelay(400) - .setDuration(300) - .start() - - // Cards stagger in - val toggleCard: CardView = findViewById(R.id.toggle_card) - animateCardEntrance(toggleCard, 100) - - val manualWithdrawCard: CardView = findViewById(R.id.manual_withdraw_card) - animateCardEntrance(manualWithdrawCard, 200) - - // If auto-withdraw is enabled, start icon animation - if (settingsManager.isGloballyEnabled()) { - heroIconContainer.postDelayed({ startIconPulseAnimation() }, 800) - } - } - - private fun animateCardEntrance(card: View, delay: Long) { - card.alpha = 0f - card.translationY = 30f - card.animate() - .alpha(1f) - .translationY(0f) - .setStartDelay(delay) - .setDuration(350) - .setInterpolator(AccelerateDecelerateInterpolator()) - .start() - } - override fun onResume() { super.onResume() // Refresh history when returning to activity (e.g., after completing a withdrawal) @@ -445,7 +416,6 @@ class AutoWithdrawSettingsActivity : AppCompatActivity() { override fun onDestroy() { super.onDestroy() - iconAnimator?.cancel() } /** @@ -461,6 +431,7 @@ class AutoWithdrawSettingsActivity : AppCompatActivity() { inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { val iconContainer: FrameLayout = view.findViewById(R.id.icon_container) val statusIcon: ImageView = view.findViewById(R.id.status_icon) + val statusBadgeIcon: FrameLayout = view.findViewById(R.id.status_badge_icon_container) val amountText: TextView = view.findViewById(R.id.amount_text) val addressText: TextView = view.findViewById(R.id.address_text) val mintText: TextView = view.findViewById(R.id.mint_text) @@ -504,19 +475,18 @@ class AutoWithdrawSettingsActivity : AppCompatActivity() { // Status styling when (entry.status) { WithdrawHistoryEntry.STATUS_COMPLETED -> { - holder.statusIcon.setImageResource(R.drawable.ic_check) - holder.statusIcon.setColorFilter(ContextCompat.getColor(this@AutoWithdrawSettingsActivity, R.color.color_success_green)) - holder.iconContainer.backgroundTintList = ContextCompat.getColorStateList(this@AutoWithdrawSettingsActivity, R.color.color_bg_secondary) - holder.statusBadge.text = getString(R.string.auto_withdraw_status_completed) - holder.statusBadge.setTextColor(ContextCompat.getColor(this@AutoWithdrawSettingsActivity, R.color.color_success_green)) - holder.statusBadge.background = ContextCompat.getDrawable(this@AutoWithdrawSettingsActivity, R.drawable.bg_status_pill_success) + holder.statusIcon.setImageResource(R.drawable.ic_arrow_up_send) + holder.statusIcon.setColorFilter(ContextCompat.getColor(this@AutoWithdrawSettingsActivity, R.color.color_text_primary)) + holder.statusBadgeIcon.visibility = View.VISIBLE + holder.statusBadge.visibility = View.GONE holder.expandIndicator.visibility = View.GONE holder.errorContainer.visibility = View.GONE } WithdrawHistoryEntry.STATUS_PENDING -> { holder.statusIcon.setImageResource(R.drawable.ic_pending) holder.statusIcon.setColorFilter(ContextCompat.getColor(this@AutoWithdrawSettingsActivity, R.color.color_warning)) - holder.iconContainer.backgroundTintList = ContextCompat.getColorStateList(this@AutoWithdrawSettingsActivity, R.color.color_bg_secondary) + holder.statusBadgeIcon.visibility = View.GONE + holder.statusBadge.visibility = View.VISIBLE holder.statusBadge.text = getString(R.string.auto_withdraw_status_pending) holder.statusBadge.setTextColor(ContextCompat.getColor(this@AutoWithdrawSettingsActivity, R.color.color_warning)) holder.statusBadge.background = ContextCompat.getDrawable(this@AutoWithdrawSettingsActivity, R.drawable.bg_status_pill_pending) @@ -526,7 +496,8 @@ class AutoWithdrawSettingsActivity : AppCompatActivity() { WithdrawHistoryEntry.STATUS_FAILED -> { holder.statusIcon.setImageResource(R.drawable.ic_close) holder.statusIcon.setColorFilter(ContextCompat.getColor(this@AutoWithdrawSettingsActivity, R.color.color_error)) - holder.iconContainer.backgroundTintList = ContextCompat.getColorStateList(this@AutoWithdrawSettingsActivity, R.color.color_bg_secondary) + holder.statusBadgeIcon.visibility = View.GONE + holder.statusBadge.visibility = View.VISIBLE holder.statusBadge.text = getString(R.string.auto_withdraw_status_failed) holder.statusBadge.setTextColor(ContextCompat.getColor(this@AutoWithdrawSettingsActivity, R.color.color_error)) holder.statusBadge.background = ContextCompat.getDrawable(this@AutoWithdrawSettingsActivity, R.drawable.bg_status_pill_error) @@ -589,13 +560,6 @@ class AutoWithdrawSettingsActivity : AppCompatActivity() { holder.expandIndicator.setImageResource(R.drawable.ic_chevron_down) } - // Animate item appearance - holder.itemView.alpha = 0f - holder.itemView.animate() - .alpha(1f) - .setStartDelay((position * 50).toLong()) - .setDuration(200) - .start() } private fun toggleExpand(entryId: String, holder: ViewHolder) { diff --git a/app/src/main/java/com/electricdreams/numo/feature/baskets/BasketArchiveActivity.kt b/app/src/main/java/com/electricdreams/numo/feature/baskets/BasketArchiveActivity.kt index e4db7a756..af56bffbd 100644 --- a/app/src/main/java/com/electricdreams/numo/feature/baskets/BasketArchiveActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/feature/baskets/BasketArchiveActivity.kt @@ -60,7 +60,7 @@ class BasketArchiveActivity : AppCompatActivity() { } private fun initializeViews() { - findViewById(R.id.back_button).setOnClickListener { finish() } + findViewById(R.id.top_bar).onNavClick { finish() } archiveRecyclerView = findViewById(R.id.archive_recycler_view) emptyView = findViewById(R.id.empty_view) @@ -128,7 +128,6 @@ class BasketArchiveActivity : AppCompatActivity() { title = getString(R.string.basket_archive_delete_title), message = getString(R.string.basket_archive_delete_message, displayName), confirmText = getString(R.string.common_delete), - cancelText = getString(R.string.common_cancel), isDestructive = true, onConfirm = { savedBasketManager.deleteArchivedBasket(basket.id) diff --git a/app/src/main/java/com/electricdreams/numo/feature/baskets/BasketArchiveAdapter.kt b/app/src/main/java/com/electricdreams/numo/feature/baskets/BasketArchiveAdapter.kt index de56cc185..ce660cd6e 100644 --- a/app/src/main/java/com/electricdreams/numo/feature/baskets/BasketArchiveAdapter.kt +++ b/app/src/main/java/com/electricdreams/numo/feature/baskets/BasketArchiveAdapter.kt @@ -5,6 +5,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageButton import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.electricdreams.numo.R import com.electricdreams.numo.core.model.SavedBasket @@ -84,7 +85,23 @@ class BasketArchiveAdapter( override fun getItemCount(): Int = baskets.size fun updateBaskets(newBaskets: List) { + val oldBaskets = baskets + val diffResult = DiffUtil.calculateDiff(BasketDiffCallback(oldBaskets, newBaskets)) baskets = newBaskets - notifyDataSetChanged() + diffResult.dispatchUpdatesTo(this) + } + + private class BasketDiffCallback( + private val oldList: List, + private val newList: List + ) : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldList.size + override fun getNewListSize(): Int = newList.size + + override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean = + oldList[oldPos].id == newList[newPos].id + + override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean = + oldList[oldPos] == newList[newPos] } } diff --git a/app/src/main/java/com/electricdreams/numo/feature/baskets/BasketNamesSettingsActivity.kt b/app/src/main/java/com/electricdreams/numo/feature/baskets/BasketNamesSettingsActivity.kt index ac41d2770..0a4574ce5 100644 --- a/app/src/main/java/com/electricdreams/numo/feature/baskets/BasketNamesSettingsActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/feature/baskets/BasketNamesSettingsActivity.kt @@ -12,7 +12,9 @@ import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import com.electricdreams.numo.ui.util.DialogHelper import com.electricdreams.numo.R +import com.electricdreams.numo.ui.components.EmptyStateHelper import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -33,7 +35,7 @@ class BasketNamesSettingsActivity : AppCompatActivity() { private lateinit var namesHeader: TextView private lateinit var namesCard: LinearLayout private lateinit var namesList: LinearLayout - private lateinit var emptyState: LinearLayout + private lateinit var emptyState: View private lateinit var addNameButton: View private lateinit var clearAllButton: View @@ -54,8 +56,7 @@ class BasketNamesSettingsActivity : AppCompatActivity() { } private fun initViews() { - // Back button - findViewById(R.id.back_button).setOnClickListener { finish() } + findViewById(R.id.top_bar).onNavClick { finish() } // Container views namesContainer = findViewById(R.id.names_container) @@ -83,7 +84,15 @@ class BasketNamesSettingsActivity : AppCompatActivity() { namesHeader.visibility = View.GONE namesCard.visibility = View.GONE emptyState.visibility = View.VISIBLE + EmptyStateHelper.bind( + emptyState, + R.drawable.ic_label, + getString(R.string.basket_names_settings_empty_title), + getString(R.string.basket_names_settings_empty_subtitle), + "+ Add Name" + ) { showAddNameDialog() } clearAllButton.visibility = View.GONE + addNameButton.visibility = View.GONE } else { // Show names list namesHeader.visibility = View.VISIBLE @@ -95,7 +104,7 @@ class BasketNamesSettingsActivity : AppCompatActivity() { names.forEachIndexed { index, name -> val itemView = inflater.inflate(R.layout.item_basket_name_preset, namesList, false) - bindNameItem(itemView, index, name, names.size) + bindNameItem(itemView, index, name) namesList.addView(itemView) // Add divider between items (not after last) @@ -103,13 +112,13 @@ class BasketNamesSettingsActivity : AppCompatActivity() { addDivider() } } + + // Show add button only when items exist and can add more + addNameButton.visibility = if (basketNamesManager.canAddMore()) View.VISIBLE else View.GONE } - - // Update add button visibility - addNameButton.visibility = if (basketNamesManager.canAddMore()) View.VISIBLE else View.GONE } - private fun bindNameItem(view: View, index: Int, name: String, totalCount: Int) { + private fun bindNameItem(view: View, index: Int, name: String) { val nameText = view.findViewById(R.id.preset_name) val deleteButton = view.findViewById(R.id.delete_button) @@ -239,26 +248,28 @@ class BasketNamesSettingsActivity : AppCompatActivity() { } private fun showDeleteConfirmation(name: String) { - AlertDialog.Builder(this) - .setTitle(R.string.basket_names_dialog_delete_title) - .setMessage(getString(R.string.basket_names_dialog_delete_message, name)) - .setPositiveButton(R.string.common_delete) { _, _ -> + DialogHelper.showConfirmation(this, DialogHelper.ConfirmationConfig( + title = getString(R.string.basket_names_dialog_delete_title), + message = getString(R.string.basket_names_dialog_delete_message, name), + confirmText = getString(R.string.common_delete), + isDestructive = true, + onConfirm = { basketNamesManager.removePresetName(name) refreshNamesList() } - .setNegativeButton(R.string.common_cancel, null) - .show() + )) } private fun showClearAllConfirmation() { - AlertDialog.Builder(this) - .setTitle(R.string.basket_names_dialog_clear_all_title) - .setMessage(R.string.basket_names_dialog_clear_all_message) - .setPositiveButton(R.string.basket_names_dialog_clear_all_confirm) { _, _ -> + DialogHelper.showConfirmation(this, DialogHelper.ConfirmationConfig( + title = getString(R.string.basket_names_dialog_clear_all_title), + message = getString(R.string.basket_names_dialog_clear_all_message), + confirmText = getString(R.string.basket_names_dialog_clear_all_confirm), + isDestructive = true, + onConfirm = { basketNamesManager.clearAll() refreshNamesList() } - .setNegativeButton(R.string.common_cancel, null) - .show() + )) } } diff --git a/app/src/main/java/com/electricdreams/numo/feature/baskets/SavedBasketsActivity.kt b/app/src/main/java/com/electricdreams/numo/feature/baskets/SavedBasketsActivity.kt index 0b854a42c..28551ba02 100644 --- a/app/src/main/java/com/electricdreams/numo/feature/baskets/SavedBasketsActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/feature/baskets/SavedBasketsActivity.kt @@ -10,6 +10,7 @@ import android.widget.LinearLayout import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import com.electricdreams.numo.ui.util.DialogHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.electricdreams.numo.R @@ -159,15 +160,16 @@ class SavedBasketsActivity : AppCompatActivity() { val index = savedBasketManager.getBasketIndex(basket.id) val displayName = basket.getDisplayName(index) - AlertDialog.Builder(this) - .setTitle(R.string.saved_baskets_delete_title) - .setMessage(getString(R.string.saved_baskets_delete_message, displayName)) - .setPositiveButton(R.string.common_delete) { _, _ -> + DialogHelper.showConfirmation(this, DialogHelper.ConfirmationConfig( + title = getString(R.string.saved_baskets_delete_title), + message = getString(R.string.saved_baskets_delete_message, displayName), + confirmText = getString(R.string.common_delete), + isDestructive = true, + onConfirm = { savedBasketManager.deleteBasket(basket.id) loadBaskets() } - .setNegativeButton(R.string.common_cancel, null) - .show() + )) } private fun updateArchiveButtonVisibility() { diff --git a/app/src/main/java/com/electricdreams/numo/feature/history/BasketReceiptActivity.kt b/app/src/main/java/com/electricdreams/numo/feature/history/BasketReceiptActivity.kt new file mode 100644 index 000000000..0356d8080 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/feature/history/BasketReceiptActivity.kt @@ -0,0 +1,691 @@ +package com.electricdreams.numo.feature.history + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import com.electricdreams.numo.core.util.MintManager +import com.google.android.material.button.MaterialButton +import androidx.appcompat.app.AppCompatActivity +import com.electricdreams.numo.R +import com.electricdreams.numo.core.model.Amount +import com.electricdreams.numo.core.model.CheckoutBasket +import com.electricdreams.numo.core.model.CheckoutBasketItem +import com.electricdreams.numo.core.util.CurrencyManager +import com.electricdreams.numo.core.util.ReceiptPrinter +import com.electricdreams.numo.feature.enableEdgeToEdgeWithPill +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Beautiful receipt view displaying all items purchased in a checkout. + * Follows Apple-like design principles with clean typography, + * generous spacing, and professional layout suitable for baristas + * to verify customer orders. + * + * Supports three display modes: + * 1. Fiat-only basket: Large fiat amount, grey sats below + * 2. Mixed/Sats basket: Large sats amount, grey fiat equivalent below + * 3. No basket: Single "Payment" line item with amount + */ +class BasketReceiptActivity : AppCompatActivity() { + + private lateinit var totalAmountText: TextView + private lateinit var totalSubtitleText: TextView + private lateinit var checkoutDateText: TextView + private lateinit var itemsHeaderText: TextView + private lateinit var itemsContainer: LinearLayout + private lateinit var totalsContainer: LinearLayout + private lateinit var subtotalRow: LinearLayout + private lateinit var subtotalLabel: TextView + private lateinit var subtotalValue: TextView + private lateinit var vatBreakdownContainer: LinearLayout + private lateinit var satsItemsRow: LinearLayout + private lateinit var satsItemsValue: TextView + private lateinit var satsItemsEquiv: TextView + private lateinit var finalTotalLabel: TextView + private lateinit var finalTotalValue: TextView + private lateinit var satsEquivalentText: TextView + private lateinit var paidAmountText: TextView + private lateinit var printButton: MaterialButton + + private var basket: CheckoutBasket? = null + + // Additional payment data for printing and display + private var paymentType: String? = null + private var paymentDate: Date = Date() + private var transactionId: String? = null + private var mintUrl: String? = null + private var bitcoinPrice: Double? = null + + // For non-basket transactions + private var totalSatoshis: Long = 0 + private var enteredAmount: Long = 0 + private var enteredCurrency: String = "" // Will be initialized in loadBasketData + + // Tip information + private var tipAmountSats: Long = 0 + private var tipPercentage: Int = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_basket_receipt) + + // Edge-to-edge so the receipt sheet scrolls behind the gesture nav pill + enableEdgeToEdgeWithPill(this, lightNavIcons = true) + + initializeViews() + loadBasketData() + displayReceipt() + } + + private fun initializeViews() { + // Back button + findViewById(R.id.top_bar).onNavClick { finish() } + + // Print button (bottom action) + printButton = findViewById(R.id.print_button) + printButton.setOnClickListener { printReceipt() } + + // Hero section + totalAmountText = findViewById(R.id.total_amount) + totalSubtitleText = findViewById(R.id.total_subtitle) + checkoutDateText = findViewById(R.id.checkout_date) + + // Items section + itemsHeaderText = findViewById(R.id.items_header) + itemsContainer = findViewById(R.id.items_container) + + // Totals section + totalsContainer = findViewById(R.id.totals_container) + subtotalRow = findViewById(R.id.subtotal_row) + subtotalLabel = findViewById(R.id.subtotal_label) + subtotalValue = findViewById(R.id.subtotal_value) + vatBreakdownContainer = findViewById(R.id.vat_breakdown_container) + satsItemsRow = findViewById(R.id.sats_items_row) + satsItemsValue = findViewById(R.id.sats_items_value) + satsItemsEquiv = findViewById(R.id.sats_items_equiv) + finalTotalLabel = findViewById(R.id.final_total_label) + finalTotalValue = findViewById(R.id.final_total_value) + satsEquivalentText = findViewById(R.id.sats_equivalent) + + // Payment info + paidAmountText = findViewById(R.id.paid_amount) + } + + private fun loadBasketData() { + val basketJson = intent.getStringExtra(EXTRA_CHECKOUT_BASKET_JSON) + basket = CheckoutBasket.fromJson(basketJson) + + // Load additional payment data + paymentType = intent.getStringExtra(EXTRA_PAYMENT_TYPE) + val dateMillis = intent.getLongExtra(EXTRA_PAYMENT_DATE, System.currentTimeMillis()) + paymentDate = Date(dateMillis) + transactionId = intent.getStringExtra(EXTRA_TRANSACTION_ID) + mintUrl = intent.getStringExtra(EXTRA_MINT_URL) + val btcPrice = intent.getDoubleExtra(EXTRA_BITCOIN_PRICE, -1.0) + bitcoinPrice = if (btcPrice > 0) btcPrice else null + + // For non-basket transactions + totalSatoshis = intent.getLongExtra(EXTRA_TOTAL_SATOSHIS, 0) + enteredAmount = intent.getLongExtra(EXTRA_ENTERED_AMOUNT, 0) + enteredCurrency = intent.getStringExtra(EXTRA_ENTERED_CURRENCY) ?: CurrencyManager.getInstance(this).getCurrentCurrency() + + // Load tip information + tipAmountSats = intent.getLongExtra(EXTRA_TIP_AMOUNT_SATS, 0) + tipPercentage = intent.getIntExtra(EXTRA_TIP_PERCENTAGE, 0) + + android.util.Log.d("BasketReceiptActivity", "Received basket JSON: ${basketJson?.length ?: 0} chars") + android.util.Log.d("BasketReceiptActivity", "Parsed basket: ${basket?.items?.size ?: 0} items") + android.util.Log.d("BasketReceiptActivity", "Non-basket fallback: totalSats=$totalSatoshis, enteredAmount=$enteredAmount, currency=$enteredCurrency") + android.util.Log.d("BasketReceiptActivity", "Tip: ${tipAmountSats} sats ($tipPercentage%)") + } + + private fun printReceipt() { + val receiptPrinter = ReceiptPrinter(this) + val receiptData = ReceiptPrinter.ReceiptData( + basket = basket, + paymentType = paymentType, + paymentDate = paymentDate, + transactionId = transactionId, + mintUrl = mintUrl, + bitcoinPrice = bitcoinPrice, + totalSatoshis = basket?.totalSatoshis ?: totalSatoshis, + enteredAmount = enteredAmount, + enteredCurrency = enteredCurrency, + tipAmountSats = tipAmountSats, + tipPercentage = tipPercentage, + ) + + // Print directly - one click printing + receiptPrinter.printReceipt(receiptData) + } + + /** + * Determine if sats should be the primary display amount. + * True for: mixed baskets, sats-only baskets, or sats-only payments + */ + private fun shouldShowSatsAsPrimary(): Boolean { + val b = basket + if (b != null) { + // Mixed pricing or sats-only = show sats as primary + return b.hasMixedPriceTypes() || b.getFiatItems().isEmpty() + } + // No basket - use fiat if entered amount exists, otherwise sats + return enteredAmount == 0L && enteredCurrency == "sat" + } + + /** + * Calculate total fiat value including converted sats items. + */ + private fun getTotalFiatIncludingSatsConversion(): Long { + val b = basket + if (b == null) { + return enteredAmount + } + + val fiatTotal = b.getFiatGrossTotalCents() + val satsItems = b.getSatsItems() + + if (satsItems.isEmpty() || bitcoinPrice == null || bitcoinPrice!! <= 0) { + return fiatTotal + } + + // Convert sats items to fiat + val satsTotal = b.getSatsDirectTotal() + val satsInFiat = ((satsTotal.toDouble() / 100_000_000.0) * bitcoinPrice!! * 100).toLong() + + return fiatTotal + satsInFiat + } + + /** + * True when the entered currency differs from sats — i.e. there's a + * real fiat↔sats conversion worth showing. + */ + private fun hasCrossCurrencyConversion(): Boolean { + val entryCurrency = Amount.Currency.fromCode(enteredCurrency) + return enteredAmount > 0 && totalSatoshis > 0 && entryCurrency != Amount.Currency.BTC + } + + /** + * Returns true when the receipt is a simple single payment with no basket, + * no tip, and no cross-currency conversion — i.e. the breakdown sections + * would just repeat the hero amount. + */ + private fun isSimplePayment(): Boolean { + if (basket != null) return false + if (tipAmountSats > 0) return false + if (hasCrossCurrencyConversion()) return false + return true + } + + private fun displayReceipt() { + displayHeroSection() + displayDetails() + + if (isSimplePayment()) { + findViewById(R.id.breakdown_container).visibility = View.GONE + } else { + displayItems() + displayTotals() + displayPaymentInfo() + } + } + + private fun displayDetails() { + // Payment type + val paymentTypeText: TextView = findViewById(R.id.detail_payment_type) + paymentTypeText.text = when (paymentType) { + "lightning" -> getString(R.string.basket_receipt_type_lightning) + "cashu" -> getString(R.string.basket_receipt_type_cashu) + else -> getString(R.string.basket_receipt_type_payment) + } + + // Mint + val mintRow: View = findViewById(R.id.row_mint) + val mintText: TextView = findViewById(R.id.detail_mint) + if (!mintUrl.isNullOrEmpty()) { + mintText.text = MintManager.getInstance(this).getMintDisplayName(mintUrl!!) + mintRow.visibility = View.VISIBLE + } + + // Transaction ID + val txIdRow: View = findViewById(R.id.row_transaction_id) + val txIdText: TextView = findViewById(R.id.detail_transaction_id) + if (!transactionId.isNullOrEmpty()) { + txIdText.text = formatTruncatedId(transactionId!!) + txIdRow.visibility = View.VISIBLE + } + } + + private fun formatTruncatedId(id: String): String { + if (id.length <= 18) return id + return "${id.take(6)}…${id.takeLast(6)}" + } + + private fun displayHeroSection() { + val b = basket + val currency = b?.let { Amount.Currency.fromCode(it.currency) } + ?: Amount.Currency.fromCode(enteredCurrency) + + val showSatsAsPrimary = shouldShowSatsAsPrimary() + + // Use BASE amounts (excluding tip) for the hero section - this is what was sold + val baseSats = (b?.totalSatoshis ?: totalSatoshis) - tipAmountSats + val baseFiat = getTotalFiatIncludingSatsConversion() // enteredAmount is already base amount + + if (showSatsAsPrimary) { + // Primary: Sats amount (base, not including tip) + val satsAmount = Amount(baseSats, Amount.Currency.BTC) + totalAmountText.text = satsAmount.toString() + + // Secondary: Fiat equivalent (only if there's a real cross-currency conversion) + if (hasCrossCurrencyConversion()) { + val fiatEquiv = Amount(baseFiat, currency) + totalSubtitleText.text = "≈ $fiatEquiv" + totalSubtitleText.visibility = View.VISIBLE + } else { + totalSubtitleText.visibility = View.GONE + } + } else { + // Primary: Fiat amount (entered amount is already base amount) + val fiatAmount = Amount(baseFiat, currency) + totalAmountText.text = fiatAmount.toString() + + // Secondary: Sats equivalent + if (baseSats > 0) { + val satsAmount = Amount(baseSats, Amount.Currency.BTC) + totalSubtitleText.text = "≈ $satsAmount" + totalSubtitleText.visibility = View.VISIBLE + } else { + totalSubtitleText.visibility = View.GONE + } + } + + // VAT subtitle (if applicable and we have a basket with VAT) + val totalVat = b?.getFiatVatTotalCents() ?: 0 + if (totalVat > 0) { + val vatAmount = Amount(totalVat, currency) + // Add VAT info to subtitle if space permits + val currentSubtitle = totalSubtitleText.text.toString() + if (currentSubtitle.isNotEmpty()) { + totalSubtitleText.text = getString( + R.string.basket_receipt_total_subtitle_with_vat, + currentSubtitle, + vatAmount.toString() + ) + } else { + totalSubtitleText.text = getString(R.string.basket_receipt_total_subtitle_vat_only, vatAmount.toString()) + totalSubtitleText.visibility = View.VISIBLE + } + } + + // Checkout date + val dateFormat = SimpleDateFormat("MMM d, yyyy 'at' h:mm a", Locale.getDefault()) + checkoutDateText.text = dateFormat.format(paymentDate) + } + + private fun displayItems() { + val b = basket + val currency = b?.let { Amount.Currency.fromCode(it.currency) } + ?: Amount.Currency.fromCode(enteredCurrency) + + // Clear container + itemsContainer.removeAllViews() + + if (b != null && b.items.isNotEmpty()) { + // Items header with count + val itemCount = b.getTotalItemCount() + itemsHeaderText.text = if (itemCount == 1) getString(R.string.basket_receipt_items_header_single) else getString(R.string.basket_receipt_items_header_multiple, itemCount) + + // Add each item + val inflater = LayoutInflater.from(this) + + b.items.forEachIndexed { index, item -> + val itemView = inflater.inflate(R.layout.item_receipt_line, itemsContainer, false) + bindItemView(itemView, item, currency) + itemsContainer.addView(itemView) + + // Add divider between items (not after last) + if (index < b.items.size - 1) { + addDivider(itemsContainer) + } + } + } else { + // No basket - single "Payment" line + itemsHeaderText.text = getString(R.string.basket_receipt_items_header_single) + + val inflater = LayoutInflater.from(this) + val itemView = inflater.inflate(R.layout.item_receipt_line, itemsContainer, false) + bindPaymentOnlyView(itemView, currency) + itemsContainer.addView(itemView) + } + } + + private fun bindItemView(view: View, item: CheckoutBasketItem, currency: Amount.Currency) { + // Quantity badge + val quantityText = view.findViewById(R.id.item_quantity) + quantityText.text = item.quantity.toString() + + // Item name (with variation if present) + val nameText = view.findViewById(R.id.item_name) + nameText.text = item.displayName + + // Unit price text and line total - show in original currency + val unitPriceText = view.findViewById(R.id.item_unit_price) + val totalText = view.findViewById(R.id.item_total) + val vatDetailRow = view.findViewById(R.id.vat_detail_row) + + if (item.isFiatPrice()) { + val unitPrice = Amount(item.getGrossPricePerUnitCents(), currency) + unitPriceText.text = if (item.quantity > 1) "$unitPrice each" else "$unitPrice" + + val lineTotal = Amount(item.getGrossTotalCents(), currency) + totalText.text = lineTotal.toString() + + // VAT detail row (only for fiat items with VAT) + if (item.vatEnabled && item.vatRate > 0) { + val vatLabel = view.findViewById(R.id.vat_label) + val vatAmountText = view.findViewById(R.id.vat_amount) + + vatLabel.text = getString(R.string.basket_receipt_vat_label, item.vatRate) + val itemVat = Amount(item.getTotalVatCents(), currency) + vatAmountText.text = itemVat.toString() + vatDetailRow.visibility = View.VISIBLE + } else { + vatDetailRow.visibility = View.GONE + } + } else { + // Sats-priced item + val unitPriceSats = Amount(item.priceSats, Amount.Currency.BTC) + unitPriceText.text = if (item.quantity > 1) "$unitPriceSats each" else "$unitPriceSats" + + val lineTotalSats = Amount(item.getNetTotalSats(), Amount.Currency.BTC) + totalText.text = lineTotalSats.toString() + + // Show fiat equivalent in VAT row + if (bitcoinPrice != null && bitcoinPrice!! > 0) { + val satsInFiat = ((item.getNetTotalSats().toDouble() / 100_000_000.0) * bitcoinPrice!! * 100).toLong() + val fiatEquiv = Amount(satsInFiat, currency) + + val vatLabel = view.findViewById(R.id.vat_label) + val vatAmountText = view.findViewById(R.id.vat_amount) + vatLabel.text = getString(R.string.basket_receipt_equivalent_label) + vatAmountText.text = "≈ $fiatEquiv" + vatDetailRow.visibility = View.VISIBLE + } else { + vatDetailRow.visibility = View.GONE + } + } + } + + private fun bindPaymentOnlyView(view: View, currency: Amount.Currency) { + // Quantity badge + val quantityText = view.findViewById(R.id.item_quantity) + quantityText.text = "1" + + // Item name + val nameText = view.findViewById(R.id.item_name) + nameText.text = getString(R.string.basket_receipt_payment_item_label) + + // Unit price text and line total + val unitPriceText = view.findViewById(R.id.item_unit_price) + val totalText = view.findViewById(R.id.item_total) + val vatDetailRow = view.findViewById(R.id.vat_detail_row) + + if (enteredAmount > 0) { + // Fiat payment — show fiat as price, sats equivalent below + val amount = Amount(enteredAmount, currency) + unitPriceText.visibility = View.GONE + totalText.text = amount.toString() + + if (totalSatoshis > 0) { + val satsAmount = Amount(totalSatoshis, Amount.Currency.BTC) + val vatLabel = view.findViewById(R.id.vat_label) + val vatAmountText = view.findViewById(R.id.vat_amount) + vatLabel.text = "≈" + vatAmountText.text = satsAmount.toString() + vatDetailRow.visibility = View.VISIBLE + } else { + vatDetailRow.visibility = View.GONE + } + } else { + // Sats-only payment — just show the amount, no redundant unit price + val satsAmount = Amount(totalSatoshis, Amount.Currency.BTC) + unitPriceText.visibility = View.GONE + totalText.text = satsAmount.toString() + vatDetailRow.visibility = View.GONE + } + } + + private fun displayTotals() { + val b = basket + val currency = b?.let { Amount.Currency.fromCode(it.currency) } + ?: Amount.Currency.fromCode(enteredCurrency) + + val showSatsAsPrimary = shouldShowSatsAsPrimary() + + // Use BASE amounts (excluding tip) for totals - tip is shown separately below + val fullSats = b?.totalSatoshis ?: totalSatoshis + val baseSats = fullSats - tipAmountSats + val baseFiat = getTotalFiatIncludingSatsConversion() // enteredAmount is already base amount + + if (b != null) { + val hasVat = b.hasVat() + val hasFiatItems = b.getFiatItems().isNotEmpty() + val hasSatsItems = b.getSatsItems().isNotEmpty() + + // Fiat subtotal row (net, only if there's VAT to show) + if (hasVat && hasFiatItems) { + val netTotal = b.getFiatNetTotalCents() + subtotalLabel.text = "Fiat Subtotal (net)" + subtotalValue.text = Amount(netTotal, currency).toString() + subtotalRow.visibility = View.VISIBLE + } else if (hasFiatItems && hasSatsItems) { + // Show fiat subtotal if mixed basket + subtotalLabel.text = "Fiat Items" + subtotalValue.text = Amount(b.getFiatGrossTotalCents(), currency).toString() + subtotalRow.visibility = View.VISIBLE + } else { + subtotalRow.visibility = View.GONE + } + + // VAT breakdown by rate + vatBreakdownContainer.removeAllViews() + if (hasVat && hasFiatItems) { + val vatBreakdown = b.getVatBreakdown() + vatBreakdown.forEach { (rate, amount) -> + addVatRow(rate, amount, currency) + } + } + + // Sats items subtotal if mixed basket + if (hasSatsItems) { + satsItemsValue.text = Amount(b.getSatsDirectTotal(), Amount.Currency.BTC).toString() + + if (bitcoinPrice != null && bitcoinPrice!! > 0) { + val satsInFiat = ((b.getSatsDirectTotal().toDouble() / 100_000_000.0) * bitcoinPrice!! * 100).toLong() + satsItemsEquiv.text = "≈ ${Amount(satsInFiat, currency)}" + satsItemsEquiv.visibility = View.VISIBLE + } else { + satsItemsEquiv.visibility = View.GONE + } + satsItemsRow.visibility = View.VISIBLE + } else { + satsItemsRow.visibility = View.GONE + } + } else { + // No basket + subtotalRow.visibility = View.GONE + vatBreakdownContainer.removeAllViews() + satsItemsRow.visibility = View.GONE + } + + // Final total (BASE amount, excluding tip) + if (showSatsAsPrimary) { + finalTotalValue.text = Amount(baseSats, Amount.Currency.BTC).toString() + + if (hasCrossCurrencyConversion()) { + satsEquivalentText.text = "≈ ${Amount(baseFiat, currency)}" + satsEquivalentText.visibility = View.VISIBLE + } else { + satsEquivalentText.visibility = View.GONE + } + } else { + finalTotalValue.text = Amount(baseFiat, currency).toString() + + if (baseSats > 0) { + satsEquivalentText.text = "≈ ${Amount(baseSats, Amount.Currency.BTC)}" + satsEquivalentText.visibility = View.VISIBLE + } else { + satsEquivalentText.visibility = View.GONE + } + } + + // Show tip as separate line AFTER total - it doesn't add to the Total for accounting + if (tipAmountSats > 0) { + addTipRow(currency) + + // Also add a "Total Paid" line showing the full amount with tip + addTotalPaidRow(currency, fullSats) + } + } + + private fun addTotalPaidRow(currency: Amount.Currency, totalSats: Long) { + val row = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + topMargin = (12 * resources.displayMetrics.density).toInt() + } + } + + val label = TextView(this).apply { + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + text = getString(R.string.basket_receipt_total_paid_label) + textSize = 15f + setTextColor(resources.getColor(R.color.color_text_primary, theme)) + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + } + + val value = TextView(this).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + text = Amount(totalSats, Amount.Currency.BTC).toString() + textSize = 15f + setTextColor(resources.getColor(R.color.color_text_primary, theme)) + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + } + + row.addView(label) + row.addView(value) + vatBreakdownContainer.addView(row) + } + + private fun addTipRow(currency: Amount.Currency) { + val row = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + topMargin = (8 * resources.displayMetrics.density).toInt() + } + } + + val label = TextView(this).apply { + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + text = if (tipPercentage > 0) getString(R.string.basket_receipt_tip_label_with_percentage, tipPercentage) else getString(R.string.basket_receipt_tip_label) + textSize = 15f + setTextColor(resources.getColor(R.color.color_success_green, theme)) + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + } + + val value = TextView(this).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + text = Amount(tipAmountSats, Amount.Currency.BTC).toString() + textSize = 15f + setTextColor(resources.getColor(R.color.color_success_green, theme)) + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + } + + row.addView(label) + row.addView(value) + vatBreakdownContainer.addView(row) + } + + private fun addVatRow(rate: Int, amountCents: Long, currency: Amount.Currency) { + val row = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + topMargin = (8 * resources.displayMetrics.density).toInt() + } + } + + val label = TextView(this).apply { + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + text = getString(R.string.basket_receipt_vat_row_label, rate) + textSize = 15f + setTextColor(resources.getColor(R.color.color_text_secondary, theme)) + } + + val value = TextView(this).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + text = Amount(amountCents, currency).toString() + textSize = 15f + setTextColor(resources.getColor(R.color.color_text_secondary, theme)) + } + + row.addView(label) + row.addView(value) + vatBreakdownContainer.addView(row) + } + + private fun displayPaymentInfo() { + val sats = basket?.totalSatoshis ?: totalSatoshis + paidAmountText.text = Amount(sats, Amount.Currency.BTC).toString() + } + + private fun addDivider(container: LinearLayout) { + val divider = View(this).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + (0.5f * resources.displayMetrics.density).toInt() + ).apply { + marginStart = (56 * resources.displayMetrics.density).toInt() // Align with item text + marginEnd = (16 * resources.displayMetrics.density).toInt() + } + setBackgroundColor(resources.getColor(R.color.color_divider, theme)) + } + container.addView(divider) + } + + companion object { + const val EXTRA_CHECKOUT_BASKET_JSON = "checkout_basket_json" + const val EXTRA_PAYMENT_TYPE = "payment_type" + const val EXTRA_PAYMENT_DATE = "payment_date" + const val EXTRA_TRANSACTION_ID = "transaction_id" + const val EXTRA_MINT_URL = "mint_url" + const val EXTRA_BITCOIN_PRICE = "bitcoin_price" + const val EXTRA_TOTAL_SATOSHIS = "total_satoshis" + const val EXTRA_ENTERED_AMOUNT = "entered_amount" + const val EXTRA_ENTERED_CURRENCY = "entered_currency" + const val EXTRA_TIP_AMOUNT_SATS = "tip_amount_sats" + const val EXTRA_TIP_PERCENTAGE = "tip_percentage" + } +} diff --git a/app/src/main/java/com/electricdreams/numo/feature/history/PaymentsHistoryActivity.kt b/app/src/main/java/com/electricdreams/numo/feature/history/PaymentsHistoryActivity.kt index 107f345ce..2d8b5d703 100644 --- a/app/src/main/java/com/electricdreams/numo/feature/history/PaymentsHistoryActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/feature/history/PaymentsHistoryActivity.kt @@ -16,6 +16,7 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import com.electricdreams.numo.ui.util.DialogHelper import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.electricdreams.numo.feature.enableEdgeToEdgeWithPill @@ -28,6 +29,7 @@ import com.electricdreams.numo.core.model.Amount import com.electricdreams.numo.core.util.CurrencyManager import com.electricdreams.numo.core.worker.BitcoinPriceWorker import com.electricdreams.numo.databinding.ActivityHistoryBinding +import com.electricdreams.numo.ui.components.EmptyStateHelper import com.electricdreams.numo.feature.autowithdraw.AutoWithdrawManager import com.electricdreams.numo.feature.autowithdraw.WithdrawHistoryEntry import com.electricdreams.numo.payment.PaymentIntentFactory @@ -76,14 +78,11 @@ class PaymentsHistoryActivity : AppCompatActivity() { // Let history content run behind the gesture nav pill for a modern look enableEdgeToEdgeWithPill(this, lightNavIcons = true) - // Setup Back Button - binding.backButton?.setOnClickListener { finish() } - - // Setup overflow menu button - binding.overflowButton?.setOnClickListener { showOverflowMenu(it) } + binding.topBar.onNavClick { finish() } + binding.overflowButton.setOnClickListener { showOverflowMenu(binding.overflowButton) } // Setup insights entry point - binding.insightsButton?.setOnClickListener { + binding.insightsButton.setOnClickListener { startActivity(Intent(this, com.electricdreams.numo.feature.insights.InsightsActivity::class.java)) } @@ -320,22 +319,15 @@ class PaymentsHistoryActivity : AppCompatActivity() { .show() } - private fun showDeleteConfirmation(entry: HistoryEntry) { - AlertDialog.Builder(this) - .setTitle(R.string.history_dialog_delete_title) - .setMessage(R.string.history_dialog_delete_message) - .setPositiveButton(R.string.history_dialog_delete_positive) { _, _ -> deletePaymentFromHistory(entry) } - .setNegativeButton(R.string.history_dialog_delete_negative, null) - .show() - } private fun showClearHistoryConfirmation() { - AlertDialog.Builder(this) - .setTitle(R.string.history_dialog_clear_title) - .setMessage(R.string.history_dialog_clear_message) - .setPositiveButton(R.string.history_dialog_clear_positive) { _, _ -> clearAllHistory() } - .setNegativeButton(R.string.history_dialog_clear_negative, null) - .show() + DialogHelper.showConfirmation(this, DialogHelper.ConfirmationConfig( + title = getString(R.string.history_dialog_clear_title), + message = getString(R.string.history_dialog_clear_message), + confirmText = getString(R.string.history_dialog_clear_positive), + isDestructive = true, + onConfirm = { clearAllHistory() } + )) } private fun setupFilterBar() { @@ -563,7 +555,15 @@ class PaymentsHistoryActivity : AppCompatActivity() { adapter.setEntries(currentHistoryList) val isEmpty = currentHistoryList.isEmpty() - binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE + binding.emptyView.root.visibility = if (isEmpty) View.VISIBLE else View.GONE + if (isEmpty) { + EmptyStateHelper.bind( + binding.emptyView.root, + R.drawable.ic_receipt, + "No Payments Yet", + "Payment history will appear here once you start accepting payments" + ) + } } private fun clearAllHistory() { diff --git a/app/src/main/java/com/electricdreams/numo/feature/history/TokenHistoryActivity.kt b/app/src/main/java/com/electricdreams/numo/feature/history/TokenHistoryActivity.kt index d7ce7a105..5e69708bf 100644 --- a/app/src/main/java/com/electricdreams/numo/feature/history/TokenHistoryActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/feature/history/TokenHistoryActivity.kt @@ -4,11 +4,12 @@ import android.content.Context import android.content.SharedPreferences import android.os.Bundle import android.view.View -import androidx.appcompat.app.AlertDialog +import com.electricdreams.numo.ui.util.DialogHelper import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.LinearLayoutManager import com.electricdreams.numo.feature.enableEdgeToEdgeWithPill import com.electricdreams.numo.R +import com.electricdreams.numo.ui.components.EmptyStateHelper import com.electricdreams.numo.core.data.model.TokenHistoryEntry import com.electricdreams.numo.databinding.ActivityHistoryBinding import com.electricdreams.numo.ui.adapter.TokenHistoryAdapter @@ -32,12 +33,13 @@ class TokenHistoryActivity : AppCompatActivity() { adapter = TokenHistoryAdapter().apply { setOnDeleteClickListener { entry, position -> - AlertDialog.Builder(this@TokenHistoryActivity) - .setTitle(R.string.token_history_dialog_delete_title) - .setMessage(R.string.token_history_dialog_delete_message) - .setPositiveButton(R.string.token_history_dialog_delete_positive) { _, _ -> deleteTokenFromHistory(position) } - .setNegativeButton(R.string.common_cancel, null) - .show() + DialogHelper.showConfirmation(this@TokenHistoryActivity, DialogHelper.ConfirmationConfig( + title = getString(R.string.token_history_dialog_delete_title), + message = getString(R.string.token_history_dialog_delete_message), + confirmText = getString(R.string.token_history_dialog_delete_positive), + isDestructive = true, + onConfirm = { deleteTokenFromHistory(position) } + )) } } @@ -59,16 +61,25 @@ class TokenHistoryActivity : AppCompatActivity() { adapter.setEntries(history) val isEmpty = history.isEmpty() - binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE + binding.emptyView.root.visibility = if (isEmpty) View.VISIBLE else View.GONE + if (isEmpty) { + EmptyStateHelper.bind( + binding.emptyView.root, + R.drawable.ic_receipt, + "No Tokens Yet", + "Cashu token history will appear here" + ) + } } private fun showClearHistoryConfirmation() { - AlertDialog.Builder(this) - .setTitle(R.string.token_history_dialog_clear_title) - .setMessage(R.string.token_history_dialog_clear_message) - .setPositiveButton(R.string.token_history_dialog_clear_positive) { _, _ -> clearAllHistory() } - .setNegativeButton(R.string.common_cancel, null) - .show() + DialogHelper.showConfirmation(this, DialogHelper.ConfirmationConfig( + title = getString(R.string.token_history_dialog_clear_title), + message = getString(R.string.token_history_dialog_clear_message), + confirmText = getString(R.string.token_history_dialog_clear_positive), + isDestructive = true, + onConfirm = { clearAllHistory() } + )) } private fun clearAllHistory() { diff --git a/app/src/main/java/com/electricdreams/numo/feature/history/TransactionDetailActivity.kt b/app/src/main/java/com/electricdreams/numo/feature/history/TransactionDetailActivity.kt index f86239568..6ce0ef36e 100644 --- a/app/src/main/java/com/electricdreams/numo/feature/history/TransactionDetailActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/feature/history/TransactionDetailActivity.kt @@ -9,7 +9,6 @@ import android.widget.LinearLayout import androidx.appcompat.widget.PopupMenu import android.widget.TextView import android.widget.Toast -import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import com.electricdreams.numo.core.util.CurrencyManager import com.electricdreams.numo.R @@ -23,6 +22,7 @@ import com.electricdreams.numo.feature.autowithdraw.AutoWithdrawManager import com.electricdreams.numo.ui.util.DialogHelper import com.electricdreams.numo.core.util.MintProfileService import com.electricdreams.numo.core.util.SavedBasketManager +import com.electricdreams.numo.feature.enableEdgeToEdgeWithPill import androidx.lifecycle.lifecycleScope import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -50,6 +50,7 @@ class TransactionDetailActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + enableEdgeToEdgeWithPill(this) setContentView(R.layout.activity_transaction_detail) ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)) { v, windowInsets -> @@ -107,11 +108,9 @@ class TransactionDetailActivity : AppCompatActivity() { get() = entry.amount < 0 private fun setupViews() { - // Back button - findViewById(R.id.back_button).setOnClickListener { finish() } - - // Overflow menu button - findViewById(R.id.overflow_button).setOnClickListener { showOverflowMenu(it) } + val topBar = findViewById(R.id.top_bar) + topBar.onNavClick { finish() } + topBar.onActionClick { showOverflowMenu(topBar.actionView) } // Display transaction details displayTransactionDetails() @@ -596,27 +595,6 @@ class TransactionDetailActivity : AppCompatActivity() { ).show() } - private fun openWithApp() { - val cashuUri = "cashu:${entry.token}" - val uriIntent = Intent(Intent.ACTION_VIEW, Uri.parse(cashuUri)).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - - val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, cashuUri) - } - - val chooserIntent = Intent.createChooser(uriIntent, "Open payment with...").apply { - putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(shareIntent)) - } - - try { - startActivity(chooserIntent) - } catch (e: Exception) { - Toast.makeText(this, R.string.history_toast_no_app, Toast.LENGTH_SHORT).show() - } - } private fun copyDestination(destination: String) { val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager @@ -672,12 +650,13 @@ class TransactionDetailActivity : AppCompatActivity() { } private fun showDeleteConfirmation() { - AlertDialog.Builder(this) - .setTitle(R.string.history_dialog_delete_title) - .setMessage(R.string.history_dialog_delete_message) - .setPositiveButton(R.string.history_dialog_delete_positive) { _, _ -> deleteTransaction() } - .setNegativeButton(R.string.history_dialog_delete_negative, null) - .show() + DialogHelper.showConfirmation(this, DialogHelper.ConfirmationConfig( + title = getString(R.string.history_dialog_delete_title), + message = getString(R.string.history_dialog_delete_message), + confirmText = getString(R.string.history_dialog_delete_positive), + isDestructive = true, + onConfirm = { deleteTransaction() } + )) } private fun deleteTransaction() { diff --git a/app/src/main/java/com/electricdreams/numo/feature/items/EmptyStateAnimator.kt b/app/src/main/java/com/electricdreams/numo/feature/items/EmptyStateAnimator.kt index 6a523f8ef..31488c2b9 100644 --- a/app/src/main/java/com/electricdreams/numo/feature/items/EmptyStateAnimator.kt +++ b/app/src/main/java/com/electricdreams/numo/feature/items/EmptyStateAnimator.kt @@ -15,6 +15,7 @@ import android.widget.FrameLayout import android.widget.TextView import androidx.core.content.ContextCompat import com.electricdreams.numo.R +import com.electricdreams.numo.ui.util.isAnimationEnabled import kotlin.math.sin /** @@ -82,6 +83,7 @@ class EmptyStateAnimator( ) fun start() { + if (!context.isAnimationEnabled()) return // Prevent multiple simultaneous setups if (isSettingUp || isAnimationRunning) return isSettingUp = true @@ -219,8 +221,8 @@ class EmptyStateAnimator( val targetY = containerHeight * startYPercent // Create pop-in animation set - val scaleXAnim = ObjectAnimator.ofFloat(tile.view, "scaleX", 0f, 1.1f, 1f) - val scaleYAnim = ObjectAnimator.ofFloat(tile.view, "scaleY", 0f, 1.1f, 1f) + val scaleXAnim = ObjectAnimator.ofFloat(tile.view, "scaleX", 0.85f, 1.1f, 1f) + val scaleYAnim = ObjectAnimator.ofFloat(tile.view, "scaleY", 0.85f, 1.1f, 1f) val alphaAnim = ObjectAnimator.ofFloat(tile.view, "alpha", 0f, 1f) val translateYAnim = ObjectAnimator.ofFloat( tile.view, "translationY", @@ -330,6 +332,45 @@ class EmptyStateAnimator( tileContainer?.removeAllViews() } + /** + * Animate tiles out (reverse pop-in: staggered shrink + fade), then stop. + * Calls [onComplete] after the last tile exits. + */ + fun animateOut(onComplete: (() -> Unit)? = null) { + if (!isAnimationRunning || floatingTiles.isEmpty()) { + stop() + onComplete?.invoke() + return + } + + floatAnimator?.cancel() + var completedCount = 0 + + floatingTiles.reversed().forEachIndexed { index, tile -> + val delay = index * 50L + val scaleX = ObjectAnimator.ofFloat(tile.view, "scaleX", tile.view.scaleX, 0.85f) + val scaleY = ObjectAnimator.ofFloat(tile.view, "scaleY", tile.view.scaleY, 0.85f) + val alpha = ObjectAnimator.ofFloat(tile.view, "alpha", tile.view.alpha, 0f) + + AnimatorSet().apply { + playTogether(scaleX, scaleY, alpha) + duration = 250L + startDelay = delay + interpolator = android.view.animation.DecelerateInterpolator() + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + completedCount++ + if (completedCount == floatingTiles.size) { + stop() + onComplete?.invoke() + } + } + }) + start() + } + } + } + private fun createTileView(tile: TileItem, density: Float): View { // Get size in pixels val sizePx = when (tile.size) { diff --git a/app/src/main/java/com/electricdreams/numo/feature/items/ItemEntryActivity.kt b/app/src/main/java/com/electricdreams/numo/feature/items/ItemEntryActivity.kt index 5658733a5..4bd695285 100644 --- a/app/src/main/java/com/electricdreams/numo/feature/items/ItemEntryActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/feature/items/ItemEntryActivity.kt @@ -6,13 +6,15 @@ import android.os.Bundle import android.view.View import android.widget.Button import android.widget.EditText +import android.widget.ImageView +import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat +import androidx.core.widget.doAfterTextChanged import com.electricdreams.numo.R import com.electricdreams.numo.core.model.Item import com.electricdreams.numo.core.model.PriceType @@ -59,6 +61,7 @@ class ItemEntryActivity : AppCompatActivity() { private var editItemId: String? = null private var isEditMode: Boolean = false private var currentItem: Item? = null + private var moreDetailsExpanded: Boolean = false // Activity Result Launchers private val selectGalleryLauncher: ActivityResultLauncher = @@ -141,12 +144,10 @@ class ItemEntryActivity : AppCompatActivity() { priceTypeToggle = findViewById(R.id.price_type_toggle), btnPriceFiat = findViewById(R.id.btn_price_fiat), btnPriceBitcoin = findViewById(R.id.btn_price_bitcoin), - fiatPriceContainer = findViewById(R.id.fiat_price_container), - satsPriceContainer = findViewById(R.id.sats_price_container), + fiatPriceLayout = findViewById(R.id.fiat_price_layout), + satsPriceLayout = findViewById(R.id.sats_price_layout), priceInput = findViewById(R.id.item_price_input), satsInput = findViewById(R.id.item_sats_input), - currencySymbol = findViewById(R.id.currency_symbol), - currencyCode = findViewById(R.id.currency_code), vatSectionCard = findViewById(R.id.vat_section_card), switchVatEnabled = findViewById(R.id.switch_vat_enabled), vatFieldsContainer = findViewById(R.id.vat_fields_container), @@ -225,18 +226,53 @@ class ItemEntryActivity : AppCompatActivity() { } private fun setupClickListeners() { - findViewById(R.id.back_button)?.setOnClickListener { finish() } + findViewById(R.id.top_bar).onNavClick { finish() } findViewById