diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 2c131ccbe..eee1053e7 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -63,6 +63,8 @@ dependencies { implementation(Libs.okhttp) implementation(Libs.mixpanel) + implementation(Libs.androidx_paging_runtime) + kapt(Libs.androidx_room_compiler) implementation(Libs.sqlcipher) diff --git a/api/schemas/com.getcode.db.AppDatabase/8.json b/api/schemas/com.getcode.db.AppDatabase/8.json new file mode 100644 index 000000000..ed12ef725 --- /dev/null +++ b/api/schemas/com.getcode.db.AppDatabase/8.json @@ -0,0 +1,272 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "45c39753eadbdaeddb35d2e002424dac", + "entities": [ + { + "tableName": "CurrencyRate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FaqItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT, `question` TEXT NOT NULL, `answer` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "question", + "columnName": "question", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "answer", + "columnName": "answer", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefInt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefBool", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PrefDouble", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SendLimit", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `limit` REAL NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "limit", + "columnName": "limit", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "GiftCard", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `entropy` TEXT NOT NULL, `amount` INTEGER NOT NULL, `date` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entropy", + "columnName": "entropy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchangeData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fiat` REAL NOT NULL, `currency` TEXT NOT NULL, `synced_at` INTEGER NOT NULL, PRIMARY KEY(`currency`))", + "fields": [ + { + "fieldPath": "fx", + "columnName": "fiat", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "currency" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '45c39753eadbdaeddb35d2e002424dac')" + ] + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/db/AppDatabase.kt b/api/src/main/java/com/getcode/db/AppDatabase.kt index edda5057d..67db56398 100644 --- a/api/src/main/java/com/getcode/db/AppDatabase.kt +++ b/api/src/main/java/com/getcode/db/AppDatabase.kt @@ -1,10 +1,15 @@ package com.getcode.db import android.content.Context +import androidx.room.AutoMigration import androidx.room.Database +import androidx.room.DeleteTable import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.room.migration.AutoMigrationSpec +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import com.getcode.model.* import com.getcode.network.repository.decodeBase64 import com.getcode.vendor.Base58 @@ -16,7 +21,6 @@ import java.io.File @Database( entities = [ - HistoricalTransaction::class, CurrencyRate::class, FaqItem::class, PrefInt::class, @@ -27,7 +31,10 @@ import java.io.File GiftCard::class, ExchangeRate::class, ], - version = 7 + autoMigrations = [ + AutoMigration(from = 7, to = 8, spec = AppDatabase.Migration7To8::class) + ], + version = 8 ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { @@ -37,6 +44,9 @@ abstract class AppDatabase : RoomDatabase() { abstract fun prefDoubleDao(): PrefDoubleDao abstract fun giftCardDao(): GiftCardDao abstract fun exchangeDao(): ExchangeDao + + @DeleteTable(tableName = "HistoricalTransaction") + class Migration7To8 : AutoMigrationSpec } object Database { diff --git a/api/src/main/java/com/getcode/manager/SessionManager.kt b/api/src/main/java/com/getcode/manager/SessionManager.kt index 3ed307d47..f5f995ffa 100644 --- a/api/src/main/java/com/getcode/manager/SessionManager.kt +++ b/api/src/main/java/com/getcode/manager/SessionManager.kt @@ -14,8 +14,7 @@ import javax.inject.Singleton @Singleton -class SessionManager @Inject constructor( -) { +class SessionManager @Inject constructor() { data class SessionState( val entropyB64: String? = null, val keyPair: Ed25519.KeyPair? = null, @@ -26,7 +25,9 @@ class SessionManager @Inject constructor( fun set(context: Context, entropyB64: String) { val mnemonic = MnemonicPhrase.fromEntropyB64(context, entropyB64) - if (getOrganizer()?.mnemonic?.words == mnemonic.words) return + if (getOrganizer()?.mnemonic?.words == mnemonic.words + && getOrganizer()?.ownerKeyPair == authState.value.keyPair + ) return val organizer = Organizer.newInstance( context = context, mnemonic = mnemonic @@ -37,7 +38,7 @@ class SessionManager @Inject constructor( entropyB64 = entropyB64, keyPair = organizer.ownerKeyPair, isAuthenticated = true, - organizer = organizer + organizer = organizer, ) } } diff --git a/api/src/main/java/com/getcode/mapper/ChatMessageMapper.kt b/api/src/main/java/com/getcode/mapper/ChatMessageMapper.kt new file mode 100644 index 000000000..0150e7fc5 --- /dev/null +++ b/api/src/main/java/com/getcode/mapper/ChatMessageMapper.kt @@ -0,0 +1,22 @@ +package com.getcode.mapper + + +import com.codeinc.gen.chat.v1.ChatService +import com.getcode.model.ChatMessage +import com.getcode.model.MessageContent +import javax.inject.Inject +import com.codeinc.gen.chat.v1.ChatService.ChatMessage as ApiChatMessage +import com.getcode.model.ChatMessage as DomainChatMessage + + +class ChatMessageMapper @Inject constructor( +): Mapper { + override fun map(from: ChatService.ChatMessage): ChatMessage { + return ChatMessage( + id = from.messageId.value.toByteArray().toList(), + cursor = from.cursor.value.toList(), + dateMillis = from.ts.seconds * 1_000L, + contents = from.contentList.mapNotNull { MessageContent(it) }, + ) + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/mapper/ChatMetadataMapper.kt b/api/src/main/java/com/getcode/mapper/ChatMetadataMapper.kt new file mode 100644 index 000000000..5e23a3ddb --- /dev/null +++ b/api/src/main/java/com/getcode/mapper/ChatMetadataMapper.kt @@ -0,0 +1,25 @@ +package com.getcode.mapper + +import com.codeinc.gen.chat.v1.ChatService +import com.getcode.model.Chat +import com.getcode.model.Pointer +import com.getcode.model.Title +import javax.inject.Inject + +class ChatMetadataMapper @Inject constructor() : Mapper { + override fun map(from: ChatService.ChatMetadata): Chat { + return Chat( + id = from.chatId.value.toByteArray().toList(), + cursor = from.cursor.value.toByteArray().toList(), + title = Title(from), + pointer = Pointer(from.readPointer), + unreadCount = from.numUnread, + canMute = from.canMute, + isMuted = from.isMuted, + canUnsubscribe = from.canUnsubscribe, + isSubscribed = from.isSubscribed, + isVerified = from.isVerified, + messages = emptyList() + ) + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/mapper/Mapper.kt b/api/src/main/java/com/getcode/mapper/Mapper.kt new file mode 100644 index 000000000..92b37dd3b --- /dev/null +++ b/api/src/main/java/com/getcode/mapper/Mapper.kt @@ -0,0 +1,9 @@ +package com.getcode.mapper + +interface Mapper { + fun map(from: F): T +} + +interface SuspendMapper { + suspend fun map(from: F): T +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/Chat.kt b/api/src/main/java/com/getcode/model/Chat.kt new file mode 100644 index 000000000..97ccf3066 --- /dev/null +++ b/api/src/main/java/com/getcode/model/Chat.kt @@ -0,0 +1,168 @@ +package com.getcode.model + +import com.codeinc.gen.chat.v1.ChatService +import com.codeinc.gen.chat.v1.ChatService.Content + +typealias ID = List +typealias Cursor = List + +/** + * Chat domain model for On-Chain messaging. This serves as a reference to a collection of messages. + * + * @param id Unique chat identifier ([ID]) + * @param cursor [Cursor] value for this chat for reference in subsequent GetChatsRequest + * @param title Recommended chat [Title] inferred by the type of chat + * @param pointer [Pointer] in the chat indicating the most recently read message by the user + * @param unreadCount Estimated number of unread messages in this chat + * @param canMute Can the user mute this chat? + * @param isMuted Has the user muted this chat? + * @param canUnsubscribe Can the user unsubscribe from this chat? + * @param isSubscribed Is the user subscribed to this chat? + * @param isVerified Is this a verified chat? + * NOTE: It's possible to have two chats with the same title, but with + * different verification statuses. They should be treated separately. + * @param messages List of messages within this chat + */ +data class Chat( + val id: ID, + val cursor: Cursor, + val title: Title?, + val pointer: Pointer?, + val unreadCount: Int, + val canMute: Boolean, + val isMuted: Boolean, + val canUnsubscribe: Boolean, + val isSubscribed: Boolean, + val isVerified: Boolean, + val messages: List +) { + fun resetUnreadCount() = copy(unreadCount = 0) + fun toggleMute() = copy(isMuted = !isMuted) + + val newestMessage: ChatMessage? + get() = messages.maxByOrNull { it.dateMillis } + + val lastMessageMillis: Long? + get() = newestMessage?.dateMillis +} + +sealed interface Pointer { + data object Unknown : Pointer + data class Read(val id: ID) : Pointer + + companion object { + operator fun invoke(proto: ChatService.Pointer): Pointer { + return when (proto.kind) { + ChatService.Pointer.Kind.UNKNOWN -> Unknown + ChatService.Pointer.Kind.READ -> Read(proto.value.value.toList()) + ChatService.Pointer.Kind.UNRECOGNIZED -> Unknown + else -> Unknown + } + } + } + +} + +sealed interface Title { + val value: String + data class Localized(override val value: String) : Title + data class Domain(override val value: String) : Title + + companion object { + operator fun invoke(proto: ChatService.ChatMetadata): Title? { + return when (proto.titleCase) { + ChatService.ChatMetadata.TitleCase.LOCALIZED -> Localized(proto.localized.key) + ChatService.ChatMetadata.TitleCase.DOMAIN -> Domain(proto.domain.value) + ChatService.ChatMetadata.TitleCase.TITLE_NOT_SET -> null + else -> null + } + } + } +} + +sealed interface Verb { + data object Unknown : Verb + data object Gave : Verb + data object Received : Verb + data object Withdrew : Verb + data object Deposited: Verb + data object Sent : Verb + data object Returned : Verb + data object Spent : Verb + data object Paid : Verb + data object Purchased : Verb + + companion object { + operator fun invoke(proto: ChatService.ExchangeDataContent.Verb): Verb { + return when (proto) { + ChatService.ExchangeDataContent.Verb.UNKNOWN -> Unknown + ChatService.ExchangeDataContent.Verb.GAVE -> Gave + ChatService.ExchangeDataContent.Verb.RECEIVED ->Received + ChatService.ExchangeDataContent.Verb.WITHDREW -> Withdrew + ChatService.ExchangeDataContent.Verb.DEPOSITED -> Deposited + ChatService.ExchangeDataContent.Verb.SENT -> Sent + ChatService.ExchangeDataContent.Verb.RETURNED -> Returned + ChatService.ExchangeDataContent.Verb.SPENT -> Spent + ChatService.ExchangeDataContent.Verb.PAID -> Paid + ChatService.ExchangeDataContent.Verb.PURCHASED -> Purchased + ChatService.ExchangeDataContent.Verb.UNRECOGNIZED -> Unknown + } + } + } +} + +data class ChatMessage( + val id: ID, + val cursor: Cursor, + val dateMillis: Long, + val contents: List +) + +sealed interface MessageContent { + data class Localized(val value: String) : MessageContent + data class Exchange(val amount: GenericAmount, val verb: Verb) : MessageContent + data object SodiumBox : MessageContent + + companion object { + operator fun invoke(proto: Content): MessageContent? { + return when (proto.typeCase) { + Content.TypeCase.LOCALIZED -> Localized(proto.localized.key) + Content.TypeCase.EXCHANGE_DATA -> { + val verb = Verb(proto.exchangeData.verb) + when (proto.exchangeData.exchangeDataCase) { + ChatService.ExchangeDataContent.ExchangeDataCase.EXACT -> { + val exact = proto.exchangeData.exact + val currency = CurrencyCode.tryValueOf(exact.currency) ?: return null + val kinAmount = KinAmount.newInstance( + kin = Kin.fromQuarks(exact.quarks), + rate = Rate( + fx = exact.exchangeRate, + currency = currency + ) + ) + + Exchange(GenericAmount.Exact(kinAmount), verb) + } + ChatService.ExchangeDataContent.ExchangeDataCase.PARTIAL -> { + val partial = proto.exchangeData.partial + val currency = CurrencyCode.tryValueOf(partial.currency) ?: return null + + val fiat = Fiat( + currency = currency, + amount = partial.nativeAmount + ) + + Exchange(GenericAmount.Partial(fiat), verb) + } + ChatService.ExchangeDataContent.ExchangeDataCase.EXCHANGEDATA_NOT_SET -> return null + else -> return null + } + } + Content.TypeCase.NACL_BOX -> SodiumBox + Content.TypeCase.TYPE_NOT_SET -> return null + else -> return null + } + } + } + +} diff --git a/api/src/main/java/com/getcode/model/Fiat.kt b/api/src/main/java/com/getcode/model/Fiat.kt index cdf8c5b52..a76bbd3e8 100644 --- a/api/src/main/java/com/getcode/model/Fiat.kt +++ b/api/src/main/java/com/getcode/model/Fiat.kt @@ -5,4 +5,22 @@ sealed interface Value data class Fiat( val currency: CurrencyCode, val amount: Double, -): Value \ No newline at end of file +): Value + +sealed interface GenericAmount { + + val currencyCode: CurrencyCode + data class Exact(val amount: KinAmount): GenericAmount { + override val currencyCode: CurrencyCode = amount.rate.currency + } + data class Partial(val fiat: Fiat): GenericAmount { + override val currencyCode: CurrencyCode = fiat.currency + } + + fun amountUsing(rate: Rate): KinAmount { + return when (this) { + is Exact -> amount + is Partial -> KinAmount.fromFiatAmount(fiat.amount, rate) + } + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/HistoricalTransaction.kt b/api/src/main/java/com/getcode/model/HistoricalTransaction.kt deleted file mode 100644 index 8a5dc234b..000000000 --- a/api/src/main/java/com/getcode/model/HistoricalTransaction.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.getcode.model - -import androidx.room.Entity -import androidx.room.PrimaryKey -import com.codeinc.gen.common.v1.Model - -@Entity -data class HistoricalTransaction( - @PrimaryKey(autoGenerate = true) val uid: Int? = null, - val id: List, - val paymentType: PaymentType, - val date: Long, - val transactionRateFx: Double?, - val transactionRateCurrency: String?, - val transactionAmountQuarks: Long, - val nativeAmount: Double, - val isDeposit: Boolean, - val isWithdrawal: Boolean, - val isRemoteSend: Boolean, - val isReturned: Boolean, - val airdropType: AirdropType?, -) { - fun getKinAmount(): KinAmount? { - transactionRateFx ?: return null - transactionRateCurrency ?: return null - val currency = CurrencyCode.tryValueOf(transactionRateCurrency) ?: return null - - return KinAmount.newInstance( - kin = Kin.fromQuarks(quarks = transactionAmountQuarks), - rate = Rate( - fx = transactionRateFx, - currency = currency - ) - ) - } -} - -enum class PaymentType { - Unknown, - Send, - Receive; - - companion object { - fun tryValueOf(value: String): PaymentType? { - return try { - valueOf(value.lowercase().replaceFirstChar { it.uppercase() }) - } catch (e: Exception) { - null - } - } - } -} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/KinAmount.kt b/api/src/main/java/com/getcode/model/KinAmount.kt index 335d72a3c..b711ff6ff 100644 --- a/api/src/main/java/com/getcode/model/KinAmount.kt +++ b/api/src/main/java/com/getcode/model/KinAmount.kt @@ -1,6 +1,7 @@ package com.getcode.model import com.codeinc.gen.transaction.v2.TransactionService.ExchangeData +import com.getcode.model.Kin.Companion.fromKin import com.getcode.utils.FormatUtils data class KinAmount( @@ -19,6 +20,10 @@ data class KinAmount( } companion object { + fun newInstance(kin: Int, rate: Rate): KinAmount { + return newInstance(fromKin(kin), rate) + } + fun newInstance(kin: Kin, rate: Rate): KinAmount { return KinAmount( kin = kin, diff --git a/api/src/main/java/com/getcode/network/BalanceController.kt b/api/src/main/java/com/getcode/network/BalanceController.kt index fd239c6b2..f73f3e964 100644 --- a/api/src/main/java/com/getcode/network/BalanceController.kt +++ b/api/src/main/java/com/getcode/network/BalanceController.kt @@ -2,35 +2,101 @@ package com.getcode.network import android.content.Context import com.getcode.manager.SessionManager +import com.getcode.model.Currency +import com.getcode.model.CurrencyCode +import com.getcode.model.Rate import com.getcode.network.client.TransactionReceiver +import com.getcode.network.exchange.Exchange import com.getcode.network.repository.AccountRepository import com.getcode.network.repository.BalanceRepository import com.getcode.network.repository.TransactionRepository import com.getcode.solana.organizer.Organizer import com.getcode.solana.organizer.Tray +import com.getcode.utils.FormatUtils +import com.getcode.utils.network.NetworkConnectivityListener import dagger.hilt.android.qualifiers.ApplicationContext import io.reactivex.rxjava3.core.Completable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withTimeout import timber.log.Timber +import java.util.Locale import java.util.concurrent.TimeUnit import javax.inject.Inject +data class BalanceDisplay( + val flag: Int? = null, + val marketValue: Double = 0.0, + val formattedValue: String = "", + +) class BalanceController @Inject constructor( + exchange: Exchange, + networkObserver: NetworkConnectivityListener, + getCurrency: suspend (rates: Map) -> Currency, @ApplicationContext private val context: Context, private val balanceRepository: BalanceRepository, private val transactionRepository: TransactionRepository, private val accountRepository: AccountRepository, private val privacyMigration: PrivacyMigration, - private val transactionReceiver: TransactionReceiver -) { + private val transactionReceiver: TransactionReceiver, + val getDefaultCountry: () -> String, + val suffix: () -> String, +): CoroutineScope by CoroutineScope(Dispatchers.IO) { - fun observe(): Flow = balanceRepository.balanceFlow + fun observeRawBalance(): Flow = balanceRepository.balanceFlow - val balance: Double + val rawBalance: Double get() = balanceRepository.balanceFlow.value + private val _balanceDisplay = MutableStateFlow(null) + val formattedBalance: SharedFlow + get() = _balanceDisplay + .stateIn(this, SharingStarted.Eagerly, BalanceDisplay()) + + init { + combine( + exchange.observeRates() + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + .map { getCurrency(it) } + .onEach { + val display = _balanceDisplay.value ?: BalanceDisplay() + _balanceDisplay.value = display.copy(flag = it.resId) + } + .mapNotNull { currency -> CurrencyCode.tryValueOf(currency.code) } + .mapNotNull { + exchange.fetchRatesIfNeeded() + exchange.rateFor(it) + }, + balanceRepository.balanceFlow, + networkObserver.state + ) { rate, balance, _ -> + rate to balance + }.map { (rate, balance) -> + refreshBalance(balance, rate.fx) + }.distinctUntilChanged().onEach { (marketValue, amountText) -> + val display = _balanceDisplay.value ?: BalanceDisplay() + _balanceDisplay.value = display.copy(marketValue = marketValue, formattedValue = amountText) + }.launchIn(this) + } + fun setTray(organizer: Organizer, tray: Tray) { organizer.set(tray) balanceRepository.setBalance(organizer.availableBalance.toKinTruncatingLong().toDouble()) @@ -130,4 +196,20 @@ class BalanceController @Inject constructor( } } } + + private fun refreshBalance(balance: Double, rate: Double): Pair { + val fiatValue = FormatUtils.getFiatValue(balance, rate) + val locale = Locale( + Locale.getDefault().language, + getDefaultCountry() + ) + val fiatValueFormatted = FormatUtils.formatCurrency(fiatValue, locale) + val amountText = StringBuilder().apply { + append(fiatValueFormatted) + append(" ") + append(suffix()) + }.toString() + + return fiatValue to amountText + } } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/HistoryController.kt b/api/src/main/java/com/getcode/network/HistoryController.kt new file mode 100644 index 000000000..31799201e --- /dev/null +++ b/api/src/main/java/com/getcode/network/HistoryController.kt @@ -0,0 +1,185 @@ +package com.getcode.network + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.PagingSource +import androidx.paging.cachedIn +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.manager.SessionManager +import com.getcode.model.Chat +import com.getcode.model.ChatMessage +import com.getcode.model.Cursor +import com.getcode.model.ID +import com.getcode.network.client.Client +import com.getcode.network.client.advancePointer +import com.getcode.network.client.fetchChats +import com.getcode.network.client.fetchMessagesFor +import com.getcode.network.client.setMuted +import com.getcode.network.repository.encodeBase64 +import com.getcode.network.source.ChatMessagePagingSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import okhttp3.internal.toImmutableList +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HistoryController @Inject constructor( + private val client: Client, +) : CoroutineScope by CoroutineScope(Dispatchers.IO) { + + val hasFetchedChats: Boolean + get() = _chats.value.orEmpty().isNotEmpty() + + private val _chats = MutableStateFlow?>(null) + val chats: StateFlow?> + get() = _chats.asStateFlow() + + + private val pagerMap = mutableMapOf>() + private val chatFlows = mutableMapOf>>() + + private val pagingConfig = PagingConfig(pageSize = 20) + + + fun reset() { + pagerMap.clear() + chatFlows.clear() + } + + private fun chatMessagePager(chatId: ID) = Pager(pagingConfig) { + pagerMap[chatId] ?: ChatMessagePagingSource(client, owner()!!, chatId).also { + pagerMap[chatId] = it + } + } + + fun chatFlow(chatId: ID) = + chatFlows[chatId] ?: chatMessagePager(chatId).flow.cachedIn(GlobalScope).also { + chatFlows[chatId] = it + } + + val unreadCount = chats + .filterNotNull() + .map { it.filter { c -> !c.isMuted } } + .map { it.sumOf { c -> c.unreadCount } } + + private fun owner(): KeyPair? = SessionManager.getKeyPair() + + suspend fun fetchChats() { + val containers = fetchChatsWithoutMessages() + Timber.d("chats fetched = ${containers.count()}") + _chats.value = containers + + val updatedWithMessages = mutableListOf() + containers.onEach { chat -> + val result = fetchLatestMessageForChat(chat.id) + result.onSuccess { message -> + if (message != null) { + updatedWithMessages.add(chat.copy(messages = listOf(message))) + } + }.onFailure { + updatedWithMessages.add(chat) + } + } + + _chats.value = updatedWithMessages.sortedByDescending { it.lastMessageMillis } + + pagerMap.entries.onEach { (id, pagingSource) -> + pagingSource.invalidate() + } + } + + suspend fun advanceReadPointer(chatId: ID) { + val owner = owner() ?: return + + _chats.update { + it?.toMutableList()?.apply chats@{ + indexOfFirst { chat -> chat.id == chatId } + .takeIf { index -> index >= 0 } + ?.let { index -> + val chat = this[index] + val newestMessage = chat.newestMessage + if (newestMessage != null) { + client.advancePointer(owner, chatId, newestMessage.id) + .onSuccess { + this[index] = chat.resetUnreadCount() + } + } + } + }?.toList() + } + } + + suspend fun setMuted(chatId: ID, muted: Boolean): Result { + val owner = owner() ?: return Result.failure(Throwable("No owner detected")) + + _chats.update { + it?.toMutableList()?.apply chats@{ + indexOfFirst { chat -> chat.id == chatId } + .takeIf { index -> index >= 0 } + ?.let { index -> + val chat = this[index] + Timber.d("changing mute state for chat locally") + this[index] = chat.copy(isMuted = muted) + } + }?.toList() + } + + return client.setMuted(owner, chatId, muted) + } + + suspend fun fetchMessagesForChat( + id: List, + cursor: Cursor? = null, + limit: Int? = null + ): Result { + val encodedId = id.toByteArray().encodeBase64() + val owner = owner() ?: return Result.success(null) + return client.fetchMessagesFor(owner, id, cursor, limit) + .onFailure { + Timber.e(t = it, "Failed to fetch messages for $encodedId.") + }.map { it.getOrNull(0) } + } + + private suspend fun fetchLatestMessageForChat(id: List): Result { + val encodedId = id.toByteArray().encodeBase64() + Timber.d("fetching last message for $encodedId") + val owner = owner() ?: return Result.success(null) + return client.fetchMessagesFor(owner, id, limit = 1) + .onFailure { + Timber.e(t = it, "Failed to fetch messages for $encodedId.") + }.map { it.getOrNull(0) } + } + + private suspend fun fetchChatsWithoutMessages(): List { + val owner = owner() ?: return emptyList() + val result = client.fetchChats(owner) + return if (result.isSuccess) { + result.getOrNull().orEmpty() + } else { + result.exceptionOrNull()?.printStackTrace() + emptyList() + } + } +} + +fun List.mapInPlace(mutator: (Chat) -> (Chat)): List { + val updated = toMutableList().apply { + this.forEachIndexed { i, value -> + val changedValue = mutator(value) + + this[i] = changedValue + } + } + return updated.toImmutableList() +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/api/ChatApi.kt b/api/src/main/java/com/getcode/network/api/ChatApi.kt new file mode 100644 index 000000000..1f57e1ee6 --- /dev/null +++ b/api/src/main/java/com/getcode/network/api/ChatApi.kt @@ -0,0 +1,136 @@ +package com.getcode.network.api + +import com.codeinc.gen.chat.v1.ChatGrpc +import com.codeinc.gen.chat.v1.ChatService +import com.codeinc.gen.chat.v1.ChatService.AdvancePointerRequest +import com.codeinc.gen.chat.v1.ChatService.AdvancePointerResponse +import com.codeinc.gen.chat.v1.ChatService.GetChatsRequest +import com.codeinc.gen.chat.v1.ChatService.GetMessagesRequest +import com.codeinc.gen.chat.v1.ChatService.Pointer.Kind +import com.codeinc.gen.chat.v1.ChatService.SetMuteStateRequest +import com.codeinc.gen.chat.v1.ChatService.SetMuteStateResponse +import com.getcode.ed25519.Ed25519 +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.Cursor +import com.getcode.model.ID +import com.getcode.network.core.GrpcApi +import com.getcode.network.repository.toByteString +import com.getcode.network.repository.toSignature +import com.getcode.network.repository.toSolanaAccount +import io.grpc.ManagedChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import java.io.ByteArrayOutputStream +import javax.inject.Inject + +class ChatApi @Inject constructor( + managedChannel: ManagedChannel +) : GrpcApi(managedChannel) { + private val api = ChatGrpc.newStub(managedChannel) + + fun fetchChats(owner: KeyPair): Flow { + val request = GetChatsRequest.newBuilder() + .setOwner(owner.publicKeyBytes.toSolanaAccount()) + .setSignature(owner) + .build() + + return api::getChats + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + fun fetchChatMessages(owner: KeyPair, chatId: ID, cursor: Cursor? = null, limit: Int? = null): Flow { + val builder = GetMessagesRequest.newBuilder() + .setChatId(ChatService.ChatId.newBuilder() + .setValue(chatId.toByteArray().toByteString()) + .build() + ) + + if (cursor != null) { + builder.setCursor(ChatService.Cursor.newBuilder() + .setValue(cursor.toByteString())) + } + + if (limit != null) { + builder.setPageSize(limit) + } + + builder.setDirection(ChatService.GetMessagesRequest.Direction.DESC) + + val request = builder + .setOwner(owner.publicKeyBytes.toSolanaAccount()) + .setSignature(owner) + .build() + + return api::getMessages + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + fun advancePointer(owner: KeyPair, chatId: ID, to: ID): Flow { + val request = AdvancePointerRequest.newBuilder() + .setChatId(ChatService.ChatId.newBuilder() + .setValue(chatId.toByteArray().toByteString()) + .build() + ).setPointer(ChatService.Pointer.newBuilder() + .setKindValue(Kind.READ_VALUE) + .setValue(ChatService.ChatMessageId.newBuilder() + .setValue(to.toByteArray().toByteString()) + ) + ).setOwner(owner.publicKeyBytes.toSolanaAccount()) + .setSignature(owner) + .build() + + return api::advancePointer + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } + + fun setMuteState(owner: KeyPair, chatId: ID, muted: Boolean): Flow { + val request = SetMuteStateRequest.newBuilder() + .setChatId(ChatService.ChatId.newBuilder() + .setValue(chatId.toByteArray().toByteString()) + .build() + ).setIsMuted(muted) + .setOwner(owner.publicKeyBytes.toSolanaAccount()) + .setSignature(owner) + .build() + + return api::setMuteState + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) + } +} + +private fun GetChatsRequest.Builder.setSignature(owner: KeyPair): GetChatsRequest.Builder { + val bos = ByteArrayOutputStream() + buildPartial().writeTo(bos) + setSignature(Ed25519.sign(bos.toByteArray(), owner).toSignature()) + + return this +} + +private fun GetMessagesRequest.Builder.setSignature(owner: KeyPair): GetMessagesRequest.Builder { + val bos = ByteArrayOutputStream() + buildPartial().writeTo(bos) + setSignature(Ed25519.sign(bos.toByteArray(), owner).toSignature()) + + return this +} + +private fun SetMuteStateRequest.Builder.setSignature(owner: KeyPair): SetMuteStateRequest.Builder { + val bos = ByteArrayOutputStream() + buildPartial().writeTo(bos) + setSignature(Ed25519.sign(bos.toByteArray(), owner).toSignature()) + + return this +} + +private fun AdvancePointerRequest.Builder.setSignature(owner: KeyPair): AdvancePointerRequest.Builder { + val bos = ByteArrayOutputStream() + buildPartial().writeTo(bos) + setSignature(Ed25519.sign(bos.toByteArray(), owner).toSignature()) + + return this +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/client/Client.kt b/api/src/main/java/com/getcode/network/client/Client.kt index 1b0653a19..366422106 100644 --- a/api/src/main/java/com/getcode/network/client/Client.kt +++ b/api/src/main/java/com/getcode/network/client/Client.kt @@ -4,12 +4,14 @@ import android.content.Context import com.getcode.analytics.AnalyticsService import com.getcode.manager.SessionManager import com.getcode.network.BalanceController +import com.getcode.network.HistoryController import com.getcode.network.exchange.Exchange import com.getcode.network.repository.AccountRepository import com.getcode.network.repository.MessagingRepository import com.getcode.network.repository.PrefRepository import com.getcode.network.repository.TransactionRepository import com.getcode.utils.network.NetworkConnectivityListener +import com.getcode.network.service.ChatService import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -37,6 +39,7 @@ class Client @Inject constructor( internal val exchange: Exchange, internal val transactionReceiver: TransactionReceiver, internal val networkObserver: NetworkConnectivityListener, + internal val chatService: ChatService, ) { private val TAG = "PollTimer" diff --git a/api/src/main/java/com/getcode/network/client/Client_Chat.kt b/api/src/main/java/com/getcode/network/client/Client_Chat.kt new file mode 100644 index 000000000..3f8292edc --- /dev/null +++ b/api/src/main/java/com/getcode/network/client/Client_Chat.kt @@ -0,0 +1,40 @@ +package com.getcode.network.client + +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.Chat +import com.getcode.model.ChatMessage +import com.getcode.model.Cursor +import com.getcode.model.ID +import com.getcode.network.repository.encodeBase64 +import timber.log.Timber + +suspend fun Client.fetchChats(owner: KeyPair): Result> { + return chatService.fetchChats(owner) + .onSuccess { + Timber.d("chats fetched=${it.count()}") + }.onFailure { + it.printStackTrace() + } +} + +suspend fun Client.setMuted(owner: KeyPair, chat: ID, muted: Boolean): Result { + return chatService.setMuteState(owner, chat, muted) +} + +suspend fun Client.fetchMessagesFor(owner: KeyPair, chatId: ID, cursor: Cursor? = null, limit: Int? = null) : Result> { + return chatService.fetchMessagesFor(owner, chatId, cursor, limit) + .onSuccess { + Timber.d("messages fetched=${it.count()} for ${chatId.toByteArray().encodeBase64()}") + Timber.d("start=${it.minOf { it.dateMillis }}, end=${it.maxOf { it.dateMillis }}") + }.onFailure { + Timber.e(t = it, "Failed fetching messages.") + } +} + +suspend fun Client.advancePointer( + owner: KeyPair, + chatId: ID, + to: ID, +): Result { + return chatService.advancePointer(owner, chatId, to) +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/client/Client_Transaction.kt b/api/src/main/java/com/getcode/network/client/Client_Transaction.kt index cf2b48eb6..cba02cd3b 100644 --- a/api/src/main/java/com/getcode/network/client/Client_Transaction.kt +++ b/api/src/main/java/com/getcode/network/client/Client_Transaction.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import android.content.Context import com.getcode.api.BuildConfig import com.getcode.db.Database -import com.getcode.ed25519.Ed25519 import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.manager.SessionManager import com.getcode.manager.TopBarManager @@ -15,7 +14,6 @@ import com.getcode.solana.keys.PublicKey import com.getcode.solana.keys.base58 import com.getcode.solana.organizer.GiftCardAccount import com.getcode.solana.organizer.Organizer -import com.getcode.utils.catchSafely import com.getcode.utils.flowInterval import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable @@ -23,28 +21,19 @@ import io.reactivex.rxjava3.core.Single import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.takeWhile -import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.rx3.asFlow -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import timber.log.Timber import java.util.* -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import kotlin.math.min @@ -73,6 +62,7 @@ fun Client.transfer( isWithdrawal ).flatMapCompletable { if (it.isSuccess) { + Timber.d("transfer successful") Completable.complete() } else { Completable.error(it.exceptionOrNull() ?: Throwable("Failed to complete transfer")) @@ -195,9 +185,7 @@ suspend fun Client.receiveRemoteSuspend(giftCard: GiftCardAccount): KinAmount = ) balanceController.fetchBalanceSuspend() - transactionRepository.fetchPaymentHistoryDelta(organizer.ownerKeyPair) - .ignoreElement() - .subscribe() + // TODO: fetch chats here somehow? return@withContext kinAmount } @@ -216,7 +204,7 @@ suspend fun Client.cancelRemoteSend( ).blockingAwait() balanceController.fetchBalanceSuspend() - return balanceController.balance + return balanceController.rawBalance } @@ -358,6 +346,7 @@ fun Client.sendRemotely( suspend fun Client.requestFirstKinAirdrop( owner: KeyPair, ): Result { + Timber.d("requesting airdrop") return transactionRepository.requestFirstKinAirdrop(owner) } @@ -423,40 +412,6 @@ fun Client.fetchTransactionLimits( return transactionRepository.fetchTransactionLimits(owner, seconds) } -fun Client.historicalTransactions() = transactionRepository.transactionCache - -@OptIn(ExperimentalCoroutinesApi::class) -fun Client.observeTransactions( -): Flow> { - return SessionManager.authState - .map { it.keyPair } - .flatMapLatest { owner -> - transactionRepository.transactionCache - .map { it to owner } - }.map { (trx, owner) -> trx.orEmpty().sortedByDescending { it.date } to owner } - .flatMapConcat { (initialList, owner) -> - owner ?: return@flatMapConcat emptyFlow() - fetchPaymentHistoryDelta(owner, initialList.firstOrNull()?.id?.toByteArray()) - .toObservable().asFlow() - .filter { it.isNotEmpty() } - .scan(initialList) { previous, update -> - Timber.d("prev=${previous.count()}, update=${update.count()}") - previous - .filterNot { update.contains(it) } - .plus(update) - .sortedByDescending { it.date } - .also { Timber.d("now ${it.count()}") } - } - } -} - -fun Client.fetchPaymentHistoryDelta( - owner: KeyPair, - afterId: ByteArray? = transactionRepository.transactionCache.value?.firstOrNull()?.id?.toByteArray() -): Single> { - return transactionRepository.fetchPaymentHistoryDelta(owner, afterId) -} - fun Client.fetchDestinationMetadata(destination: PublicKey): Single { return transactionRepository.fetchDestinationMetadata(destination) } @@ -480,10 +435,14 @@ fun Client.receiveIfNeeded(): Completable { } fun Client.receiveFromPrimaryIfWithinLimits(organizer: Organizer): Completable { + Timber.d("receive within limits") val depositBalance = organizer.availableDepositBalance.toKinTruncating() // Nothing to deposit - if (!depositBalance.hasWholeKin()) return Completable.complete() + if (!depositBalance.hasWholeKin()) { + Timber.d("nothing to deposit ($depositBalance)") + return Completable.complete() + } // We want to deposit the smaller of the two: balance in the // primary account or the max allowed amount provided by server @@ -496,7 +455,7 @@ fun Client.receiveFromPrimaryIfWithinLimits(organizer: Organizer): Completable { } .filter { pair -> val (depositAmount, _) = pair - depositAmount.hasWholeKin() + depositAmount.hasWholeKin().also { Timber.d("hasWholeKin=$it") } } .flatMapSingle { pair -> val (depositAmount, maxDeposit) = pair diff --git a/api/src/main/java/com/getcode/network/core/GrpcApi.kt b/api/src/main/java/com/getcode/network/core/GrpcApi.kt index 8d5207908..e41b7a612 100644 --- a/api/src/main/java/com/getcode/network/core/GrpcApi.kt +++ b/api/src/main/java/com/getcode/network/core/GrpcApi.kt @@ -7,6 +7,11 @@ import io.grpc.stub.StreamObserver import io.reactivex.rxjava3.core.BackpressureStrategy import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.reflect.KFunction2 abstract class GrpcApi(protected val managedChannel: ManagedChannel) { @@ -23,6 +28,16 @@ abstract class GrpcApi(protected val managedChannel: ManagedChannel) { request: Request, backpressureStrategy: BackpressureStrategy = BackpressureStrategy.BUFFER ): Flowable = internalCallAsCancellableFlowable(request, backpressureStrategy) + + fun KFunction2, Unit>.callAsFlow( + request: Request, + backpressureStrategy: BackpressureStrategy = BackpressureStrategy.BUFFER + ): Flow = internalCallAsFlow(request, backpressureStrategy) + + fun KFunction2, Unit>.callAsCancellableFlow( + request: Request, + backpressureStrategy: BackpressureStrategy = BackpressureStrategy.BUFFER + ): Flow = internalCallAsCancellableFlow(request, backpressureStrategy) } internal fun KFunction2, Unit>.internalCallAsSingle( @@ -66,6 +81,13 @@ internal fun KFunction2 KFunction2, Unit>.internalCallAsFlow( + request: Request, + backpressureStrategy: BackpressureStrategy = BackpressureStrategy.BUFFER +): Flow { + return internalCallAsFlowable(request, backpressureStrategy).asFlow() +} + internal fun KFunction2, Unit>.internalCallAsCancellableFlowable( request: Request, backpressureStrategy: BackpressureStrategy = BackpressureStrategy.BUFFER @@ -108,3 +130,10 @@ internal fun KFunction2 KFunction2, Unit>.internalCallAsCancellableFlow( + request: Request, + backpressureStrategy: BackpressureStrategy = BackpressureStrategy.BUFFER +): Flow { + return internalCallAsCancellableFlowable(request, backpressureStrategy).asFlow() +} diff --git a/api/src/main/java/com/getcode/network/core/NetworkOracle.kt b/api/src/main/java/com/getcode/network/core/NetworkOracle.kt index 140f5af05..0973a6eab 100644 --- a/api/src/main/java/com/getcode/network/core/NetworkOracle.kt +++ b/api/src/main/java/com/getcode/network/core/NetworkOracle.kt @@ -3,8 +3,15 @@ package com.getcode.network.core import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.timeout +import kotlinx.coroutines.rx3.asCoroutineDispatcher import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.seconds const val INFINITE_STREAM_TIMEOUT = -1L const val DEFAULT_STREAM_TIMEOUT = 15L @@ -18,6 +25,11 @@ interface NetworkOracle { request: Flowable, timeout: Long = DEFAULT_STREAM_TIMEOUT ): Flowable + + fun managedRequest( + request: Flow, + timeout: Long = DEFAULT_STREAM_TIMEOUT + ): Flow } class NetworkOracleImpl : NetworkOracle { @@ -37,4 +49,20 @@ class NetworkOracleImpl : NetworkOracle { } .subscribeOn(scheduler) } + + @OptIn(FlowPreview::class) + override fun managedRequest( + request: Flow, + timeout: Long + ): Flow { + return request + .let { + if (timeout != INFINITE_STREAM_TIMEOUT) { + it.timeout(timeout.seconds) + } else { + it + } + } + .flowOn(scheduler.asCoroutineDispatcher()) + } } diff --git a/api/src/main/java/com/getcode/network/exchange/Exchange.kt b/api/src/main/java/com/getcode/network/exchange/Exchange.kt index 8153a6ea0..0e74a8a87 100644 --- a/api/src/main/java/com/getcode/network/exchange/Exchange.kt +++ b/api/src/main/java/com/getcode/network/exchange/Exchange.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import timber.log.Timber @@ -26,21 +27,63 @@ import javax.inject.Singleton import kotlin.coroutines.resume import kotlin.time.Duration.Companion.minutes -class Exchange @Inject constructor( +interface Exchange { + val localRate: Rate + fun observeLocalRate(): Flow + + fun rates(): Map + fun observeRates(): Flow> + + suspend fun fetchRatesIfNeeded() + + fun rateFor(currencyCode: CurrencyCode): Rate? + + fun rateForUsd(): Rate? +} + +class ExchangeNull: Exchange { + override val localRate: Rate + get() = Rate.oneToOne + + override fun observeLocalRate(): Flow { + return emptyFlow() + } + + override fun rates(): Map { + return emptyMap() + } + + override fun observeRates(): Flow> { + return emptyFlow() + } + + override suspend fun fetchRatesIfNeeded() = Unit + + override fun rateFor(currencyCode: CurrencyCode): Rate? { + return null + } + + override fun rateForUsd(): Rate? { + return null + } + +} + +class CodeExchange @Inject constructor( private val currencyApi: CurrencyApi, private val networkOracle: NetworkOracle, private val defaultCurrency: () -> Currency?, -): CoroutineScope by CoroutineScope(Dispatchers.IO) { +): Exchange, CoroutineScope by CoroutineScope(Dispatchers.IO) { private val db = Database.getInstance() private var entryRate: Rate = Rate.oneToOne private val _localRate = MutableStateFlow(Rate.oneToOne) - val localRate + override val localRate get() = _localRate.value - fun observeLocalRate(): Flow = _localRate + override fun observeLocalRate(): Flow = _localRate private var rateDate: Long = System.currentTimeMillis() @@ -53,8 +96,8 @@ class Exchange @Inject constructor( _rates.value = value.rates } - fun rates() = rates.rates - fun observeRates(): Flow> = _rates + override fun rates() = rates.rates + override fun observeRates(): Flow> = _rates private val isStale: Boolean get() { @@ -78,7 +121,7 @@ class Exchange @Inject constructor( } } - suspend fun fetchRatesIfNeeded() { + override suspend fun fetchRatesIfNeeded() { if (isStale) { runCatching { fetchExchangeRates() } .onSuccess { (updatedRates, date) -> @@ -90,7 +133,7 @@ class Exchange @Inject constructor( } } - fun set(currency: CurrencyCode) { + private fun set(currency: CurrencyCode) { entryCurrency = currency updateRates() } @@ -113,9 +156,9 @@ class Exchange @Inject constructor( entryCurrency = CurrencyCode.tryValueOf(localRegionCurrency.code) } - fun rateFor(currencyCode: CurrencyCode): Rate? = rates.rateFor(currencyCode) + override fun rateFor(currencyCode: CurrencyCode): Rate? = rates.rateFor(currencyCode) - fun rateForUsd(): Rate? = rates.rateForUsd() + override fun rateForUsd(): Rate? = rates.rateForUsd() private fun updateRates() { if (rates.isEmpty) { @@ -150,7 +193,6 @@ class Exchange @Inject constructor( currencyApi.getRates() .let { networkOracle.managedRequest(it) } .subscribe({ response -> - Timber.d("rates=${response.ratesMap.count()}") val rates = response.ratesMap.mapNotNull { (key, value) -> val currency = CurrencyCode.tryValueOf(key) ?: return@mapNotNull null Rate(fx = value, currency = currency) diff --git a/api/src/main/java/com/getcode/network/repository/Extensions.kt b/api/src/main/java/com/getcode/network/repository/Extensions.kt index 017b37f20..0cbc59f6e 100644 --- a/api/src/main/java/com/getcode/network/repository/Extensions.kt +++ b/api/src/main/java/com/getcode/network/repository/Extensions.kt @@ -14,6 +14,7 @@ import java.net.URLEncoder fun isMock() = false +fun List.toByteString(): ByteString = ByteString.copyFrom(this.toByteArray()) fun ByteArray.toByteString(): ByteString = ByteString.copyFrom(this) fun ByteArray.toUserId(): Model.UserId { diff --git a/api/src/main/java/com/getcode/network/repository/IdentityRepository.kt b/api/src/main/java/com/getcode/network/repository/IdentityRepository.kt index 98db9b8b2..5affecf1d 100644 --- a/api/src/main/java/com/getcode/network/repository/IdentityRepository.kt +++ b/api/src/main/java/com/getcode/network/repository/IdentityRepository.kt @@ -3,7 +3,6 @@ package com.getcode.network.repository import com.codeinc.gen.phone.v1.PhoneVerificationService import com.codeinc.gen.user.v1.IdentityService import com.getcode.db.Database -import com.getcode.db.InMemoryDao import com.getcode.ed25519.Ed25519 import com.getcode.model.AirdropType import com.getcode.model.PrefsBool @@ -92,11 +91,11 @@ class IdentityRepository @Inject constructor( fun getUserLocal(): Flowable { return Flowable.zip( - prefRepository.get(PrefsString.KEY_USER_ID), - prefRepository.get(PrefsString.KEY_DATA_CONTAINER_ID), - prefRepository.get(PrefsBool.IS_DEBUG_ALLOWED), - prefRepository.get(PrefsBool.IS_ELIGIBLE_GET_FIRST_KIN_AIRDROP), - prefRepository.get(PrefsBool.IS_ELIGIBLE_GIVE_FIRST_KIN_AIRDROP), + prefRepository.getFlowable(PrefsString.KEY_USER_ID), + prefRepository.getFlowable(PrefsString.KEY_DATA_CONTAINER_ID), + prefRepository.getFlowable(PrefsBool.IS_DEBUG_ALLOWED), + prefRepository.getFlowable(PrefsBool.IS_ELIGIBLE_GET_FIRST_KIN_AIRDROP), + prefRepository.getFlowable(PrefsBool.IS_ELIGIBLE_GIVE_FIRST_KIN_AIRDROP), Flowable.just(phoneRepository.phoneLinked) ) { userId, dataContainerId, isDebugAllowed, isEligibleGetFirstKinAirdrop, isEligibleGiveFirstKinAirdrop, isPhoneNumberLinked -> var eligibleAirdrops = Sets.newHashSet() diff --git a/api/src/main/java/com/getcode/network/repository/MessagingRepository.kt b/api/src/main/java/com/getcode/network/repository/MessagingRepository.kt index 21217e8a5..9e87f6359 100644 --- a/api/src/main/java/com/getcode/network/repository/MessagingRepository.kt +++ b/api/src/main/java/com/getcode/network/repository/MessagingRepository.kt @@ -53,15 +53,19 @@ class MessagingRepository @Inject constructor( return messagingApi.openMessageStream(request) .let { networkOracle.managedRequest(it, INFINITE_STREAM_TIMEOUT) } .map { + Timber.d("message stream response received") it.messagesList .filter { message -> message.kindCase == MessagingService.Message.KindCase.REQUEST_TO_GRAB_BILL } } .doOnNext { messagesList -> - if (messagesList.isEmpty()) return@doOnNext + if (messagesList.isEmpty()) { + Timber.e("message list is empty") + return@doOnNext + } ackMessages(rendezvousKeyPair, messagesList.map { it.id }) - .subscribe({}, ErrorUtils::handleError) + .subscribe({ Timber.d("acked") }, ErrorUtils::handleError) } .filter { it.isNotEmpty() } .map { messagesList -> @@ -73,11 +77,11 @@ class MessagingRepository @Inject constructor( PaymentRequest(account, signature) }.first() } - .retry(10L) + .retry(10L) { + it.printStackTrace() + true + } .subscribeOn(Schedulers.computation()) - //.subscribe({}, { error -> - //}) - //.disposeBy(lifecycle) } private fun ackMessages( diff --git a/api/src/main/java/com/getcode/network/repository/PrefRepository.kt b/api/src/main/java/com/getcode/network/repository/PrefRepository.kt index 9fc74834f..46ede02a1 100644 --- a/api/src/main/java/com/getcode/network/repository/PrefRepository.kt +++ b/api/src/main/java/com/getcode/network/repository/PrefRepository.kt @@ -8,12 +8,15 @@ import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onEmpty import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -22,7 +25,23 @@ import javax.inject.Inject class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dispatchers.IO) { - fun get(key: PrefsString): Flowable { + suspend fun get(key: PrefsString, default: String): String { + return observeOrDefault(key, default).firstOrNull() ?: default + } + + suspend fun get(key: PrefsBool, default: Boolean): Boolean { + return observeOrDefault(key, default).firstOrNull() ?: default + } + + suspend fun get(key: PrefDouble, default: Double): Double { + return observeOrDefault(key, default).firstOrNull() ?: default + } + + suspend fun get(key: PrefInt, default: Long): Long { + return observeOrDefault(key, default).firstOrNull() ?: default + } + + fun getFlowable(key: PrefsString): Flowable { val db = Database.getInstance() ?: return Flowable.empty() return db.prefStringDao().get(key.value) .subscribeOn(Schedulers.computation()) @@ -30,7 +49,7 @@ class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dis .distinctUntilChanged() } - fun get(key: PrefsBool): Flowable { + fun getFlowable(key: PrefsBool): Flowable { val db = Database.getInstance() ?: return Flowable.empty() return db.prefBoolDao().get(key.value) .subscribeOn(Schedulers.computation()) @@ -39,11 +58,10 @@ class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dis } fun observeOrDefault(key: PrefsBool, default: Boolean): Flow { - val db = Database.getInstance() ?: return flowOf(default) + val db = Database.getInstance() ?: return flowOf(default).also { Timber.e("observe bool ; DB not available") } return db.prefBoolDao().observe(key.value) .flowOn(Dispatchers.IO) .map { it?.value ?: default } - .distinctUntilChanged() } fun observeOrDefault(key: PrefsString, default: String): Flow { @@ -70,7 +88,7 @@ class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dis .distinctUntilChanged() } - fun get(key: String): Flowable { + fun getFlowable(key: String): Flowable { val db = Database.getInstance() ?: return Flowable.empty() return db.prefIntDao().get(key) .subscribeOn(Schedulers.computation()) @@ -124,7 +142,10 @@ class PrefRepository @Inject constructor(): CoroutineScope by CoroutineScope(Dis fun set(key: PrefsBool, value: Boolean) { launch { - Database.getInstance()?.prefBoolDao()?.insert(PrefBool(key.value, value)) + runCatching { + val db = Database.getInstance() ?: throw IllegalStateException("No DB") + db.prefBoolDao().insert(PrefBool(key.value, value)) + }.onFailure { Timber.d(it.message) }.onSuccess { Timber.d("saved ${key.value} => $value") } } } diff --git a/api/src/main/java/com/getcode/network/repository/SendTransactionRepository.kt b/api/src/main/java/com/getcode/network/repository/SendTransactionRepository.kt index c57d89aa1..c2fb28c55 100644 --- a/api/src/main/java/com/getcode/network/repository/SendTransactionRepository.kt +++ b/api/src/main/java/com/getcode/network/repository/SendTransactionRepository.kt @@ -80,7 +80,6 @@ class SendTransactionRepository @Inject constructor( amount = amount, successful = false ) - ErrorUtils.handleError(it) } } diff --git a/api/src/main/java/com/getcode/network/repository/TransactionRepository.kt b/api/src/main/java/com/getcode/network/repository/TransactionRepository.kt index ca8bf5fe4..0cb61b09b 100644 --- a/api/src/main/java/com/getcode/network/repository/TransactionRepository.kt +++ b/api/src/main/java/com/getcode/network/repository/TransactionRepository.kt @@ -30,21 +30,14 @@ import com.getcode.solana.organizer.AccountType import com.getcode.solana.organizer.GiftCardAccount import com.getcode.solana.organizer.Organizer import com.getcode.utils.ErrorUtils -import com.google.protobuf.ByteString import com.google.protobuf.Timestamp import dagger.hilt.android.qualifiers.ApplicationContext import io.grpc.stub.StreamObserver import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.subjects.SingleSubject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOn @@ -61,17 +54,13 @@ private const val TAG = "TransactionRepositoryV2" @Singleton class TransactionRepository @Inject constructor( @ApplicationContext private val context: Context, - private val transactionApi: TransactionApiV2 + private val transactionApi: TransactionApiV2, ) : CoroutineScope by CoroutineScope(Dispatchers.IO) { var sendLimit = mutableListOf() var maxDeposit: Long = 0 - private var _transactionCache = MutableStateFlow?>(null) - val transactionCache: StateFlow?> - get() = _transactionCache.asStateFlow() - fun setMaximumDeposit(deposit: Long) { maxDeposit = deposit @@ -84,7 +73,6 @@ class TransactionRepository @Inject constructor( fun clear() { Timber.d("clearing transactions") maxDeposit = 0 - _transactionCache.value = null } fun createAccounts(organizer: Organizer): Single { @@ -159,7 +147,11 @@ class TransactionRepository @Inject constructor( return submit(intent = intent, owner = organizer.tray.owner.getCluster().authority.keyPair) } - fun receiveFromRelationship(domain: Domain, amount: Kin, organizer: Organizer): Single { + fun receiveFromRelationship( + domain: Domain, + amount: Kin, + organizer: Organizer + ): Single { val intent = IntentDeposit.newInstance( source = AccountType.Relationship(domain), organizer = organizer, @@ -244,7 +236,7 @@ class TransactionRepository @Inject constructor( return submit(intent, owner = organizer.tray.owner.getCluster().authority.keyPair) } - private fun submit(intent: IntentType, owner: Ed25519.KeyPair): Single { + private fun submit(intent: IntentType, owner: KeyPair): Single { Timber.i("Submit ${intent.javaClass.simpleName}") val subject = SingleSubject.create() @@ -333,7 +325,10 @@ class TransactionRepository @Inject constructor( } @SuppressLint("CheckResult") - fun establishRelationship(organizer: Organizer, domain: Domain): Single { + fun establishRelationship( + organizer: Organizer, + domain: Domain + ): Single { val intent = IntentEstablishRelationship.newInstance(context, organizer, domain) return submit(intent = intent, organizer.tray.owner.getCluster().authority.keyPair) @@ -382,7 +377,10 @@ class TransactionRepository @Inject constructor( } } - suspend fun fetchIntentMetadata(owner: Ed25519.KeyPair, intentId: PublicKey): Result { + suspend fun fetchIntentMetadata( + owner: KeyPair, + intentId: PublicKey + ): Result { val request = TransactionService.GetIntentMetadataRequest.newBuilder() .setIntentId(intentId.toIntentId()) .setOwner(owner.publicKeyBytes.toSolanaAccount()) @@ -410,8 +408,9 @@ class TransactionRepository @Inject constructor( } } + @SuppressLint("CheckResult") fun fetchTransactionLimits( - owner: Ed25519.KeyPair, + owner: KeyPair, timestamp: Long ): Flowable> { val request = TransactionService.GetLimitsRequest.newBuilder() @@ -454,97 +453,6 @@ class TransactionRepository @Inject constructor( .map { it.associateBy { i -> i.id } } } - fun fetchPaymentHistoryDelta( - owner: Ed25519.KeyPair, - afterId: ByteArray? = null - ): Single> { - return Single.create> { - val container = mutableListOf() - var after: ByteArray? = afterId - - while (true) { - val response = runCatching { - fetchPaymentHistoryPage(owner, after).blockingGet() - }.getOrDefault(emptyList()) - - if (response.isEmpty()) { - // let cache know we're empty here, if it's not initialized yet - if (_transactionCache.value == null) { - _transactionCache.value = emptyList() - } - break - } - container.addAll(response) - _transactionCache.value = _transactionCache.value.orEmpty() - .filterNot { item -> response.contains(item) } - .plus(response) - .toMutableList() - after = response.lastOrNull()?.id?.toByteArray() - } - container.reverse() - it.onSuccess(container) - }.subscribeOn(Schedulers.io()).onErrorReturn { emptyList() } - } - - private fun fetchPaymentHistoryPage( - owner: Ed25519.KeyPair, - afterId: ByteArray? = null, - pageSize: Int = 100 - ): Single> { - val request = TransactionService.GetPaymentHistoryRequest.newBuilder() - .setOwner(owner.publicKeyBytes.toSolanaAccount()) - .setDirection(TransactionService.GetPaymentHistoryRequest.Direction.ASC) - .setPageSize(pageSize) - .let { - if (afterId != null) { - it.setCursor( - TransactionService.Cursor.newBuilder() - .setValue(ByteString.copyFrom(afterId)) - ) - } else { - it - } - } - .let { - val bos = ByteArrayOutputStream() - it.buildPartial().writeTo(bos) - it.setSignature(Ed25519.sign(bos.toByteArray(), owner).toSignature()) - } - .build() - - return transactionApi.getPaymentHistory(request) - .map { response -> - if (response.result == TransactionService.GetPaymentHistoryResponse.Result.OK) { - response.itemsList.map Response@{ item -> - val currency = - CurrencyCode.tryValueOf(item.exchangeData.currency.uppercase()) - ?: return@Response null - - HistoricalTransaction( - id = item.cursor.value.toByteArray().toList(), - paymentType = PaymentType.tryValueOf(item.paymentType.name) - ?: PaymentType.Unknown, - date = item.timestamp.seconds, - transactionRateFx = item.exchangeData.exchangeRate, - transactionRateCurrency = currency.name, - transactionAmountQuarks = item.exchangeData.quarks, - nativeAmount = item.exchangeData.nativeAmount, - isDeposit = item.isDeposit, - isWithdrawal = item.isWithdraw, - isRemoteSend = item.isRemoteSend, - isReturned = item.isReturned, - airdropType = item.isAirdrop.ifElse( - AirdropType.getInstance(item.airdropType), - null - ), - ) - }.filterNotNull() - } else { - emptyList() - } - } - } - fun fetchDestinationMetadata(destination: PublicKey): Single { val request = TransactionService.CanWithdrawToAccountRequest.newBuilder() .setAccount(destination.bytes.toSolanaAccount()) @@ -567,7 +475,7 @@ class TransactionRepository @Inject constructor( } } - fun fetchUpgradeableIntents(owner: Ed25519.KeyPair): Single> { + fun fetchUpgradeableIntents(owner: KeyPair): Single> { val request = TransactionService.GetPrioritizedIntentsForPrivacyUpgradeRequest.newBuilder() .setOwner(owner.publicKeyBytes.toSolanaAccount()) .setLimit(100) //TODO: implement paging diff --git a/api/src/main/java/com/getcode/network/service/ChatService.kt b/api/src/main/java/com/getcode/network/service/ChatService.kt new file mode 100644 index 000000000..2d94ec3cc --- /dev/null +++ b/api/src/main/java/com/getcode/network/service/ChatService.kt @@ -0,0 +1,168 @@ +package com.getcode.network.service + +import com.codeinc.gen.chat.v1.ChatService +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.mapper.ChatMessageMapper +import com.getcode.mapper.ChatMetadataMapper +import com.getcode.model.Chat +import com.getcode.model.ChatMessage +import com.getcode.model.Cursor +import com.getcode.model.ID +import com.getcode.network.api.ChatApi +import com.getcode.network.core.NetworkOracle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import timber.log.Timber +import javax.inject.Inject +import kotlin.reflect.KClass + +/** + * Abstraction layer to handle [ChatApi] request results and map to domain models + */ +class ChatService @Inject constructor( + private val api: ChatApi, + private val chatMapper: ChatMetadataMapper, + private val messageMapper: ChatMessageMapper, + private val networkOracle: NetworkOracle, +) { + + fun observeChats(owner: KeyPair): Flow>> { + return networkOracle.managedRequest(api.fetchChats(owner)) + .map { response -> + when (response.result) { + ChatService.GetChatsResponse.Result.OK -> { + Result.success(response.chatsList.map(chatMapper::map)) + } + + ChatService.GetChatsResponse.Result.NOT_FOUND -> { + val error = Throwable("Error: chats not found for owner") + Timber.e(t = error) + Result.failure(error) + } + + ChatService.GetChatsResponse.Result.UNRECOGNIZED -> { + val error = Throwable("Error: Unrecognized request.") + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = Throwable("Error: Unknown") + Timber.e(t = error) + Result.failure(error) + } + } + } + } + + @Throws(NoSuchElementException::class) + suspend fun fetchChats(owner: KeyPair): Result> { + return observeChats(owner).first() + } + + suspend fun setMuteState(owner: KeyPair, chatId: ID, muted: Boolean): Result { + return networkOracle.managedRequest(api.setMuteState(owner, chatId, muted)) + .map { response -> + when (response.result) { + ChatService.SetMuteStateResponse.Result.OK -> { + Result.success(muted) + } + + ChatService.SetMuteStateResponse.Result.CHAT_NOT_FOUND -> { + val error = Throwable("Error: chat not found for $chatId") + Timber.e(t = error) + Result.failure(error) + } + + ChatService.SetMuteStateResponse.Result.CANT_MUTE -> { + val error = Throwable("Error: Unable to change mute state for $chatId.") + Timber.e(t = error) + Result.failure(error) + } + + ChatService.SetMuteStateResponse.Result.UNRECOGNIZED -> { + val error = Throwable("Error: Unrecognized request.") + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = Throwable("Error: Unknown") + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } + + suspend fun fetchMessagesFor( + owner: KeyPair, + chatId: ID, + cursor: Cursor? = null, + limit: Int? = null + ): Result> { + return networkOracle.managedRequest(api.fetchChatMessages(owner, chatId, cursor, limit)) + .map { response -> + when (response.result) { + ChatService.GetMessagesResponse.Result.OK -> { + Result.success(response.messagesList.map(messageMapper::map)) + } + + ChatService.GetMessagesResponse.Result.NOT_FOUND -> { + val error = Throwable("Error: messages not found for chat $chatId") + Timber.e(t = error) + Result.failure(error) + } + + ChatService.GetMessagesResponse.Result.UNRECOGNIZED -> { + val error = Throwable("Error: Unrecognized request.") + Timber.e(t = error) + Result.failure(error) + } + + else -> { + val error = Throwable("Error: Unknown") + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } + + suspend fun advancePointer( + owner: KeyPair, + chatId: ID, + to: ID, + ): Result { + return networkOracle.managedRequest(api.advancePointer(owner, chatId, to)) + .map { response -> + when (response.result) { + ChatService.AdvancePointerResponse.Result.OK -> { + Result.success(Unit) + } + ChatService.AdvancePointerResponse.Result.CHAT_NOT_FOUND -> { + val error = Throwable("Error: chat not found $chatId") + Timber.e(t = error) + Result.failure(error) + } + ChatService.AdvancePointerResponse.Result.MESSAGE_NOT_FOUND -> { + val error = Throwable("Error: message not found $to") + Timber.e(t = error) + Result.failure(error) + } + ChatService.AdvancePointerResponse.Result.UNRECOGNIZED -> { + val error = Throwable("Error: Unrecognized request.") + Timber.e(t = error) + Result.failure(error) + } + else -> { + val error = Throwable("Error: Unknown") + Timber.e(t = error) + Result.failure(error) + } + } + }.first() + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/source/ChatMessagePagingSource.kt b/api/src/main/java/com/getcode/network/source/ChatMessagePagingSource.kt new file mode 100644 index 000000000..b4722c8b9 --- /dev/null +++ b/api/src/main/java/com/getcode/network/source/ChatMessagePagingSource.kt @@ -0,0 +1,49 @@ +package com.getcode.network.source + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.model.ChatMessage +import com.getcode.model.Cursor +import com.getcode.model.ID +import com.getcode.network.client.Client +import com.getcode.network.client.fetchMessagesFor + +class ChatMessagePagingSource( + private val client: Client, + private val owner: KeyPair, + private val chatId: ID, +) : PagingSource() { + override suspend fun load( + params: LoadParams + ): LoadResult { + // Start refresh at page 1 if undefined. + val nextCursor = params.key + val response = client.fetchMessagesFor(owner, chatId, cursor = nextCursor, limit = 20) + + response.exceptionOrNull()?.let { + return LoadResult.Error(it) + } + + val messages = response.getOrDefault(emptyList()) + return LoadResult.Page( + data = messages, + prevKey = null, // Only paging forward. + nextKey = messages.last().cursor + ) + } + + override fun getRefreshKey(state: PagingState): Cursor? { + // Try to find the page key of the closest page to anchorPosition from + // either the prevKey or the nextKey; you need to handle nullability + // here. + // * prevKey == null -> anchorPage is the first page. + // * nextKey == null -> anchorPage is the last page. + // * both prevKey and nextKey are null -> anchorPage is the + // initial page, so return null. + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) + } + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/utils/ErrorUtils.kt b/api/src/main/java/com/getcode/utils/ErrorUtils.kt index e77ce4fa7..9583f8010 100644 --- a/api/src/main/java/com/getcode/utils/ErrorUtils.kt +++ b/api/src/main/java/com/getcode/utils/ErrorUtils.kt @@ -28,7 +28,7 @@ object ErrorUtils { throwable.cause ?: throwable else throwable - if (BuildConfig.DEBUG || isDisplayErrors && !isSuppressibleError(throwable)) { + if (BuildConfig.DEBUG || (isDisplayErrors && !isSuppressibleError(throwable))) { Timber.e(throwable) TopBarManager.showMessage( diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 65ee5356a..a8691d9f0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -65,9 +65,9 @@ android { applicationIdSuffix = ".dev" signingConfig = signingConfigs.getByName("contributors") -// isMinifyEnabled = true -// isShrinkResources = true -// proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } @@ -113,9 +113,11 @@ dependencies { implementation(Libs.kotlin_stdlib) implementation(Libs.kotlinx_collections_immutable) implementation(Libs.kotlinx_serialization_json) + implementation(Libs.kotlinx_datetime) implementation(Libs.androidx_core) implementation(Libs.androidx_constraint_layout) implementation(Libs.androidx_lifecycle_runtime) + implementation(Libs.androidx_lifecycle_viewmodel) implementation(Libs.androidx_navigation_fragment) implementation(Libs.androidx_navigation_ui) @@ -138,10 +140,13 @@ dependencies { implementation(Libs.compose_ui_tools_preview) implementation(Libs.compose_foundation) implementation(Libs.compose_material) + implementation(Libs.compose_materialIconsExtended) implementation(Libs.compose_activities) implementation(Libs.compose_view_models) implementation(Libs.compose_livedata) implementation(Libs.compose_navigation) + implementation(Libs.compose_paging) + implementation(Libs.androidx_constraint_layout_compose) implementation(Libs.compose_voyager_navigation) diff --git a/app/src/main/java/com/getcode/CodeApp.kt b/app/src/main/java/com/getcode/CodeApp.kt index 00b70cb09..688eaf101 100644 --- a/app/src/main/java/com/getcode/CodeApp.kt +++ b/app/src/main/java/com/getcode/CodeApp.kt @@ -30,17 +30,16 @@ import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.LoginScreen import com.getcode.navigation.screens.MainRoot import com.getcode.navigation.transitions.SheetSlideTransition -import com.getcode.theme.Brand import com.getcode.theme.CodeTheme import com.getcode.theme.LocalCodeColors -import com.getcode.util.getActivity -import com.getcode.util.getActivityScopedViewModel -import com.getcode.util.measured -import com.getcode.view.components.AuthCheck -import com.getcode.view.components.BottomBarContainer -import com.getcode.view.components.CodeScaffold -import com.getcode.view.components.TitleBar -import com.getcode.view.components.TopBarContainer +import com.getcode.ui.utils.getActivity +import com.getcode.ui.utils.getActivityScopedViewModel +import com.getcode.ui.utils.measured +import com.getcode.ui.components.AuthCheck +import com.getcode.ui.components.BottomBarContainer +import com.getcode.ui.components.CodeScaffold +import com.getcode.ui.components.TitleBar +import com.getcode.ui.components.TopBarContainer @Composable fun CodeApp() { diff --git a/app/src/main/java/com/getcode/Locals.kt b/app/src/main/java/com/getcode/Locals.kt index 4a120764b..7c2adf3cd 100644 --- a/app/src/main/java/com/getcode/Locals.kt +++ b/app/src/main/java/com/getcode/Locals.kt @@ -5,6 +5,8 @@ import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.staticCompositionLocalOf import com.getcode.analytics.AnalyticsService import com.getcode.analytics.AnalyticsServiceNull +import com.getcode.network.exchange.Exchange +import com.getcode.network.exchange.ExchangeNull import com.getcode.util.CurrencyUtils import com.getcode.util.DeeplinkHandler import com.getcode.util.PhoneUtils @@ -15,5 +17,7 @@ val LocalAnalytics: ProvidableCompositionLocal = staticComposi val LocalNetworkObserver: ProvidableCompositionLocal = staticCompositionLocalOf { NetworkObserverStub() } val LocalPhoneFormatter: ProvidableCompositionLocal = staticCompositionLocalOf { null } val LocalCurrencyUtils: ProvidableCompositionLocal = staticCompositionLocalOf { null } +val LocalExchange: ProvidableCompositionLocal = staticCompositionLocalOf { ExchangeNull() } val LocalDeeplinks: ProvidableCompositionLocal = staticCompositionLocalOf { null } val LocalTopBarPadding: ProvidableCompositionLocal = staticCompositionLocalOf { PaddingValues() } + diff --git a/app/src/main/java/com/getcode/TopLevelViewModel.kt b/app/src/main/java/com/getcode/TopLevelViewModel.kt index eee2772b5..e22162339 100644 --- a/app/src/main/java/com/getcode/TopLevelViewModel.kt +++ b/app/src/main/java/com/getcode/TopLevelViewModel.kt @@ -1,63 +1,18 @@ package com.getcode import android.app.Activity -import androidx.lifecycle.viewModelScope -import com.getcode.data.transactions.toUi import com.getcode.manager.AuthManager -import com.getcode.manager.SessionManager -import com.getcode.network.client.Client -import com.getcode.network.client.historicalTransactions -import com.getcode.network.client.observeTransactions import com.getcode.util.resources.ResourceHelper -import com.getcode.utils.network.NetworkConnectivityListener -import com.getcode.utils.network.SignalStrength import com.getcode.view.BaseViewModel -import com.getcode.view.main.balance.BalanceSheetViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import timber.log.Timber import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds -@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class TopLevelViewModel @Inject constructor( private val authManager: AuthManager, - private val client: Client, - networkObserver: NetworkConnectivityListener, resources: ResourceHelper, ) : BaseViewModel(resources) { - init { - SessionManager.authState - .map { it.keyPair } - .filterNotNull() - .flatMapLatest { networkObserver.state } - .distinctUntilChanged() - .onEach { Timber.d("it=$it") } - .filter { it.connected && it.signalStrength.isKnown() && it.type.isValid() } - .map { it.connected } - .distinctUntilChanged() - .filter { it } - .onEach { client.pollOnce(delay = 0) } - .flatMapLatest { - client.observeTransactions() - .flowOn(Dispatchers.IO) - }.launchIn(viewModelScope) - } + fun logout(activity: Activity, onComplete: () -> Unit = {}) { authManager.logout(activity, onComplete) } diff --git a/app/src/main/java/com/getcode/analytics/AnalyticsWatcher.kt b/app/src/main/java/com/getcode/analytics/AnalyticsWatcher.kt index e8f6cfce4..304a18492 100644 --- a/app/src/main/java/com/getcode/analytics/AnalyticsWatcher.kt +++ b/app/src/main/java/com/getcode/analytics/AnalyticsWatcher.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import com.getcode.LocalAnalytics -import com.getcode.util.RepeatOnLifecycle +import com.getcode.ui.utils.RepeatOnLifecycle @Composable fun AnalyticsWatcher( diff --git a/app/src/main/java/com/getcode/data/transactions/HistoricalTransactionUiModel.kt b/app/src/main/java/com/getcode/data/transactions/HistoricalTransactionUiModel.kt deleted file mode 100644 index d3f55732f..000000000 --- a/app/src/main/java/com/getcode/data/transactions/HistoricalTransactionUiModel.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.getcode.data.transactions - -import com.getcode.model.AirdropType -import com.getcode.model.Currency -import com.getcode.model.HistoricalTransaction -import com.getcode.model.Kin -import com.getcode.model.PaymentType -import com.getcode.util.DateUtils -import com.getcode.util.Kin -import com.getcode.util.flagResId -import com.getcode.util.format -import com.getcode.util.resources.ResourceHelper -import com.getcode.utils.FormatUtils - -fun HistoricalTransaction.toUi( - currencyLookup: (String) -> Currency?, - resources: ResourceHelper, -): HistoricalTransactionUiModel { - val currency = currencyLookup( transactionRateCurrency?.uppercase().orEmpty()) ?: Currency.Kin - - val isKin = currency.code == "KIN" - val currencyResId = currency.flagResId(resources) - - val kinAmount = Kin.fromQuarks(transactionAmountQuarks) - val amount: Double = - if (isKin) kinAmount.toKinTruncatingLong().toDouble() - else nativeAmount - - val amountText = currency.format(resources, amount) - - return HistoricalTransactionUiModel( - id = id, - amountText = amountText, - dateText = DateUtils.getDateWithToday(date * 1000L), - isKin = isKin, - kinAmountText = FormatUtils.formatWholeRoundDown( - kinAmount.toKinTruncatingLong().toDouble() - ), - paymentType = paymentType, - currencyResourceId = currencyResId, - isWithdrawal = isWithdrawal, - isRemoteSend = isRemoteSend, - isDeposit = isDeposit, - isReturned = isReturned, - airdropType = airdropType - ) -} - -data class HistoricalTransactionUiModel( - val id: List, - val amountText: String = "", - val dateText: String = "", - val isKin: Boolean = false, - val kinAmountText: String = "", - val paymentType: PaymentType, - val currencyResourceId: Int? = 0, - val isWithdrawal: Boolean = false, - val isRemoteSend: Boolean = false, - val isDeposit: Boolean = false, - val isReturned: Boolean = false, - val airdropType: AirdropType? -) \ No newline at end of file diff --git a/app/src/main/java/com/getcode/inject/ApiModule.kt b/app/src/main/java/com/getcode/inject/ApiModule.kt index 0d4088e7f..5515fc7f5 100644 --- a/app/src/main/java/com/getcode/inject/ApiModule.kt +++ b/app/src/main/java/com/getcode/inject/ApiModule.kt @@ -2,8 +2,11 @@ package com.getcode.inject import android.content.Context import com.getcode.BuildConfig +import com.getcode.R import com.getcode.analytics.AnalyticsService +import com.getcode.model.Currency import com.getcode.network.BalanceController +import com.getcode.network.HistoryController import com.getcode.network.PrivacyMigration import com.getcode.network.api.CurrencyApi import com.getcode.network.api.TransactionApiV2 @@ -11,6 +14,7 @@ import com.getcode.network.client.Client import com.getcode.network.client.TransactionReceiver import com.getcode.network.core.NetworkOracle import com.getcode.network.core.NetworkOracleImpl +import com.getcode.network.exchange.CodeExchange import com.getcode.network.exchange.Exchange import com.getcode.network.repository.AccountRepository import com.getcode.network.repository.BalanceRepository @@ -20,6 +24,10 @@ import com.getcode.network.repository.TransactionRepository import com.getcode.util.AccountAuthenticator import com.getcode.util.locale.LocaleHelper import com.getcode.utils.network.NetworkConnectivityListener +import com.getcode.network.service.ChatService +import com.getcode.util.CurrencyUtils +import com.getcode.util.Kin +import com.getcode.util.resources.ResourceHelper import com.mixpanel.android.mpmetrics.MixpanelAPI import dagger.Module import dagger.Provides @@ -31,6 +39,8 @@ import io.grpc.android.AndroidChannelBuilder import io.reactivex.rxjava3.core.Scheduler import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.kin.sdk.base.network.api.agora.OkHttpChannelBuilderForcedTls12 import java.util.concurrent.TimeUnit import javax.inject.Singleton @@ -95,22 +105,41 @@ object ApiModule { @Singleton @Provides fun provideBalanceController( + exchange: Exchange, @ApplicationContext context: Context, balanceRepository: BalanceRepository, transactionRepository: TransactionRepository, accountRepository: AccountRepository, privacyMigration: PrivacyMigration, - transactionReceiver: TransactionReceiver + transactionReceiver: TransactionReceiver, + networkObserver: NetworkConnectivityListener, + locale: LocaleHelper, + resources: ResourceHelper, + currencyUtils: CurrencyUtils, ): BalanceController { return BalanceController( - context, - balanceRepository, - transactionRepository, - accountRepository, - privacyMigration, - transactionReceiver, - - ) + exchange = exchange, + context = context, + balanceRepository = balanceRepository, + transactionRepository = transactionRepository, + accountRepository = accountRepository, + privacyMigration = privacyMigration, + transactionReceiver = transactionReceiver, + networkObserver = networkObserver, + getCurrency = { rates -> + withContext(Dispatchers.Default) { + val defaultCurrencyCode = locale.getDefaultCurrency()?.code + return@withContext currencyUtils.getCurrenciesWithRates(rates) + .firstOrNull { p -> + p.code == defaultCurrencyCode + } ?: Currency.Kin + } + }, + getDefaultCountry = { + locale.getDefaultCountry() + }, + suffix = { resources.getString(R.string.core_ofKin) } + ) } @Singleton @@ -119,7 +148,7 @@ object ApiModule { currencyApi: CurrencyApi, networkOracle: NetworkOracle, locale: LocaleHelper - ) = Exchange(currencyApi, networkOracle) { + ): Exchange = CodeExchange(currencyApi, networkOracle) { locale.getDefaultCurrency() } @@ -136,6 +165,7 @@ object ApiModule { transactionReceiver: TransactionReceiver, exchange: Exchange, networkObserver: NetworkConnectivityListener, + chatService: ChatService, ): Client { return Client( context, @@ -147,7 +177,8 @@ object ApiModule { prefRepository, exchange, transactionReceiver, - networkObserver + networkObserver, + chatService, ) } diff --git a/app/src/main/java/com/getcode/inject/AppModule.kt b/app/src/main/java/com/getcode/inject/AppModule.kt index 54c11fa0e..649f572db 100644 --- a/app/src/main/java/com/getcode/inject/AppModule.kt +++ b/app/src/main/java/com/getcode/inject/AppModule.kt @@ -10,6 +10,8 @@ import android.os.VibratorManager import android.telephony.TelephonyManager import com.getcode.analytics.AnalyticsManager import com.getcode.analytics.AnalyticsService +import com.getcode.network.exchange.CodeExchange +import com.getcode.network.exchange.Exchange import com.getcode.util.AndroidLocale import com.getcode.util.AndroidPermissions import com.getcode.util.AndroidResources diff --git a/app/src/main/java/com/getcode/manager/AuthManager.kt b/app/src/main/java/com/getcode/manager/AuthManager.kt index 06486976f..2a781bc3f 100644 --- a/app/src/main/java/com/getcode/manager/AuthManager.kt +++ b/app/src/main/java/com/getcode/manager/AuthManager.kt @@ -14,6 +14,7 @@ import com.getcode.model.AirdropType import com.getcode.model.PrefsBool import com.getcode.model.PrefsString import com.getcode.network.BalanceController +import com.getcode.network.HistoryController import com.getcode.network.exchange.Exchange import com.getcode.network.repository.IdentityRepository import com.getcode.network.repository.PhoneRepository @@ -48,6 +49,7 @@ class AuthManager @Inject constructor( private val prefRepository: PrefRepository, private val exchange: Exchange, private val balanceController: BalanceController, + private val historyController: HistoryController, private val inMemoryDao: InMemoryDao, private val analytics: AnalyticsService, ): CoroutineScope by CoroutineScope(Dispatchers.IO) { @@ -175,8 +177,8 @@ class AuthManager @Inject constructor( private fun fetchData(context: Context, entropyB64: String): Single> { - var owner = SessionManager.authState.value?.keyPair - if (owner == null || SessionManager.authState.value?.entropyB64 != entropyB64) { + var owner = SessionManager.authState.value.keyPair + if (owner == null || SessionManager.authState.value.entropyB64 != entropyB64) { owner = MnemonicPhrase.fromEntropyB64(context, entropyB64).getSolanaKeyPair(context) } @@ -198,7 +200,7 @@ class AuthManager @Inject constructor( } .flatMap { user = it - if (SessionManager.authState.value?.entropyB64 != entropyB64) { + if (SessionManager.authState.value.entropyB64 != entropyB64) { sessionManager.set(context, entropyB64) } balanceController.fetchBalance() @@ -208,6 +210,7 @@ class AuthManager @Inject constructor( savePrefs(phone!!, user!!) updateFcmToken(owner, user!!.dataContainerId.toByteArray()) launch { exchange.fetchRatesIfNeeded() } + launch { historyController.fetchChats() } if (!BuildConfig.DEBUG) Bugsnag.setUser(null, phone?.phoneNumber, null) } } @@ -227,15 +230,16 @@ class AuthManager @Inject constructor( analytics.logout() sessionManager.clear() Database.close() + historyController.reset() inMemoryDao.clear() Database.delete(context) if (!BuildConfig.DEBUG) Bugsnag.setUser(null, null, null) } - private fun savePrefs( phone: PhoneRepository.GetAssociatedPhoneNumberResponse, user: IdentityRepository.GetUserResponse ) { + Timber.d("saving prefs") phoneRepository.phoneNumber = phone.phoneNumber prefRepository.set( Pair( @@ -253,6 +257,8 @@ class AuthManager @Inject constructor( PrefsBool.IS_DEBUG_ALLOWED, user.enableDebugOptions, ) + + Timber.d("airdrops eligible = ${user.eligibleAirdrops.joinToString { it.name }}") prefRepository.set( PrefsBool.IS_ELIGIBLE_GET_FIRST_KIN_AIRDROP, user.eligibleAirdrops.contains(AirdropType.GetFirstKin), diff --git a/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt b/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt new file mode 100644 index 000000000..cd592e789 --- /dev/null +++ b/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt @@ -0,0 +1,101 @@ +package com.getcode.navigation.screens + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.paging.compose.collectAsLazyPagingItems +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.hilt.getViewModel +import com.getcode.R +import com.getcode.analytics.AnalyticsManager +import com.getcode.analytics.AnalyticsScreenWatcher +import com.getcode.model.ID +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.ui.utils.getActivityScopedViewModel +import com.getcode.ui.components.chat.localized +import com.getcode.view.main.balance.BalanceSheet +import com.getcode.view.main.balance.BalanceSheetViewModel +import com.getcode.view.main.chat.ChatScreen +import com.getcode.view.main.chat.ChatViewModel +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +data object BalanceModal : ChatGraph, ModalRoot { + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + + override val name: String + @Composable get() = stringResource(id = R.string.title_balance) + + @Composable + override fun Content() { + val navigator = LocalCodeNavigator.current + + val viewModel = getActivityScopedViewModel() + val state by viewModel.stateFlow.collectAsState() + val isViewingBuckets by remember(state.isDebugBucketsVisible) { + derivedStateOf { state.isDebugBucketsVisible } + } + + ModalContainer( + navigator = navigator, + onLogoClicked = {}, + title = { null }, + backButton = { isViewingBuckets }, + onBackClicked = isViewingBuckets.takeIf { it }?.let { + { + viewModel.dispatchEvent( + BalanceSheetViewModel.Event.OnDebugBucketsVisible(false) + ) + } + }, + closeButton = close@{ + if (viewModel.stateFlow.value.isDebugBucketsVisible) return@close false + if (navigator.isVisible) { + it is BalanceModal + } else { + navigator.progress > 0f + } + }, + onCloseClicked = null, + ) { + BalanceSheet(state = state, dispatch = viewModel::dispatchEvent) + } + + AnalyticsScreenWatcher( + lifecycleOwner = LocalLifecycleOwner.current, + event = AnalyticsManager.Screen.Balance + ) + } +} + +@Parcelize +data class ChatScreen(val chatId: ID) : ChatGraph, ModalContent { + @IgnoredOnParcel + override val key: ScreenKey = uniqueScreenKey + + @Composable + override fun Content() { + val vm = getViewModel() + val state by vm.stateFlow.collectAsState() + ModalContainer( + title = { state.title.localized }, + backButton = { it is ChatScreen }, + ) { + val messages = vm.chatMessages.collectAsLazyPagingItems() + ChatScreen(state = state, messages = messages, dispatch = vm::dispatchEvent) + } + + LaunchedEffect(chatId) { + vm.dispatchEvent(ChatViewModel.Event.OnChatIdChanged(chatId)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/navigation/screens/Graphs.kt b/app/src/main/java/com/getcode/navigation/screens/Graphs.kt index 86a3b4f19..5e26cb156 100644 --- a/app/src/main/java/com/getcode/navigation/screens/Graphs.kt +++ b/app/src/main/java/com/getcode/navigation/screens/Graphs.kt @@ -1,11 +1,7 @@ package com.getcode.navigation.screens import android.os.Parcelable -import androidx.compose.runtime.Composable import cafe.adriel.voyager.core.screen.Screen -import com.getcode.util.getActivityScopedViewModel -import com.getcode.view.main.currency.CurrencyViewModel -import com.getcode.view.main.home.HomeViewModel import kotlinx.parcelize.Parcelize /** @@ -52,4 +48,13 @@ data class WithdrawalArgs( val currencyResId: Int? = null, val currencyRate: Double? = null, val resolvedDestination: String? = null, -): Parcelable \ No newline at end of file +): Parcelable + +/** + * Nested graph for the on-chain messaging screens (balance) + */ +@Parcelize +internal sealed interface ChatGraph : Screen, Parcelable, NamedScreen { + + fun readResolve(): Any = this +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/navigation/screens/LoginScreens.kt b/app/src/main/java/com/getcode/navigation/screens/LoginScreens.kt index d6ccc4e83..ddf43ffb9 100644 --- a/app/src/main/java/com/getcode/navigation/screens/LoginScreens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/LoginScreens.kt @@ -9,7 +9,7 @@ import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.hilt.getViewModel import com.getcode.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.util.getStackScopedViewModel +import com.getcode.ui.utils.getStackScopedViewModel import com.getcode.view.login.AccessKey import com.getcode.view.login.AccessKeyViewModel import com.getcode.view.login.CameraPermission diff --git a/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt b/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt index c8730f5ea..89f7055b3 100644 --- a/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt @@ -1,10 +1,6 @@ package com.getcode.navigation.screens import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.lifecycle.Lifecycle @@ -12,17 +8,16 @@ import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.hilt.getViewModel import com.getcode.R -import com.getcode.analytics.AnalyticsScreenWatcher import com.getcode.analytics.AnalyticsManager +import com.getcode.analytics.AnalyticsScreenWatcher import com.getcode.model.KinAmount import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.util.RepeatOnLifecycle -import com.getcode.util.getActivityScopedViewModel -import com.getcode.view.components.startupLog +import com.getcode.ui.components.startupLog +import com.getcode.ui.utils.RepeatOnLifecycle +import com.getcode.ui.utils.getActivityScopedViewModel +import com.getcode.ui.utils.getStackScopedViewModel import com.getcode.view.main.account.AccountHome import com.getcode.view.main.account.AccountSheetViewModel -import com.getcode.view.main.balance.BalanceSheet -import com.getcode.view.main.balance.BalanceSheetViewModel import com.getcode.view.main.getKin.GetKinSheet import com.getcode.view.main.giveKin.GiveKinSheet import com.getcode.view.main.home.HomeScreen @@ -51,7 +46,7 @@ data class HomeScreen( @Composable override fun Content() { - val vm = getActivityScopedViewModel() + val vm = getViewModel() startupLog("home rendered") HomeScreen(vm, cashLink, requestPayload) @@ -130,56 +125,6 @@ data object GiveKinModal : AppScreen(), MainGraph, ModalRoot { } } -@Parcelize -data object BalanceModal : MainGraph, ModalRoot { - @IgnoredOnParcel - override val key: ScreenKey = uniqueScreenKey - - - override val name: String - @Composable get() = stringResource(id = R.string.title_balance) - - @Composable - override fun Content() { - val navigator = LocalCodeNavigator.current - - val viewModel = getViewModel() - val state by viewModel.stateFlow.collectAsState() - val isViewingBuckets by remember(state.isDebugBucketsVisible) { - derivedStateOf { state.isDebugBucketsVisible } - } - - ModalContainer( - navigator = navigator, - onLogoClicked = {}, - backButton = { isViewingBuckets }, - onBackClicked = isViewingBuckets.takeIf { it }?.let { - { - viewModel.dispatchEvent( - BalanceSheetViewModel.Event.OnDebugBucketsVisible(false) - ) - } - }, - closeButton = close@{ - if (viewModel.stateFlow.value.isDebugBucketsVisible) return@close false - if (navigator.isVisible) { - it is BalanceModal - } else { - navigator.progress > 0f - } - }, - onCloseClicked = null, - ) { - BalanceSheet(state = state, dispatch = viewModel::dispatchEvent) - } - - AnalyticsScreenWatcher( - lifecycleOwner = LocalLifecycleOwner.current, - event = AnalyticsManager.Screen.Balance - ) - } -} - @Parcelize data object AccountModal : MainGraph, ModalRoot { @IgnoredOnParcel @@ -191,6 +136,7 @@ data object AccountModal : MainGraph, ModalRoot { val viewModel = getActivityScopedViewModel() ModalContainer( displayLogo = true, + title = { null }, onLogoClicked = { viewModel.dispatchEvent(AccountSheetViewModel.Event.LogoClicked) }, closeButton = { if (navigator.isVisible) { diff --git a/app/src/main/java/com/getcode/navigation/screens/ModalScreens.kt b/app/src/main/java/com/getcode/navigation/screens/ModalScreens.kt index cd417a4f4..b55f96ac3 100644 --- a/app/src/main/java/com/getcode/navigation/screens/ModalScreens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/ModalScreens.kt @@ -10,8 +10,8 @@ import com.getcode.R import com.getcode.analytics.AnalyticsManager import com.getcode.analytics.AnalyticsScreenWatcher import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.util.getActivityScopedViewModel -import com.getcode.util.getStackScopedViewModel +import com.getcode.ui.utils.getActivityScopedViewModel +import com.getcode.ui.utils.getStackScopedViewModel import com.getcode.view.login.PhoneConfirm import com.getcode.view.login.PhoneVerify import com.getcode.view.login.PhoneVerifyViewModel diff --git a/app/src/main/java/com/getcode/navigation/screens/Modals.kt b/app/src/main/java/com/getcode/navigation/screens/Modals.kt index 15909b4a8..4bc44f647 100644 --- a/app/src/main/java/com/getcode/navigation/screens/Modals.kt +++ b/app/src/main/java/com/getcode/navigation/screens/Modals.kt @@ -25,8 +25,8 @@ import cafe.adriel.voyager.core.screen.uniqueScreenKey import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme -import com.getcode.view.components.SheetTitle -import com.getcode.view.components.keyboardAsState +import com.getcode.ui.components.SheetTitle +import com.getcode.ui.components.keyboardAsState import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -68,6 +68,7 @@ internal fun Screen.ModalContainer( internal fun Screen.ModalContainer( navigator: CodeNavigator = LocalCodeNavigator.current, displayLogo: Boolean = false, + title: @Composable (Screen?) -> String? = { null }, backButton: (Screen?) -> Boolean = { false }, onBackClicked: (() -> Unit)? = null, closeButton: (Screen?) -> Boolean = { false }, @@ -108,11 +109,12 @@ internal fun Screen.ModalContainer( SheetTitle( modifier = Modifier, title = { - val name = (lastItem as? NamedScreen)?.name + val screenName = (lastItem as? NamedScreen)?.name val sheetName by remember(lastItem) { - derivedStateOf { name } + derivedStateOf { screenName } } - sheetName.takeIf { !displayLogo && lastItem == this@ModalContainer } + val name = title(lastItem) ?: sheetName + name.takeIf { !displayLogo && lastItem == this@ModalContainer } }, displayLogo = displayLogo, onLogoClicked = onLogoClicked, diff --git a/app/src/main/java/com/getcode/navigation/screens/PhoneCountrySelection.kt b/app/src/main/java/com/getcode/navigation/screens/PhoneCountrySelection.kt index f53b6d814..3ae45c9ff 100644 --- a/app/src/main/java/com/getcode/navigation/screens/PhoneCountrySelection.kt +++ b/app/src/main/java/com/getcode/navigation/screens/PhoneCountrySelection.kt @@ -24,7 +24,7 @@ import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.theme.White05 import com.getcode.util.PhoneUtils -import com.getcode.util.rememberedClickable +import com.getcode.ui.utils.rememberedClickable import com.getcode.view.login.PhoneVerifyUiModel import com.getcode.view.login.PhoneVerifyViewModel diff --git a/app/src/main/java/com/getcode/theme/Color.kt b/app/src/main/java/com/getcode/theme/Color.kt index b1a6ccb60..18cfa5216 100644 --- a/app/src/main/java/com/getcode/theme/Color.kt +++ b/app/src/main/java/com/getcode/theme/Color.kt @@ -7,6 +7,7 @@ val Brand = Color(0xff0F0C1F) val BrandLight = Color(0xFF7379A0) val BrandSubtle = Color(0xFF565C86) val BrandMuted = Color(0xFF45464E) +val BrandDark = Color(0xFF1F1A34) val Brand01 = Color(0xFF130F27) val White = Color(0xffffffff) diff --git a/app/src/main/java/com/getcode/view/components/AccessKeySelectionContainer.kt b/app/src/main/java/com/getcode/ui/components/AccessKeySelectionContainer.kt similarity index 95% rename from app/src/main/java/com/getcode/view/components/AccessKeySelectionContainer.kt rename to app/src/main/java/com/getcode/ui/components/AccessKeySelectionContainer.kt index 969c9be58..4f1294fe4 100644 --- a/app/src/main/java/com/getcode/view/components/AccessKeySelectionContainer.kt +++ b/app/src/main/java/com/getcode/ui/components/AccessKeySelectionContainer.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import android.widget.Toast import androidx.compose.animation.core.animateFloatAsState @@ -21,8 +21,8 @@ import androidx.compose.ui.platform.LocalTextToolbar import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.text.AnnotatedString import androidx.lifecycle.Lifecycle -import com.getcode.util.rememberedLongClickable -import com.getcode.util.swallowClicks +import com.getcode.ui.utils.rememberedLongClickable +import com.getcode.ui.utils.swallowClicks import com.getcode.util.vibration.LocalVibrator class AccessKeySelectionContainerState(val words: String = "") { diff --git a/app/src/main/java/com/getcode/view/components/AuthCheck.kt b/app/src/main/java/com/getcode/ui/components/AuthCheck.kt similarity index 97% rename from app/src/main/java/com/getcode/view/components/AuthCheck.kt rename to app/src/main/java/com/getcode/ui/components/AuthCheck.kt index de8b27b4d..ab004650d 100644 --- a/app/src/main/java/com/getcode/view/components/AuthCheck.kt +++ b/app/src/main/java/com/getcode/ui/components/AuthCheck.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import android.content.Context import androidx.compose.runtime.Composable @@ -21,14 +21,12 @@ import com.getcode.navigation.screens.LoginGraph import com.getcode.navigation.screens.LoginScreen import com.getcode.util.DeeplinkHandler import com.getcode.util.DeeplinkResult -import com.getcode.util.getActivity +import com.getcode.ui.utils.getActivity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn diff --git a/app/src/main/java/com/getcode/ui/components/Badge.kt b/app/src/main/java/com/getcode/ui/components/Badge.kt new file mode 100644 index 000000000..baf410f57 --- /dev/null +++ b/app/src/main/java/com/getcode/ui/components/Badge.kt @@ -0,0 +1,33 @@ +package com.getcode.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import com.getcode.theme.CodeTheme +import com.getcode.ui.utils.circleBackground + + +@Composable +fun Badge( + modifier: Modifier = Modifier, + count: Int, + color: Color = CodeTheme.colors.brand, + contentColor: Color = Color.White, +) { + AnimatedVisibility(visible = count > 0) { + val text = when { + count in 1..99 -> "$count" + else -> "99+" + } + + Text( + text = text, + color = contentColor, + style = CodeTheme.typography.body1.copy(fontWeight = FontWeight.W700), + modifier = modifier.circleBackground(color = color, padding = CodeTheme.dimens.grid.x1) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/components/BottomBarContainer.kt b/app/src/main/java/com/getcode/ui/components/BottomBarContainer.kt similarity index 97% rename from app/src/main/java/com/getcode/view/components/BottomBarContainer.kt rename to app/src/main/java/com/getcode/ui/components/BottomBarContainer.kt index f16ff6178..a25bc741e 100644 --- a/app/src/main/java/com/getcode/view/components/BottomBarContainer.kt +++ b/app/src/main/java/com/getcode/ui/components/BottomBarContainer.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.animation.* import androidx.compose.animation.core.MutableTransitionState @@ -11,7 +11,7 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import com.getcode.CodeAppState import com.getcode.manager.BottomBarManager -import com.getcode.util.rememberedClickable +import com.getcode.ui.utils.rememberedClickable import java.util.* import kotlin.concurrent.timerTask diff --git a/app/src/main/java/com/getcode/view/components/BottomBarView.kt b/app/src/main/java/com/getcode/ui/components/BottomBarView.kt similarity index 98% rename from app/src/main/java/com/getcode/view/components/BottomBarView.kt rename to app/src/main/java/com/getcode/ui/components/BottomBarView.kt index 0266c69c0..4c77c66d3 100644 --- a/app/src/main/java/com/getcode/view/components/BottomBarView.kt +++ b/app/src/main/java/com/getcode/ui/components/BottomBarView.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.activity.compose.BackHandler import androidx.compose.foundation.background @@ -21,7 +21,7 @@ import com.getcode.manager.BottomBarManager import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.theme.White -import com.getcode.util.rememberedClickable +import com.getcode.ui.utils.rememberedClickable @Composable fun BottomBarView( diff --git a/app/src/main/java/com/getcode/view/components/Cloudy.kt b/app/src/main/java/com/getcode/ui/components/Cloudy.kt similarity index 95% rename from app/src/main/java/com/getcode/view/components/Cloudy.kt rename to app/src/main/java/com/getcode/ui/components/Cloudy.kt index 8c44bb081..00dc16b0a 100644 --- a/app/src/main/java/com/getcode/view/components/Cloudy.kt +++ b/app/src/main/java/com/getcode/ui/components/Cloudy.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope diff --git a/app/src/main/java/com/getcode/view/components/CodeBillSwipeToDismiss.kt b/app/src/main/java/com/getcode/ui/components/CodeBillSwipeToDismiss.kt similarity index 98% rename from app/src/main/java/com/getcode/view/components/CodeBillSwipeToDismiss.kt rename to app/src/main/java/com/getcode/ui/components/CodeBillSwipeToDismiss.kt index 6983aad09..ab0dc3ba1 100644 --- a/app/src/main/java/com/getcode/view/components/CodeBillSwipeToDismiss.kt +++ b/app/src/main/java/com/getcode/ui/components/CodeBillSwipeToDismiss.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.* diff --git a/app/src/main/java/com/getcode/view/components/CodeButton.kt b/app/src/main/java/com/getcode/ui/components/CodeButton.kt similarity index 94% rename from app/src/main/java/com/getcode/view/components/CodeButton.kt rename to app/src/main/java/com/getcode/ui/components/CodeButton.kt index 252ebccb1..a883576a9 100644 --- a/app/src/main/java/com/getcode/view/components/CodeButton.kt +++ b/app/src/main/java/com/getcode/ui/components/CodeButton.kt @@ -1,7 +1,6 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.ButtonDefaults.elevation @@ -13,21 +12,15 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.takeOrElse -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.getcode.R import com.getcode.theme.* -import com.getcode.util.calculateHorizontalPadding -import com.getcode.util.calculateVerticalPadding -import com.getcode.util.minus -import com.getcode.util.plus +import com.getcode.ui.utils.plus enum class ButtonState { Bordered, diff --git a/app/src/main/java/com/getcode/view/components/CodeCircularProgressIndicator.kt b/app/src/main/java/com/getcode/ui/components/CodeCircularProgressIndicator.kt similarity index 95% rename from app/src/main/java/com/getcode/view/components/CodeCircularProgressIndicator.kt rename to app/src/main/java/com/getcode/ui/components/CodeCircularProgressIndicator.kt index 42c28f279..d23d0f1f3 100644 --- a/app/src/main/java/com/getcode/view/components/CodeCircularProgressIndicator.kt +++ b/app/src/main/java/com/getcode/ui/components/CodeCircularProgressIndicator.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ProgressIndicatorDefaults diff --git a/app/src/main/java/com/getcode/view/components/CodeKeyPad.kt b/app/src/main/java/com/getcode/ui/components/CodeKeyPad.kt similarity index 98% rename from app/src/main/java/com/getcode/view/components/CodeKeyPad.kt rename to app/src/main/java/com/getcode/ui/components/CodeKeyPad.kt index 2c4768c19..f9b3831c2 100644 --- a/app/src/main/java/com/getcode/view/components/CodeKeyPad.kt +++ b/app/src/main/java/com/getcode/ui/components/CodeKeyPad.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import android.view.MotionEvent import androidx.compose.animation.animateColor @@ -43,7 +43,7 @@ import com.getcode.theme.CodeTheme import com.getcode.theme.Transparent import com.getcode.theme.White import com.getcode.theme.White10 -import com.getcode.util.rememberedClickable +import com.getcode.ui.utils.rememberedClickable import java.text.DecimalFormatSymbols diff --git a/app/src/main/java/com/getcode/view/components/CodeSeedView.kt b/app/src/main/java/com/getcode/ui/components/CodeSeedView.kt similarity index 98% rename from app/src/main/java/com/getcode/view/components/CodeSeedView.kt rename to app/src/main/java/com/getcode/ui/components/CodeSeedView.kt index a2e6bbc18..17066b2cf 100644 --- a/app/src/main/java/com/getcode/view/components/CodeSeedView.kt +++ b/app/src/main/java/com/getcode/ui/components/CodeSeedView.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* diff --git a/app/src/main/java/com/getcode/view/components/CodeSwitch.kt b/app/src/main/java/com/getcode/ui/components/CodeSwitch.kt similarity index 97% rename from app/src/main/java/com/getcode/view/components/CodeSwitch.kt rename to app/src/main/java/com/getcode/ui/components/CodeSwitch.kt index f5d43c400..2d6590cb9 100644 --- a/app/src/main/java/com/getcode/view/components/CodeSwitch.kt +++ b/app/src/main/java/com/getcode/ui/components/CodeSwitch.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material.ExperimentalMaterialApi diff --git a/app/src/main/java/com/getcode/view/components/ImageWithBackground.kt b/app/src/main/java/com/getcode/ui/components/ImageWithBackground.kt similarity index 98% rename from app/src/main/java/com/getcode/view/components/ImageWithBackground.kt rename to app/src/main/java/com/getcode/ui/components/ImageWithBackground.kt index b718e5ecd..08405ac2c 100644 --- a/app/src/main/java/com/getcode/view/components/ImageWithBackground.kt +++ b/app/src/main/java/com/getcode/ui/components/ImageWithBackground.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.annotation.DrawableRes import androidx.compose.foundation.Image diff --git a/app/src/main/java/com/getcode/view/components/MarkdownText.kt b/app/src/main/java/com/getcode/ui/components/MarkdownText.kt similarity index 99% rename from app/src/main/java/com/getcode/view/components/MarkdownText.kt rename to app/src/main/java/com/getcode/ui/components/MarkdownText.kt index b1f72577c..9ea5422ad 100644 --- a/app/src/main/java/com/getcode/view/components/MarkdownText.kt +++ b/app/src/main/java/com/getcode/ui/components/MarkdownText.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import android.content.Context import android.os.Build diff --git a/app/src/main/java/com/getcode/view/components/MiddleEllipsisText.kt b/app/src/main/java/com/getcode/ui/components/MiddleEllipsisText.kt similarity index 99% rename from app/src/main/java/com/getcode/view/components/MiddleEllipsisText.kt rename to app/src/main/java/com/getcode/ui/components/MiddleEllipsisText.kt index 9f50cfd49..05339b0dd 100644 --- a/app/src/main/java/com/getcode/view/components/MiddleEllipsisText.kt +++ b/app/src/main/java/com/getcode/ui/components/MiddleEllipsisText.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.material.LocalTextStyle import androidx.compose.material.Text diff --git a/app/src/main/java/com/getcode/view/components/ModalSheetLayout.kt b/app/src/main/java/com/getcode/ui/components/ModalSheetLayout.kt similarity index 96% rename from app/src/main/java/com/getcode/view/components/ModalSheetLayout.kt rename to app/src/main/java/com/getcode/ui/components/ModalSheetLayout.kt index 580db3cc7..99122a9a7 100644 --- a/app/src/main/java/com/getcode/view/components/ModalSheetLayout.kt +++ b/app/src/main/java/com/getcode/ui/components/ModalSheetLayout.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.shape.RoundedCornerShape diff --git a/app/src/main/java/com/getcode/view/components/OnLifecycleEvent.kt b/app/src/main/java/com/getcode/ui/components/OnLifecycleEvent.kt similarity index 96% rename from app/src/main/java/com/getcode/view/components/OnLifecycleEvent.kt rename to app/src/main/java/com/getcode/ui/components/OnLifecycleEvent.kt index 0b71e3824..f05c98cad 100644 --- a/app/src/main/java/com/getcode/view/components/OnLifecycleEvent.kt +++ b/app/src/main/java/com/getcode/ui/components/OnLifecycleEvent.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect diff --git a/app/src/main/java/com/getcode/view/components/OtpBox.kt b/app/src/main/java/com/getcode/ui/components/OtpBox.kt similarity index 94% rename from app/src/main/java/com/getcode/view/components/OtpBox.kt rename to app/src/main/java/com/getcode/ui/components/OtpBox.kt index edd2b4ed2..b926ffaae 100644 --- a/app/src/main/java/com/getcode/view/components/OtpBox.kt +++ b/app/src/main/java/com/getcode/ui/components/OtpBox.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background @@ -11,11 +11,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.theme.WindowSizeClass -import com.getcode.util.rememberedClickable +import com.getcode.ui.utils.rememberedClickable @Composable fun OtpBox( diff --git a/app/src/main/java/com/getcode/view/components/OtpRow.kt b/app/src/main/java/com/getcode/ui/components/OtpRow.kt similarity index 96% rename from app/src/main/java/com/getcode/view/components/OtpRow.kt rename to app/src/main/java/com/getcode/ui/components/OtpRow.kt index 616b7d31a..3c9f80427 100644 --- a/app/src/main/java/com/getcode/view/components/OtpRow.kt +++ b/app/src/main/java/com/getcode/ui/components/OtpRow.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row diff --git a/app/src/main/java/com/getcode/view/components/PermissionCheck.kt b/app/src/main/java/com/getcode/ui/components/PermissionCheck.kt similarity index 97% rename from app/src/main/java/com/getcode/view/components/PermissionCheck.kt rename to app/src/main/java/com/getcode/ui/components/PermissionCheck.kt index b6cc42bf8..4ee63b2ec 100644 --- a/app/src/main/java/com/getcode/view/components/PermissionCheck.kt +++ b/app/src/main/java/com/getcode/ui/components/PermissionCheck.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import android.content.Context import android.content.pm.PackageManager diff --git a/app/src/main/java/com/getcode/ui/components/Pill.kt b/app/src/main/java/com/getcode/ui/components/Pill.kt new file mode 100644 index 000000000..b33983166 --- /dev/null +++ b/app/src/main/java/com/getcode/ui/components/Pill.kt @@ -0,0 +1,41 @@ +package com.getcode.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import com.getcode.theme.Black50 +import com.getcode.theme.CodeTheme +import com.getcode.theme.xxl + +@Composable +fun Pill( + modifier: Modifier = Modifier, + backgroundColor: Color = Black50, + contentColor: Color = Color.White, + text: String, + textStyle: TextStyle = CodeTheme.typography.body2 +) { + Row( + modifier = modifier + .wrapContentSize() + .clip(CodeTheme.shapes.xxl) + .background(backgroundColor) + .padding( + horizontal = CodeTheme.dimens.grid.x2, + vertical = CodeTheme.dimens.grid.x1 + ), + ) { + Text( + text = text, + style = textStyle, + color = contentColor, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/Row.kt b/app/src/main/java/com/getcode/ui/components/Row.kt new file mode 100644 index 000000000..6612c63a2 --- /dev/null +++ b/app/src/main/java/com/getcode/ui/components/Row.kt @@ -0,0 +1,40 @@ +package com.getcode.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.getcode.ui.utils.calculateEndPadding +import com.getcode.ui.utils.calculateStartPadding +import com.getcode.ui.utils.calculateVerticalPadding + +@Composable +inline fun Row( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + verticalAlignment: Alignment.Vertical = Alignment.Top, + content: @Composable RowScope.() -> Unit, +) { + Box(modifier = modifier) { + androidx.compose.foundation.layout.Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = contentPadding.calculateVerticalPadding()), + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment, + ) { + Spacer(modifier = Modifier.requiredWidth(contentPadding.calculateStartPadding())) + content() + Spacer(modifier = Modifier.requiredWidth(contentPadding.calculateEndPadding())) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/components/Scaffold.kt b/app/src/main/java/com/getcode/ui/components/Scaffold.kt similarity index 98% rename from app/src/main/java/com/getcode/view/components/Scaffold.kt rename to app/src/main/java/com/getcode/ui/components/Scaffold.kt index b3fac15c0..b33e3576f 100644 --- a/app/src/main/java/com/getcode/view/components/Scaffold.kt +++ b/app/src/main/java/com/getcode/ui/components/Scaffold.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues diff --git a/app/src/main/java/com/getcode/view/components/SheetTitle.kt b/app/src/main/java/com/getcode/ui/components/SheetTitle.kt similarity index 96% rename from app/src/main/java/com/getcode/view/components/SheetTitle.kt rename to app/src/main/java/com/getcode/ui/components/SheetTitle.kt index 97e06e63e..f047843a5 100644 --- a/app/src/main/java/com/getcode/view/components/SheetTitle.kt +++ b/app/src/main/java/com/getcode/ui/components/SheetTitle.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -21,8 +21,8 @@ import com.getcode.R import com.getcode.theme.Brand import com.getcode.theme.CodeTheme import com.getcode.theme.topBarHeight -import com.getcode.util.rememberedClickable -import com.getcode.util.unboundedClickable +import com.getcode.ui.utils.rememberedClickable +import com.getcode.ui.utils.unboundedClickable @Composable fun SheetTitle( diff --git a/app/src/main/java/com/getcode/view/components/SlideToConfirm.kt b/app/src/main/java/com/getcode/ui/components/SlideToConfirm.kt similarity index 95% rename from app/src/main/java/com/getcode/view/components/SlideToConfirm.kt rename to app/src/main/java/com/getcode/ui/components/SlideToConfirm.kt index 9681d131f..d84ef3e0c 100644 --- a/app/src/main/java/com/getcode/view/components/SlideToConfirm.kt +++ b/app/src/main/java/com/getcode/ui/components/SlideToConfirm.kt @@ -1,10 +1,8 @@ @file:OptIn(ExperimentalMaterialApi::class) -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -12,10 +10,7 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.AnchoredDraggableState -import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.anchoredDraggable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -29,7 +24,6 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.FractionalThreshold import androidx.compose.material.SwipeProgress @@ -64,17 +58,14 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times import com.getcode.R import com.getcode.theme.CodeTheme import com.getcode.theme.White import com.getcode.theme.White50 -import com.getcode.util.addIf -import com.getcode.util.toPx +import com.getcode.ui.utils.addIf import kotlinx.coroutines.delay import kotlin.math.roundToInt -import kotlin.time.Duration.Companion.seconds object SlideToConfirmDefaults { @Composable diff --git a/app/src/main/java/com/getcode/view/components/SwipeableView.kt b/app/src/main/java/com/getcode/ui/components/SwipeableView.kt similarity index 99% rename from app/src/main/java/com/getcode/view/components/SwipeableView.kt rename to app/src/main/java/com/getcode/ui/components/SwipeableView.kt index ba638e005..4710455b8 100644 --- a/app/src/main/java/com/getcode/view/components/SwipeableView.kt +++ b/app/src/main/java/com/getcode/ui/components/SwipeableView.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import android.annotation.SuppressLint import androidx.compose.animation.core.AnimationSpec diff --git a/app/src/main/java/com/getcode/view/components/TextInput.kt b/app/src/main/java/com/getcode/ui/components/TextInput.kt similarity index 99% rename from app/src/main/java/com/getcode/view/components/TextInput.kt rename to app/src/main/java/com/getcode/ui/components/TextInput.kt index 692307a0e..73e99be5c 100644 --- a/app/src/main/java/com/getcode/view/components/TextInput.kt +++ b/app/src/main/java/com/getcode/ui/components/TextInput.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import android.view.ViewTreeObserver import androidx.compose.foundation.ExperimentalFoundationApi diff --git a/app/src/main/java/com/getcode/view/components/TextSection.kt b/app/src/main/java/com/getcode/ui/components/TextSection.kt similarity index 95% rename from app/src/main/java/com/getcode/view/components/TextSection.kt rename to app/src/main/java/com/getcode/ui/components/TextSection.kt index 72d418764..f1c5df1b0 100644 --- a/app/src/main/java/com/getcode/view/components/TextSection.kt +++ b/app/src/main/java/com/getcode/ui/components/TextSection.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/com/getcode/view/components/TitleBar.kt b/app/src/main/java/com/getcode/ui/components/TitleBar.kt similarity index 95% rename from app/src/main/java/com/getcode/view/components/TitleBar.kt rename to app/src/main/java/com/getcode/ui/components/TitleBar.kt index 7e8cce241..836554897 100644 --- a/app/src/main/java/com/getcode/view/components/TitleBar.kt +++ b/app/src/main/java/com/getcode/ui/components/TitleBar.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -14,7 +14,7 @@ import androidx.compose.ui.unit.dp import com.getcode.theme.Brand import com.getcode.theme.CodeTheme import com.getcode.theme.topBarHeight -import com.getcode.util.unboundedClickable +import com.getcode.ui.utils.unboundedClickable @Composable diff --git a/app/src/main/java/com/getcode/view/components/TopBarContainer.kt b/app/src/main/java/com/getcode/ui/components/TopBarContainer.kt similarity index 98% rename from app/src/main/java/com/getcode/view/components/TopBarContainer.kt rename to app/src/main/java/com/getcode/ui/components/TopBarContainer.kt index 563cf4e29..a793b4ee0 100644 --- a/app/src/main/java/com/getcode/view/components/TopBarContainer.kt +++ b/app/src/main/java/com/getcode/ui/components/TopBarContainer.kt @@ -1,4 +1,4 @@ -package com.getcode.view.components +package com.getcode.ui.components import androidx.compose.animation.* import androidx.compose.animation.core.MutableTransitionState @@ -23,7 +23,7 @@ import com.getcode.R import com.getcode.manager.TopBarManager import com.getcode.manager.TopBarManager.TopBarMessageType.* import com.getcode.theme.* -import com.getcode.util.rememberedClickable +import com.getcode.ui.utils.rememberedClickable import java.util.* import kotlin.concurrent.timerTask diff --git a/app/src/main/java/com/getcode/ui/components/chat/ChatNode.kt b/app/src/main/java/com/getcode/ui/components/chat/ChatNode.kt new file mode 100644 index 000000000..4e8bc2975 --- /dev/null +++ b/app/src/main/java/com/getcode/ui/components/chat/ChatNode.kt @@ -0,0 +1,174 @@ +package com.getcode.ui.components.chat + +import androidx.compose.animation.AnimatedVisibility +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import com.getcode.BuildConfig +import com.getcode.LocalCurrencyUtils +import com.getcode.model.Chat +import com.getcode.model.Currency +import com.getcode.model.GenericAmount +import com.getcode.model.MessageContent +import com.getcode.model.Title +import com.getcode.theme.BrandLight +import com.getcode.theme.CodeTheme +import com.getcode.util.DateUtils +import com.getcode.util.Kin +import com.getcode.util.formatted +import com.getcode.utils.FormatUtils +import com.getcode.ui.components.Badge +import java.util.Locale + +object ChatNodeDefaults { + val UnreadIndicator: Color = Color(0xFF31BB00) +} + +@Composable +fun ChatNode( + modifier: Modifier = Modifier, + chat: Chat, + onClick: () -> Unit, +) { + Column( + modifier = modifier + .clickable { onClick() } + .padding( + vertical = CodeTheme.dimens.grid.x3, + horizontal = CodeTheme.dimens.inset + ), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + val hasUnreadMessages by remember(chat.unreadCount) { + derivedStateOf { chat.unreadCount > 0 } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = chat.localizedTitle, maxLines = 1, style = CodeTheme.typography.body1) + chat.lastMessageMillis?.let { + Text( + text = DateUtils.getDateRelatively(it), + style = CodeTheme.typography.body2, + color = if (hasUnreadMessages) ChatNodeDefaults.UnreadIndicator else CodeTheme.colors.brandLight, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + modifier = Modifier.weight(1f), + text = chat.messagePreview, + style = CodeTheme.typography.body1, + color = CodeTheme.colors.brandLight, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + if (chat.isMuted) { + Icon( + Icons.AutoMirrored.Filled.VolumeOff, + contentDescription = "chat is muted", + tint = BrandLight + ) + } else { + Badge( + count = chat.unreadCount, + color = ChatNodeDefaults.UnreadIndicator + ) + } + } + } +} + + +private val Chat.localizedTitle: String + @Composable get() { + return title.localized + } + +val Title?.localized: String + @Composable get() = when (val t = this) { + is Title.Domain -> { + t.value.capitalize(Locale.getDefault()) + } + + is Title.Localized -> { + with(LocalContext.current) { + val resId = resources.getIdentifier( + t.value, + "string", + BuildConfig.APPLICATION_ID + ).let { if (it == 0) null else it } + + resId?.let { getString(it) }.orEmpty() + } + } + + else -> "Anonymous" + } + +private val Chat.messagePreview: String + @Composable get() { + val contents = messages.lastOrNull()?.contents ?: return "No content" + + var filtered: List = contents.filterIsInstance() + if (filtered.isEmpty()) { + filtered = contents + } + + return filtered.map { it.localizedText }.joinToString(" ") + } + +val MessageContent.localizedText: String + @Composable get() { + return when (val content = this) { + is MessageContent.Exchange -> { + val amount = when (val kinAmount = content.amount) { + is GenericAmount.Exact -> { + val currency = + LocalCurrencyUtils.current?.getCurrency(kinAmount.currencyCode.name) + kinAmount.amount.formatted(currency = currency ?: Currency.Kin) + } + + is GenericAmount.Partial -> { + FormatUtils.formatCurrency(kinAmount.fiat.amount, Locale.getDefault()) + } + } + + "You ${content.verb.toString().lowercase()} $amount" + } + + is MessageContent.Localized -> { + with(LocalContext.current) { + val resId = resources.getIdentifier( + content.value, + "string", + BuildConfig.APPLICATION_ID + ).let { if (it == 0) null else it } + + resId?.let { getString(it) }.orEmpty() + } + } + + MessageContent.SodiumBox -> "" + } + } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt b/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt new file mode 100644 index 000000000..6a3a7ff0e --- /dev/null +++ b/app/src/main/java/com/getcode/ui/components/chat/MessageNode.kt @@ -0,0 +1,198 @@ +package com.getcode.ui.components.chat + +import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.getcode.BuildConfig +import com.getcode.LocalExchange +import com.getcode.R +import com.getcode.model.KinAmount +import com.getcode.model.MessageContent +import com.getcode.model.Rate +import com.getcode.model.Verb +import com.getcode.theme.BrandDark +import com.getcode.theme.BrandLight +import com.getcode.theme.CodeTheme +import com.getcode.util.formatTimeRelatively +import com.getcode.view.main.home.components.PriceWithFlag +import kotlinx.datetime.Instant + +object MessageNodeDefaults { + + @Composable + fun verticalPadding( + isPreviousSameMessage: Boolean, + isNextSameMessage: Boolean + ): PaddingValues { + return when { + isPreviousSameMessage && isNextSameMessage -> PaddingValues(vertical = CodeTheme.dimens.grid.x1 / 2) + isPreviousSameMessage -> PaddingValues(top = CodeTheme.dimens.grid.x1 / 2) + isNextSameMessage -> PaddingValues(bottom = CodeTheme.dimens.grid.x1 / 2) + else -> PaddingValues(vertical = CodeTheme.dimens.grid.x1) + } + } + + val DefaultShape: CornerBasedShape + @Composable get() = CodeTheme.shapes.small + val PreviousSameShape: CornerBasedShape + @Composable get() = DefaultShape.copy(topStart = CornerSize(3.dp)) + + val NextSameShape: CornerBasedShape + @Composable get() = DefaultShape.copy(bottomStart = CornerSize(3.dp)) + + val MiddleSameShape: CornerBasedShape + @Composable get() = DefaultShape.copy( + topStart = CornerSize(3.dp), + bottomStart = CornerSize(3.dp) + ) +} + +private val MessageContent.widthFraction: Float + get() = when (this) { + is MessageContent.Exchange -> 0.895f + is MessageContent.Localized -> 0.8358f + MessageContent.SodiumBox -> 0.8358f + } + +@Composable +fun MessageNode( + modifier: Modifier = Modifier, + contents: MessageContent, + date: Instant, + isPreviousSameMessage: Boolean, + isNextSameMessage: Boolean +) { + Box( + modifier = modifier + .padding( + MessageNodeDefaults.verticalPadding( + isPreviousSameMessage = isPreviousSameMessage, + isNextSameMessage = isNextSameMessage + ) + ) + ) { + val exchange = LocalExchange.current + + Box( + modifier = Modifier + .fillMaxWidth(contents.widthFraction) + .background( + color = BrandDark, + shape = when { + isPreviousSameMessage && isNextSameMessage -> MessageNodeDefaults.MiddleSameShape + isPreviousSameMessage -> MessageNodeDefaults.PreviousSameShape + isNextSameMessage -> MessageNodeDefaults.NextSameShape + else -> MessageNodeDefaults.DefaultShape + } + ) + .padding(CodeTheme.dimens.grid.x2), + contentAlignment = Alignment.Center + ) { + when (contents) { + is MessageContent.Exchange -> { + val rate = exchange.rateFor(contents.amount.currencyCode) + if (rate != null) { + MessagePayment( + verb = contents.verb, + amount = contents.amount.amountUsing(rate) + ) + } else { + MessagePayment( + verb = Verb.Unknown, + amount = KinAmount.newInstance(0, Rate.oneToOne) + ) + } + } + + is MessageContent.Localized -> { + MessageText(text = contents.localizedText, date = date) + } + + MessageContent.SodiumBox -> { + MessageText(text = contents.localizedText, date = date) + } + } + } + } +} + +@Composable +private fun MessagePayment( + modifier: Modifier = Modifier, + verb: Verb, + amount: KinAmount, +) { + Column( + modifier = modifier + // payments have an extra 10.dp inner padding + .padding(CodeTheme.dimens.grid.x2), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = verb.localizedText, + style = CodeTheme.typography.body1.copy(fontWeight = FontWeight.W500) + ) + + PriceWithFlag( + currencyCode = amount.rate.currency, + amount = amount, + text = { price -> + Text( + text = price, + color = Color.White, + style = CodeTheme.typography.h3 + ) + } + ) + } +} + +@Composable +private fun MessageText(modifier: Modifier = Modifier, text: String, date: Instant) { + Column( + modifier = modifier + // add top padding to accommodate ascents + .padding(top = CodeTheme.dimens.grid.x1), + ) { + Text( + text = text, + style = CodeTheme.typography.body1.copy(fontWeight = FontWeight.W500) + ) + Text( + modifier = Modifier.align(Alignment.End), + text = date.formatTimeRelatively(), + style = CodeTheme.typography.caption, + color = BrandLight, + ) + } +} + +private val Verb.localizedText: String + @SuppressLint("DiscouragedApi") + @Composable get() = with(LocalContext.current) context@{ + if (this@localizedText == Verb.Unknown) stringResource(id = R.string.title_unknown) + val resId = resources.getIdentifier( + "subtitle_verb_${this@localizedText.toString().lowercase()}", + "string", + BuildConfig.APPLICATION_ID + ).let { if (it == 0) null else it } + + resId?.let { getString(it) }.orEmpty() + } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/util/AnimationUtils.kt b/app/src/main/java/com/getcode/ui/utils/AnimationUtils.kt similarity index 97% rename from app/src/main/java/com/getcode/util/AnimationUtils.kt rename to app/src/main/java/com/getcode/ui/utils/AnimationUtils.kt index 56317cbb9..18a4768d0 100644 --- a/app/src/main/java/com/getcode/util/AnimationUtils.kt +++ b/app/src/main/java/com/getcode/ui/utils/AnimationUtils.kt @@ -1,4 +1,4 @@ -package com.getcode.util +package com.getcode.ui.utils import androidx.compose.animation.* import androidx.compose.animation.core.* diff --git a/app/src/main/java/com/getcode/util/IntSize.kt b/app/src/main/java/com/getcode/ui/utils/IntSize.kt similarity index 89% rename from app/src/main/java/com/getcode/util/IntSize.kt rename to app/src/main/java/com/getcode/ui/utils/IntSize.kt index cef0d9023..ed4a4350a 100644 --- a/app/src/main/java/com/getcode/util/IntSize.kt +++ b/app/src/main/java/com/getcode/ui/utils/IntSize.kt @@ -1,4 +1,4 @@ -package com.getcode.util +package com.getcode.ui.utils import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize diff --git a/app/src/main/java/com/getcode/util/Keyboard.kt b/app/src/main/java/com/getcode/ui/utils/Keyboard.kt similarity index 97% rename from app/src/main/java/com/getcode/util/Keyboard.kt rename to app/src/main/java/com/getcode/ui/utils/Keyboard.kt index ae02f807b..5f3719e1f 100644 --- a/app/src/main/java/com/getcode/util/Keyboard.kt +++ b/app/src/main/java/com/getcode/ui/utils/Keyboard.kt @@ -1,4 +1,4 @@ -package com.getcode.util +package com.getcode.ui.utils import android.view.ViewTreeObserver import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/getcode/ui/utils/MeasureScope.kt b/app/src/main/java/com/getcode/ui/utils/MeasureScope.kt new file mode 100644 index 000000000..41dbf9ca0 --- /dev/null +++ b/app/src/main/java/com/getcode/ui/utils/MeasureScope.kt @@ -0,0 +1,14 @@ +package com.getcode.ui.utils + +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.Placeable + +@Suppress("UnusedReceiverParameter") +fun MeasureScope.widthOrZero(placeable: Placeable?): Int { + return placeable?.measuredWidth ?: 0 +} + +@Suppress("UnusedReceiverParameter") +fun MeasureScope.heightOrZero(placeable: Placeable?): Int { + return placeable?.measuredHeight ?: 0 +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/util/ModifierExt.kt b/app/src/main/java/com/getcode/ui/utils/Modifier.kt similarity index 98% rename from app/src/main/java/com/getcode/util/ModifierExt.kt rename to app/src/main/java/com/getcode/ui/utils/Modifier.kt index 160c5c354..4f5d4e8e7 100644 --- a/app/src/main/java/com/getcode/util/ModifierExt.kt +++ b/app/src/main/java/com/getcode/ui/utils/Modifier.kt @@ -1,9 +1,8 @@ -package com.getcode.util +package com.getcode.ui.utils import android.view.MotionEvent import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Indication -import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable diff --git a/app/src/main/java/com/getcode/util/PaddingValues.kt b/app/src/main/java/com/getcode/ui/utils/PaddingValues.kt similarity index 94% rename from app/src/main/java/com/getcode/util/PaddingValues.kt rename to app/src/main/java/com/getcode/ui/utils/PaddingValues.kt index 2d8e34c8a..707c48825 100644 --- a/app/src/main/java/com/getcode/util/PaddingValues.kt +++ b/app/src/main/java/com/getcode/ui/utils/PaddingValues.kt @@ -1,7 +1,6 @@ -package com.getcode.util +package com.getcode.ui.utils import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp diff --git a/app/src/main/java/com/getcode/util/RecompositionHighlighter.kt b/app/src/main/java/com/getcode/ui/utils/RecompositionHighlighter.kt similarity index 99% rename from app/src/main/java/com/getcode/util/RecompositionHighlighter.kt rename to app/src/main/java/com/getcode/ui/utils/RecompositionHighlighter.kt index ec82c6682..a7bfc2da7 100644 --- a/app/src/main/java/com/getcode/util/RecompositionHighlighter.kt +++ b/app/src/main/java/com/getcode/ui/utils/RecompositionHighlighter.kt @@ -15,7 +15,7 @@ // Courtesy of // https://github.com/android/snippets/blob/master/compose/recomposehighlighter/src/main/java/com/example/android/compose/recomposehighlighter/RecomposeHighlighter.kt -package com.getcode.util +package com.getcode.ui.utils import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable diff --git a/app/src/main/java/com/getcode/util/RepeatOnLifecycle.kt b/app/src/main/java/com/getcode/ui/utils/RepeatOnLifecycle.kt similarity index 97% rename from app/src/main/java/com/getcode/util/RepeatOnLifecycle.kt rename to app/src/main/java/com/getcode/ui/utils/RepeatOnLifecycle.kt index cc5e5d43b..47df2994a 100644 --- a/app/src/main/java/com/getcode/util/RepeatOnLifecycle.kt +++ b/app/src/main/java/com/getcode/ui/utils/RepeatOnLifecycle.kt @@ -1,4 +1,4 @@ -package com.getcode.util +package com.getcode.ui.utils import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect diff --git a/app/src/main/java/com/getcode/util/ViewExt.kt b/app/src/main/java/com/getcode/ui/utils/View.kt similarity index 97% rename from app/src/main/java/com/getcode/util/ViewExt.kt rename to app/src/main/java/com/getcode/ui/utils/View.kt index bae7b7066..50c9d3bdb 100644 --- a/app/src/main/java/com/getcode/util/ViewExt.kt +++ b/app/src/main/java/com/getcode/ui/utils/View.kt @@ -1,4 +1,4 @@ -package com.getcode.util +package com.getcode.ui.utils import android.app.Activity import android.content.Context @@ -7,7 +7,6 @@ import android.content.Intent import android.content.res.Resources import android.os.Process.killProcess import android.os.Process.myPid -import androidx.activity.ComponentActivity import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.toArgb diff --git a/app/src/main/java/com/getcode/util/Voyager.kt b/app/src/main/java/com/getcode/ui/utils/Voyager.kt similarity index 95% rename from app/src/main/java/com/getcode/util/Voyager.kt rename to app/src/main/java/com/getcode/ui/utils/Voyager.kt index 3721e9380..697ce9af3 100644 --- a/app/src/main/java/com/getcode/util/Voyager.kt +++ b/app/src/main/java/com/getcode/ui/utils/Voyager.kt @@ -1,4 +1,4 @@ -package com.getcode.util +package com.getcode.ui.utils import android.content.Context import androidx.activity.ComponentActivity @@ -11,8 +11,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.CreationExtras -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import cafe.adriel.voyager.core.lifecycle.getNavigatorScreenLifecycleProvider import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.hilt.VoyagerHiltViewModelFactories import com.getcode.navigation.core.LocalCodeNavigator diff --git a/app/src/main/java/com/getcode/util/Build.kt b/app/src/main/java/com/getcode/util/Build.kt new file mode 100644 index 000000000..1cd723f34 --- /dev/null +++ b/app/src/main/java/com/getcode/util/Build.kt @@ -0,0 +1,22 @@ +package com.getcode.util + +import android.os.Build + +val isEmulator: Boolean + get() = (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) + || Build.FINGERPRINT.startsWith("generic") + || Build.FINGERPRINT.startsWith("unknown") + || Build.HARDWARE.contains("goldfish") + || Build.HARDWARE.contains("ranchu") + || Build.MODEL.contains("google_sdk") + || Build.MODEL.contains("Emulator") + || Build.MODEL.contains("Android SDK built for x86") + || Build.MANUFACTURER.contains("Genymotion") + || Build.PRODUCT.contains("sdk_google") + || Build.PRODUCT.contains("google_sdk") + || Build.PRODUCT.contains("sdk") + || Build.PRODUCT.contains("sdk_x86") + || Build.PRODUCT.contains("sdk_gphone64_arm64") + || Build.PRODUCT.contains("vbox86p") + || Build.PRODUCT.contains("emulator") + || Build.PRODUCT.contains("simulator") \ No newline at end of file diff --git a/app/src/main/java/com/getcode/util/DateUtils.kt b/app/src/main/java/com/getcode/util/DateUtils.kt index b6d5e6ddd..ce2b2df20 100644 --- a/app/src/main/java/com/getcode/util/DateUtils.kt +++ b/app/src/main/java/com/getcode/util/DateUtils.kt @@ -2,7 +2,15 @@ package com.getcode.util import android.text.format.DateFormat import android.text.format.DateUtils +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.daysUntil import java.util.* +import kotlin.math.abs +import kotlin.time.Duration.Companion.days object DateUtils { fun getDate(millis: Long, format: String = "yyyy-MM-dd h:mm aa"): String { @@ -21,5 +29,39 @@ object DateUtils { } } + fun getDateRelatively(millis: Long): String { + val date = Instant.fromEpochMilliseconds(millis) + val weekAgo = date.minus(7.days) + + return if (DateUtils.isToday(millis)) { + "Today" + } else if (isYesterday(millis)) { + "Yesterday" + } else if (date.toEpochMilliseconds() < weekAgo.toEpochMilliseconds()) { + val daysBetween = abs(date.daysUntil(Clock.System.now(), TimeZone.currentSystemDefault())) + "$daysBetween days ago" + } else { + getDate(millis, format = "EEE, MMM dd") + } + } + private fun isYesterday(millis: Long) = DateUtils.isToday(millis + DateUtils.DAY_IN_MILLIS) +} + +fun Long.toInstantFromMillis() = Instant.fromEpochMilliseconds(this) +fun Long.toInstantFromSeconds() = Instant.fromEpochSeconds(this) + +fun Instant.formatDateRelatively(): String { + return com.getcode.util.DateUtils.getDateRelatively(toEpochMilliseconds()) +} + +@Composable +fun Instant.formatTimeRelatively(): String { + val context = LocalContext.current + val is24Hour = DateFormat.is24HourFormat(context) + return if (is24Hour) { + com.getcode.util.DateUtils.getDate(this.toEpochMilliseconds(), "H:mm") + } else { + com.getcode.util.DateUtils.getDate(this.toEpochMilliseconds(), "h:mm A") + } } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/MainActivity.kt b/app/src/main/java/com/getcode/view/MainActivity.kt index 76b1c6729..c41183e91 100644 --- a/app/src/main/java/com/getcode/view/MainActivity.kt +++ b/app/src/main/java/com/getcode/view/MainActivity.kt @@ -10,17 +10,19 @@ import com.getcode.CodeApp import com.getcode.LocalAnalytics import com.getcode.LocalCurrencyUtils import com.getcode.LocalDeeplinks +import com.getcode.LocalExchange import com.getcode.LocalNetworkObserver import com.getcode.LocalPhoneFormatter import com.getcode.analytics.AnalyticsService import com.getcode.manager.AuthManager import com.getcode.manager.SessionManager import com.getcode.network.client.Client +import com.getcode.network.exchange.Exchange import com.getcode.network.repository.PrefRepository import com.getcode.util.CurrencyUtils import com.getcode.util.DeeplinkHandler import com.getcode.util.PhoneUtils -import com.getcode.util.handleUncaughtException +import com.getcode.ui.utils.handleUncaughtException import com.getcode.util.vibration.LocalVibrator import com.getcode.util.vibration.Vibrator import com.getcode.utils.network.NetworkConnectivityListener @@ -60,6 +62,9 @@ class MainActivity : FragmentActivity() { @Inject lateinit var currencyUtils: CurrencyUtils + @Inject + lateinit var exchange: Exchange + /** * The compose navigation controller does not play nice with single task activities. * Invoking the navigation controller here will cause the intent to be fired @@ -93,6 +98,7 @@ class MainActivity : FragmentActivity() { LocalPhoneFormatter provides phoneUtils, LocalVibrator provides vibrator, LocalCurrencyUtils provides currencyUtils, + LocalExchange provides exchange, ) { CodeApp() } diff --git a/app/src/main/java/com/getcode/view/login/AccessKey.kt b/app/src/main/java/com/getcode/view/login/AccessKey.kt index a47c0fae2..1b409af65 100644 --- a/app/src/main/java/com/getcode/view/login/AccessKey.kt +++ b/app/src/main/java/com/getcode/view/login/AccessKey.kt @@ -38,15 +38,10 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.platform.LocalTextToolbar -import androidx.compose.ui.platform.TextToolbarStatus import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension import androidx.hilt.navigation.compose.hiltViewModel import com.getcode.LocalTopBarPadding import com.getcode.R @@ -54,21 +49,17 @@ import com.getcode.manager.BottomBarManager import com.getcode.manager.TopBarManager import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.LoginArgs -import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.theme.White import com.getcode.util.IntentUtils -import com.getcode.util.addIf -import com.getcode.util.debugBounds -import com.getcode.util.measured -import com.getcode.util.swallowClicks -import com.getcode.view.components.AccessKeySelectionContainer -import com.getcode.view.components.ButtonState -import com.getcode.view.components.Cloudy -import com.getcode.view.components.CodeButton -import com.getcode.view.components.PermissionCheck -import com.getcode.view.components.getPermissionLauncher -import com.getcode.view.components.rememberSelectionState +import com.getcode.ui.utils.measured +import com.getcode.ui.components.AccessKeySelectionContainer +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.Cloudy +import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.PermissionCheck +import com.getcode.ui.components.getPermissionLauncher +import com.getcode.ui.components.rememberSelectionState @OptIn(ExperimentalComposeUiApi::class) @Preview diff --git a/app/src/main/java/com/getcode/view/login/BaseAccessKeyViewModel.kt b/app/src/main/java/com/getcode/view/login/BaseAccessKeyViewModel.kt index f040db0ea..4c29d0b49 100644 --- a/app/src/main/java/com/getcode/view/login/BaseAccessKeyViewModel.kt +++ b/app/src/main/java/com/getcode/view/login/BaseAccessKeyViewModel.kt @@ -8,8 +8,6 @@ import android.media.MediaScannerConnection import android.os.Environment import androidmads.library.qrgenearator.QRGContents import androidmads.library.qrgenearator.QRGEncoder -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.applyCanvas import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.viewModelScope @@ -22,14 +20,12 @@ import com.getcode.network.repository.ApiDeniedException import com.getcode.network.repository.decodeBase64 import com.getcode.theme.* import com.getcode.util.resources.ResourceHelper -import com.getcode.util.toAGColor +import com.getcode.ui.utils.toAGColor import com.getcode.utils.ErrorUtils import com.getcode.vendor.Base58 import com.getcode.view.BaseViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.InternalCoroutinesApi -import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/getcode/view/login/CameraPermission.kt b/app/src/main/java/com/getcode/view/login/CameraPermission.kt index 235eceb8a..d10a03a56 100644 --- a/app/src/main/java/com/getcode/view/login/CameraPermission.kt +++ b/app/src/main/java/com/getcode/view/login/CameraPermission.kt @@ -17,8 +17,8 @@ import com.getcode.navigation.screens.HomeScreen import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.PermissionRequestScreen import com.getcode.theme.CodeTheme -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton @Composable fun CameraPermission(navigator: CodeNavigator = LocalCodeNavigator.current) { diff --git a/app/src/main/java/com/getcode/view/login/CameraPermissionCheck.kt b/app/src/main/java/com/getcode/view/login/CameraPermissionCheck.kt index 9fba4d085..78c685b4d 100644 --- a/app/src/main/java/com/getcode/view/login/CameraPermissionCheck.kt +++ b/app/src/main/java/com/getcode/view/login/CameraPermissionCheck.kt @@ -7,8 +7,8 @@ import com.getcode.App import com.getcode.R import com.getcode.manager.TopBarManager import com.getcode.util.IntentUtils -import com.getcode.view.components.PermissionCheck -import com.getcode.view.components.getPermissionLauncher +import com.getcode.ui.components.PermissionCheck +import com.getcode.ui.components.getPermissionLauncher @Composable fun cameraPermissionCheck(isShowError: Boolean = true, onResult: (Boolean) -> Unit): (Boolean) -> Unit { diff --git a/app/src/main/java/com/getcode/view/login/InviteCode.kt b/app/src/main/java/com/getcode/view/login/InviteCode.kt index 1b5574806..98011203d 100644 --- a/app/src/main/java/com/getcode/view/login/InviteCode.kt +++ b/app/src/main/java/com/getcode/view/login/InviteCode.kt @@ -38,8 +38,8 @@ import com.getcode.theme.CodeTheme import com.getcode.theme.White05 import com.getcode.theme.White50 import com.getcode.theme.extraSmall -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton @Preview @Composable diff --git a/app/src/main/java/com/getcode/view/login/LoginHome.kt b/app/src/main/java/com/getcode/view/login/LoginHome.kt index ad61da368..0b29069cc 100644 --- a/app/src/main/java/com/getcode/view/login/LoginHome.kt +++ b/app/src/main/java/com/getcode/view/login/LoginHome.kt @@ -37,9 +37,9 @@ import com.getcode.theme.Brand import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.util.ChromeTabsUtils -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton -import com.getcode.view.components.ImageWithBackground +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.ImageWithBackground @Preview diff --git a/app/src/main/java/com/getcode/view/login/NotificationPermission.kt b/app/src/main/java/com/getcode/view/login/NotificationPermission.kt index 388d53599..ecbbb418d 100644 --- a/app/src/main/java/com/getcode/view/login/NotificationPermission.kt +++ b/app/src/main/java/com/getcode/view/login/NotificationPermission.kt @@ -15,8 +15,8 @@ import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.screens.HomeScreen import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton @Composable fun NotificationPermission(navigator: CodeNavigator = LocalCodeNavigator.current) { diff --git a/app/src/main/java/com/getcode/view/login/NotificationPermissionCheck.kt b/app/src/main/java/com/getcode/view/login/NotificationPermissionCheck.kt index d6a1acd8b..81ced80c1 100644 --- a/app/src/main/java/com/getcode/view/login/NotificationPermissionCheck.kt +++ b/app/src/main/java/com/getcode/view/login/NotificationPermissionCheck.kt @@ -8,8 +8,8 @@ import com.getcode.App import com.getcode.R import com.getcode.manager.TopBarManager import com.getcode.util.IntentUtils -import com.getcode.view.components.PermissionCheck -import com.getcode.view.components.getPermissionLauncher +import com.getcode.ui.components.PermissionCheck +import com.getcode.ui.components.getPermissionLauncher @Composable fun notificationPermissionCheck(isShowError: Boolean = true, onResult: (Boolean) -> Unit): (Boolean) -> Unit { diff --git a/app/src/main/java/com/getcode/view/login/PhoneConfirm.kt b/app/src/main/java/com/getcode/view/login/PhoneConfirm.kt index be1a30599..54f370d62 100644 --- a/app/src/main/java/com/getcode/view/login/PhoneConfirm.kt +++ b/app/src/main/java/com/getcode/view/login/PhoneConfirm.kt @@ -46,9 +46,9 @@ import com.getcode.navigation.screens.LoginArgs import com.getcode.network.repository.replaceParam import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton -import com.getcode.view.components.OtpRow +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.OtpRow const val OTP_LENGTH = 6 diff --git a/app/src/main/java/com/getcode/view/login/PhoneVerify.kt b/app/src/main/java/com/getcode/view/login/PhoneVerify.kt index 803657c15..89cf582e4 100644 --- a/app/src/main/java/com/getcode/view/login/PhoneVerify.kt +++ b/app/src/main/java/com/getcode/view/login/PhoneVerify.kt @@ -65,10 +65,10 @@ import com.getcode.theme.White05 import com.getcode.theme.White50 import com.getcode.theme.extraSmall import com.getcode.util.PhoneUtils -import com.getcode.util.getActivity -import com.getcode.util.rememberedClickable -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton +import com.getcode.ui.utils.getActivity +import com.getcode.ui.utils.rememberedClickable +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton import com.google.android.gms.auth.api.identity.GetPhoneNumberHintIntentRequest import com.google.android.gms.auth.api.identity.Identity import kotlinx.coroutines.delay diff --git a/app/src/main/java/com/getcode/view/login/SeedDeepLink.kt b/app/src/main/java/com/getcode/view/login/SeedDeepLink.kt index e5cc2e140..a4e41e38d 100644 --- a/app/src/main/java/com/getcode/view/login/SeedDeepLink.kt +++ b/app/src/main/java/com/getcode/view/login/SeedDeepLink.kt @@ -27,9 +27,9 @@ import com.getcode.navigation.screens.LoginScreen import com.getcode.navigation.screens.PermissionRequestScreen import com.getcode.network.repository.decodeBase64 import com.getcode.network.repository.encodeBase64 -import com.getcode.util.getActivity +import com.getcode.ui.utils.getActivity import com.getcode.vendor.Base58 -import com.getcode.view.components.CodeCircularProgressIndicator +import com.getcode.ui.components.CodeCircularProgressIndicator import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/getcode/view/login/SeedInput.kt b/app/src/main/java/com/getcode/view/login/SeedInput.kt index 6ac8d3ce1..2e6648003 100644 --- a/app/src/main/java/com/getcode/view/login/SeedInput.kt +++ b/app/src/main/java/com/getcode/view/login/SeedInput.kt @@ -30,7 +30,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.LoginArgs -import com.getcode.view.components.* +import com.getcode.ui.components.* @SuppressLint("InlinedApi") @Preview diff --git a/app/src/main/java/com/getcode/view/main/account/AccountDebugBuckets.kt b/app/src/main/java/com/getcode/view/main/account/AccountDebugBuckets.kt index 7a6bc0f60..5ecbe19dc 100644 --- a/app/src/main/java/com/getcode/view/main/account/AccountDebugBuckets.kt +++ b/app/src/main/java/com/getcode/view/main/account/AccountDebugBuckets.kt @@ -17,7 +17,7 @@ import com.getcode.solana.organizer.SlotType import com.getcode.theme.BrandLight import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme -import com.getcode.view.components.MiddleEllipsisText +import com.getcode.ui.components.MiddleEllipsisText import com.getcode.view.main.balance.BalanceSheetViewModel diff --git a/app/src/main/java/com/getcode/view/main/account/AccountDeposit.kt b/app/src/main/java/com/getcode/view/main/account/AccountDeposit.kt index 72bc405ac..507cd57a7 100644 --- a/app/src/main/java/com/getcode/view/main/account/AccountDeposit.kt +++ b/app/src/main/java/com/getcode/view/main/account/AccountDeposit.kt @@ -33,11 +33,11 @@ import com.getcode.theme.CodeTheme import com.getcode.theme.White import com.getcode.theme.White05 import com.getcode.theme.extraSmall -import com.getcode.util.rememberedClickable +import com.getcode.ui.utils.rememberedClickable import com.getcode.vendor.Base58 -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton -import com.getcode.view.components.MiddleEllipsisText +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.MiddleEllipsisText @Composable fun AccountDeposit() { diff --git a/app/src/main/java/com/getcode/view/main/account/AccountFaq.kt b/app/src/main/java/com/getcode/view/main/account/AccountFaq.kt index 286b18670..302e3995f 100644 --- a/app/src/main/java/com/getcode/view/main/account/AccountFaq.kt +++ b/app/src/main/java/com/getcode/view/main/account/AccountFaq.kt @@ -17,7 +17,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.getcode.R import com.getcode.theme.CodeTheme import com.getcode.theme.White -import com.getcode.view.components.MarkdownText +import com.getcode.ui.components.MarkdownText @Preview @Composable diff --git a/app/src/main/java/com/getcode/view/main/account/AccountHome.kt b/app/src/main/java/com/getcode/view/main/account/AccountHome.kt index b28414947..d38d18181 100644 --- a/app/src/main/java/com/getcode/view/main/account/AccountHome.kt +++ b/app/src/main/java/com/getcode/view/main/account/AccountHome.kt @@ -47,8 +47,8 @@ import com.getcode.navigation.screens.WithdrawalAmountScreen import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.theme.White10 -import com.getcode.util.getActivity -import com.getcode.util.rememberedClickable +import com.getcode.ui.utils.getActivity +import com.getcode.ui.utils.rememberedClickable import kotlinx.coroutines.delay import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/getcode/view/main/account/AccountPhone.kt b/app/src/main/java/com/getcode/view/main/account/AccountPhone.kt index 573e67f64..e7dcfa1b4 100644 --- a/app/src/main/java/com/getcode/view/main/account/AccountPhone.kt +++ b/app/src/main/java/com/getcode/view/main/account/AccountPhone.kt @@ -27,8 +27,8 @@ import com.getcode.navigation.screens.PhoneVerificationScreen import com.getcode.network.repository.urlEncode import com.getcode.theme.Brand import com.getcode.theme.CodeTheme -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton @Composable fun AccountPhone( diff --git a/app/src/main/java/com/getcode/view/main/account/BackupKey.kt b/app/src/main/java/com/getcode/view/main/account/BackupKey.kt index 5987fa007..3f13257b4 100644 --- a/app/src/main/java/com/getcode/view/main/account/BackupKey.kt +++ b/app/src/main/java/com/getcode/view/main/account/BackupKey.kt @@ -2,10 +2,8 @@ package com.getcode.view.main.account import android.Manifest import android.os.Build -import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -32,43 +30,25 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.boundsInParent -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onPlaced -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalTextToolbar -import androidx.compose.ui.platform.TextToolbarStatus import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension -import androidx.room.util.copy import com.getcode.R -import com.getcode.manager.BottomBarManager import com.getcode.manager.TopBarManager import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme -import com.getcode.theme.White import com.getcode.util.IntentUtils -import com.getcode.util.debugBounds -import com.getcode.util.measured -import com.getcode.util.rememberedClickable -import com.getcode.util.rememberedLongClickable -import com.getcode.util.swallowClicks -import com.getcode.view.components.AccessKeySelectionContainer -import com.getcode.view.components.ButtonState -import com.getcode.view.components.Cloudy -import com.getcode.view.components.CodeButton -import com.getcode.view.components.PermissionCheck -import com.getcode.view.components.getPermissionLauncher -import com.getcode.view.components.rememberSelectionState +import com.getcode.ui.utils.measured +import com.getcode.ui.components.AccessKeySelectionContainer +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.Cloudy +import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.PermissionCheck +import com.getcode.ui.components.getPermissionLauncher +import com.getcode.ui.components.rememberSelectionState @Composable fun BackupKey( diff --git a/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt b/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt index 3b3d0b1f9..d07fac03a 100644 --- a/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt +++ b/app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt @@ -16,8 +16,8 @@ import com.getcode.model.BetaFlags import com.getcode.model.PrefsBool import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme -import com.getcode.util.rememberedClickable -import com.getcode.view.components.CodeSwitch +import com.getcode.ui.utils.rememberedClickable +import com.getcode.ui.components.CodeSwitch @Composable fun BetaFlagsScreen( diff --git a/app/src/main/java/com/getcode/view/main/account/ConfirmDeleteAccount.kt b/app/src/main/java/com/getcode/view/main/account/ConfirmDeleteAccount.kt index 6d71281e1..7dbb99f35 100644 --- a/app/src/main/java/com/getcode/view/main/account/ConfirmDeleteAccount.kt +++ b/app/src/main/java/com/getcode/view/main/account/ConfirmDeleteAccount.kt @@ -7,8 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.TextField import androidx.compose.runtime.Composable @@ -18,19 +16,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController -import com.getcode.App import com.getcode.R import com.getcode.manager.BottomBarManager import com.getcode.theme.CodeTheme import com.getcode.theme.extraSmall import com.getcode.theme.inputColors -import com.getcode.util.getActivity -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton +import com.getcode.ui.utils.getActivity +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton @OptIn(ExperimentalComposeUiApi::class) @Composable diff --git a/app/src/main/java/com/getcode/view/main/account/DeleteAccount.kt b/app/src/main/java/com/getcode/view/main/account/DeleteAccount.kt index 31d29eb61..965b2ee15 100644 --- a/app/src/main/java/com/getcode/view/main/account/DeleteAccount.kt +++ b/app/src/main/java/com/getcode/view/main/account/DeleteAccount.kt @@ -15,9 +15,9 @@ import com.getcode.R import com.getcode.navigation.screens.DeleteConfirmationScreen import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton -import com.getcode.view.components.TextSection +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.TextSection @Composable fun DeleteCodeAccount() { diff --git a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddress.kt b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddress.kt index 20f5e38d1..90726832a 100644 --- a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddress.kt +++ b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddress.kt @@ -30,10 +30,9 @@ import com.getcode.navigation.screens.WithdrawalArgs import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.theme.green -import com.getcode.util.debugBounds -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton -import com.getcode.view.components.TextInput +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.TextInput @OptIn(ExperimentalFoundationApi::class) @Composable diff --git a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmount.kt b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmount.kt index 0db7710d1..486938018 100644 --- a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmount.kt +++ b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAmount.kt @@ -26,9 +26,9 @@ import com.getcode.theme.CodeTheme import com.getcode.theme.displayLarge import com.getcode.util.showNetworkError import com.getcode.utils.ErrorUtils -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton -import com.getcode.view.components.CodeKeyPad +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.CodeKeyPad import com.getcode.view.main.giveKin.AmountArea import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummary.kt b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummary.kt index 965ba4323..2bb5c84a0 100644 --- a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummary.kt +++ b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummary.kt @@ -22,8 +22,8 @@ import com.getcode.navigation.screens.WithdrawalArgs import com.getcode.theme.Brand01 import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton import com.getcode.view.main.giveKin.AmountArea @Composable diff --git a/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt b/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt index a012e14c8..76c1b041a 100644 --- a/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt +++ b/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt @@ -3,10 +3,7 @@ package com.getcode.view.main.balance import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.togetherWith -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row @@ -14,11 +11,8 @@ 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.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.ClickableText import androidx.compose.material.Divider @@ -27,16 +21,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.BottomCenter -import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Alignment.Companion.CenterVertically -import androidx.compose.ui.Alignment.Companion.TopCenter import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle @@ -46,23 +34,21 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.getcode.R -import com.getcode.data.transactions.HistoricalTransactionUiModel -import com.getcode.model.AirdropType import com.getcode.model.Currency import com.getcode.model.CurrencyCode -import com.getcode.model.PaymentType +import com.getcode.model.ID import com.getcode.model.Rate import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.screens.ChatScreen import com.getcode.navigation.screens.FaqScreen import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.theme.White10 -import com.getcode.theme.extraSmall +import com.getcode.ui.components.CodeCircularProgressIndicator +import com.getcode.ui.components.chat.ChatNode import com.getcode.util.Kin -import com.getcode.view.components.CodeCircularProgressIndicator import com.getcode.view.main.account.AccountDebugBuckets import com.getcode.view.main.giveKin.AmountArea -import com.getcode.view.previewComponent.PreviewColumn @Composable @@ -90,31 +76,33 @@ fun BalanceSheet( BalanceContent( state = state, dispatch = dispatch, - upPress = { navigator.hide() }, - faqOpen = { navigator.push(FaqScreen) } + faqOpen = { navigator.push(FaqScreen) }, + openChat = { navigator.push(ChatScreen(it)) } ) } } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun BalanceContent( state: BalanceSheetViewModel.State, dispatch: (BalanceSheetViewModel.Event) -> Unit, - upPress: () -> Unit, - faqOpen: () -> Unit + faqOpen: () -> Unit, + openChat: (ID) -> Unit, ) { val lazyListState = rememberLazyListState() - val transactionsEmpty by remember(state.historicalTransactions) { - derivedStateOf { state.historicalTransactions.isEmpty() } + val chatsEmpty by remember(state.chats) { + derivedStateOf { state.chats.isEmpty() } + } + + val canClickBalance by remember(state.isDebugBucketsEnabled) { + derivedStateOf { state.isDebugBucketsVisible } } LazyColumn( modifier = Modifier - .fillMaxWidth() - .wrapContentHeight(), + .fillMaxWidth(), state = lazyListState ) { item { @@ -129,27 +117,38 @@ fun BalanceContent( ) { BalanceTop( state, - state.isDebugBucketsEnabled + canClickBalance, ) { dispatch(BalanceSheetViewModel.Event.OnDebugBucketsVisible(true)) } - if (!transactionsEmpty && !state.historicalTransactionsLoading) { + if (!chatsEmpty && !state.chatsLoading) { KinValueHint(faqOpen) } } } - items(state.historicalTransactions, key = { it.id }, contentType = { it }) { event -> - Row(Modifier.animateItemPlacement()) { - TransactionItem(event) + itemsIndexed( + state.chats, + key = { _, item -> item.id }, + contentType = { _, item -> item }) { index, event -> + ChatNode(chat = event, onClick = { openChat(event.id) }) + if (index < state.chats.lastIndex) { + Divider( + modifier = Modifier.padding(start = CodeTheme.dimens.inset), + color = White10, + ) } } when { - state.historicalTransactionsLoading -> { + state.chatsLoading -> { item { - Column(modifier = Modifier.fillMaxSize(), + Column( + modifier = Modifier.fillMaxSize(), horizontalAlignment = CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2, CenterVertically), + verticalArrangement = Arrangement.spacedBy( + CodeTheme.dimens.grid.x2, + CenterVertically + ), ) { CodeCircularProgressIndicator() Text( @@ -161,117 +160,15 @@ fun BalanceContent( } } - transactionsEmpty -> { + chatsEmpty -> { item { - EmptyTransactionsHint(upPress, faqOpen) + EmptyTransactionsHint(faqOpen) } } } } } -@Composable -fun TransactionItem(event: HistoricalTransactionUiModel) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(CodeTheme.dimens.grid.x12) - ) { - Column( - modifier = Modifier - .wrapContentWidth() - .align(Alignment.CenterStart) - .padding(horizontal = CodeTheme.dimens.inset) - ) { - Text( - modifier = Modifier - .wrapContentWidth() - .padding(bottom = CodeTheme.dimens.grid.x1), - text = when (event.paymentType) { - PaymentType.Send -> - when { - event.isWithdrawal -> stringResource(R.string.title_withdrewKin) - event.isRemoteSend -> stringResource(R.string.title_sent) - else -> stringResource(R.string.title_gaveKin) - } - - PaymentType.Receive -> - when { - event.airdropType == AirdropType.GiveFirstKin -> stringResource(R.string.title_referralBonus) - event.airdropType == AirdropType.GetFirstKin -> stringResource(R.string.title_welcomeBonus) - event.isDeposit -> stringResource(R.string.title_deposited) - event.isRemoteSend && event.isReturned -> stringResource(R.string.title_returned) - else -> stringResource(R.string.title_received) - } - - else -> stringResource(R.string.title_unknown) - }, - style = CodeTheme.typography.body1 - ) - Text( - modifier = Modifier - .wrapContentWidth() - .padding(top = CodeTheme.dimens.grid.x1), - text = event.dateText, - color = BrandLight, - style = CodeTheme.typography.body2 - ) - } - Column( - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(horizontal = CodeTheme.dimens.inset), - horizontalAlignment = Alignment.End - ) { - Row { - event.currencyResourceId?.let { - Image( - modifier = Modifier - .padding(end = CodeTheme.dimens.grid.x2) - .size(CodeTheme.dimens.grid.x3) - .clip(CodeTheme.shapes.extraSmall) - .align(CenterVertically), - painter = painterResource(id = event.currencyResourceId), - contentDescription = "" - ) - } - Text( - text = event.amountText, - style = CodeTheme.typography.body1 - ) - } - if (!event.isKin) { - Row( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3) - ) { - Image( - modifier = Modifier - .padding(end = CodeTheme.dimens.grid.x1) - .size(CodeTheme.dimens.staticGrid.x2) - .align(CenterVertically), - painter = painterResource(id = R.drawable.ic_kin_brand), - contentDescription = "" - ) - Text( - text = event.kinAmountText, - style = CodeTheme.typography.body1, - color = BrandLight - ) - } - } - } - - Divider( - color = White10, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .height(0.5.dp) - .align(BottomCenter) - ) - } -} - @Composable fun BalanceTop( state: BalanceSheetViewModel.State, @@ -282,7 +179,7 @@ fun BalanceTop( amountText = state.amountText, isAltCaption = false, isAltCaptionKinIcon = false, - isLoading = state.historicalTransactionsLoading, + isLoading = state.chatsLoading, currencyResId = state.currencyFlag, isClickable = isClickable, onClick = onClick, @@ -342,7 +239,7 @@ private fun ColumnScope.KinValueHint(onClick: () -> Unit) { } @Composable -private fun EmptyTransactionsHint(upPress: () -> Unit, faqOpen: () -> Unit) { +private fun EmptyTransactionsHint(faqOpen: () -> Unit) { val context = LocalContext.current Column( modifier = Modifier @@ -419,7 +316,8 @@ private fun TopPreview() { amountText = "$12.34 of Kin", marketValue = 1.0, selectedRate = Rate(Currency.Kin.rate, CurrencyCode.KIN), - historicalTransactions = emptyList(), + chatsLoading = false, + chats = emptyList(), isDebugBucketsEnabled = false, isDebugBucketsVisible = false, ) @@ -429,29 +327,3 @@ private fun TopPreview() { isClickable = false ) } - -@Preview -@Composable -private fun ItemPreview() { - val transaction = HistoricalTransactionUiModel( - id = emptyList(), - amountText = "$1.23 of Kin", - dateText = "2023-10-10 10:10 p.m.", - isKin = false, - kinAmountText = "1,234", - currencyResourceId = R.drawable.ic_currency_kin, - paymentType = PaymentType.Send, - isDeposit = true, - isWithdrawal = false, - isRemoteSend = false, - isReturned = false, - airdropType = null - ) - - PreviewColumn { - TransactionItem(event = transaction) - } -} - - - diff --git a/app/src/main/java/com/getcode/view/main/balance/BalanceSheetViewModel.kt b/app/src/main/java/com/getcode/view/main/balance/BalanceSheetViewModel.kt index 75d07ac65..0987dff8a 100644 --- a/app/src/main/java/com/getcode/view/main/balance/BalanceSheetViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/balance/BalanceSheetViewModel.kt @@ -1,60 +1,46 @@ package com.getcode.view.main.balance import androidx.lifecycle.viewModelScope -import com.getcode.R -import com.getcode.data.transactions.HistoricalTransactionUiModel -import com.getcode.data.transactions.toUi -import com.getcode.model.Currency -import com.getcode.model.CurrencyCode +import com.getcode.model.Chat import com.getcode.model.PrefsBool import com.getcode.model.Rate -import com.getcode.network.client.Client -import com.getcode.network.client.historicalTransactions -import com.getcode.network.exchange.Exchange -import com.getcode.network.repository.BalanceRepository +import com.getcode.network.BalanceController +import com.getcode.network.HistoryController import com.getcode.network.repository.PrefRepository -import com.getcode.util.CurrencyUtils -import com.getcode.util.Kin -import com.getcode.util.locale.LocaleHelper -import com.getcode.util.resources.ResourceHelper -import com.getcode.utils.FormatUtils import com.getcode.utils.network.NetworkConnectivityListener import com.getcode.view.BaseViewModel2 import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.shareIn import timber.log.Timber -import java.util.Locale import javax.inject.Inject @HiltViewModel class BalanceSheetViewModel @Inject constructor( - client: Client, - private val localeHelper: LocaleHelper, - private val currencyUtils: CurrencyUtils, - private val resources: ResourceHelper, - exchange: Exchange, - balanceRepository: BalanceRepository, + balanceController: BalanceController, + historyController: HistoryController, prefsRepository: PrefRepository, networkObserver: NetworkConnectivityListener, ) : BaseViewModel2( initialState = State(), updateStateForEvent = updateStateForEvent -) { data class State( +) { + data class State( val amountText: String = "", val marketValue: Double = 0.0, val selectedRate: Rate? = null, val currencyFlag: Int? = null, - val historicalTransactionsLoading: Boolean = true, - val historicalTransactions: List = listOf(), + val chatsLoading: Boolean = false, + val chats: List = emptyList(), val isDebugBucketsEnabled: Boolean = false, val isDebugBucketsVisible: Boolean = false, ) @@ -66,17 +52,14 @@ class BalanceSheetViewModel @Inject constructor( val rate: Rate, ) : Event - data class OnCurrencyFlagChanged( - val flagResId: Int?, - ) : Event data class OnBalanceChanged( + val flagResId: Int?, val marketValue: Double, val display: String, ) : Event - data class OnTransactionsLoading(val loading: Boolean) : Event - data class OnTransactionsUpdated(val transactions: List) : - Event + data class OnChatsLoading(val loading: Boolean) : Event + data class OnChatsUpdated(val chats: List) : Event } init { @@ -87,79 +70,45 @@ class BalanceSheetViewModel @Inject constructor( dispatchEvent(Dispatchers.Main, Event.OnDebugBucketsEnabled(enabled)) }.launchIn(viewModelScope) - combine( - exchange.observeRates() - .distinctUntilChanged() - .flowOn(Dispatchers.IO) - .map { getCurrency(it) } - .onEach { - dispatchEvent(Event.OnCurrencyFlagChanged(it.resId)) - } - .mapNotNull { currency -> CurrencyCode.tryValueOf(currency.code) } - .mapNotNull { - exchange.fetchRatesIfNeeded() - exchange.rateFor(it) - }, - balanceRepository.balanceFlow, - networkObserver.state - ) { rate, balance, _ -> - rate to balance - }.map { (rate, balance) -> - dispatchEvent(Dispatchers.Main, Event.OnLatestRateChanged(rate)) - refreshBalance(balance, rate.fx) - }.distinctUntilChanged().onEach { (marketValue, amountText) -> - dispatchEvent(Dispatchers.Main, Event.OnBalanceChanged(marketValue, amountText)) - }.launchIn(viewModelScope) + balanceController.formattedBalance + .onEach { Timber.d("b=$it") } + .filterNotNull() + .onEach { + dispatchEvent( + Dispatchers.Main, + Event.OnBalanceChanged( + flagResId = it.flag, + marketValue = it.marketValue, + display = it.formattedValue + ) + ) + }.launchIn(viewModelScope) - client.historicalTransactions() - .onEach { Timber.d("trx=$it") } - .map { - when { - it == null -> null // await for confirmation it's empty - it.isEmpty() && !networkObserver.isConnected -> null // remain loading while disconnected - else -> it + historyController.chats + .onEach { + if (it == null || (it.isEmpty() && !networkObserver.isConnected)) { + dispatchEvent(Dispatchers.Main, Event.OnChatsLoading(true)) } } - .distinctUntilChanged() - .map { it?.sortedByDescending { t -> t.date } } - .mapNotNull { historical -> historical?.map { transaction -> - transaction.toUi({ currencyUtils.getCurrency(it) }, resources = resources) } + .map { chats -> + when { + chats == null -> null // await for confirmation it's empty + chats.isEmpty() && !networkObserver.isConnected -> null // remain loading while disconnected + chats.any { it.messages.isEmpty() } -> null // remain loading while fetching messages + else -> chats + } } + .filterNotNull() .onEach { update -> - dispatchEvent(Dispatchers.Main, Event.OnTransactionsUpdated(update)) + dispatchEvent(Dispatchers.Main, Event.OnChatsUpdated(update)) }.onEach { - dispatchEvent(Dispatchers.Main, Event.OnTransactionsLoading(false)) + dispatchEvent(Dispatchers.Main, Event.OnChatsLoading(false)) }.launchIn(viewModelScope) } - //TODO manage currency with a repository rather than a static class - private suspend fun getCurrency(rates: Map): Currency = - withContext(Dispatchers.Default) { - val defaultCurrencyCode = localeHelper.getDefaultCurrency()?.code - return@withContext currencyUtils.getCurrenciesWithRates(rates) - .firstOrNull { p -> - p.code == defaultCurrencyCode - } ?: Currency.Kin - } - - private fun refreshBalance(balance: Double, rate: Double): Pair { - val fiatValue = FormatUtils.getFiatValue(balance, rate) - val locale = Locale( - Locale.getDefault().language, - localeHelper.getDefaultCountry() - ) - val fiatValueFormatted = FormatUtils.formatCurrency(fiatValue, locale) - val amountText = StringBuilder().apply { - append(fiatValueFormatted) - append(" ") - append(resources.getString(R.string.core_ofKin)) - }.toString() - - return fiatValue to amountText - } - companion object { val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + Timber.d("event=${event.javaClass.simpleName}") when (event) { is Event.OnDebugBucketsEnabled -> { state -> state.copy(isDebugBucketsEnabled = event.enabled) @@ -173,20 +122,18 @@ class BalanceSheetViewModel @Inject constructor( state.copy(selectedRate = event.rate) } - is Event.OnCurrencyFlagChanged -> { state -> - state.copy(currencyFlag = event.flagResId) - } is Event.OnBalanceChanged -> { state -> state.copy( + currencyFlag = event.flagResId, marketValue = event.marketValue, amountText = event.display, ) } - is Event.OnTransactionsLoading -> { state -> - state.copy(historicalTransactionsLoading = event.loading) + is Event.OnChatsLoading -> { state -> + state.copy(chatsLoading = event.loading) } - is Event.OnTransactionsUpdated -> { state -> - state.copy(historicalTransactions = event.transactions) + is Event.OnChatsUpdated -> { state -> + state.copy(chats = event.chats) } } } diff --git a/app/src/main/java/com/getcode/view/main/bill/BillAmount.kt b/app/src/main/java/com/getcode/view/main/bill/BillAmount.kt index 5eca78af2..328bff5bd 100644 --- a/app/src/main/java/com/getcode/view/main/bill/BillAmount.kt +++ b/app/src/main/java/com/getcode/view/main/bill/BillAmount.kt @@ -2,7 +2,6 @@ package com.getcode.view.main.bill import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* -import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -13,7 +12,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import com.getcode.R import com.getcode.theme.CodeTheme -import com.getcode.util.nonScaledSp +import com.getcode.ui.utils.nonScaledSp @Composable @Preview diff --git a/app/src/main/java/com/getcode/view/main/bill/CashBill.kt b/app/src/main/java/com/getcode/view/main/bill/CashBill.kt index 07a2b4b63..5b9a6c237 100644 --- a/app/src/main/java/com/getcode/view/main/bill/CashBill.kt +++ b/app/src/main/java/com/getcode/view/main/bill/CashBill.kt @@ -59,9 +59,9 @@ import com.getcode.solana.keys.base58 import com.getcode.theme.CodeTheme import com.getcode.theme.White50 import com.getcode.util.formattedRaw -import com.getcode.util.nonScaledSp -import com.getcode.util.punchCircle -import com.getcode.util.punchRectangle +import com.getcode.ui.utils.nonScaledSp +import com.getcode.ui.utils.punchCircle +import com.getcode.ui.utils.punchRectangle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt b/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt new file mode 100644 index 000000000..c71001582 --- /dev/null +++ b/app/src/main/java/com/getcode/view/main/chat/ChatScreen.kt @@ -0,0 +1,156 @@ +package com.getcode.view.main.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.itemContentType +import androidx.paging.compose.itemKey +import com.getcode.R +import com.getcode.manager.BottomBarManager +import com.getcode.theme.BrandDark +import com.getcode.theme.BrandLight +import com.getcode.theme.CodeTheme +import com.getcode.util.formatDateRelatively +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.Pill +import com.getcode.ui.components.chat.MessageNode +import com.getcode.ui.components.chat.localized + +@Composable +fun ChatScreen( + state: ChatViewModel.State, + messages: LazyPagingItems, + dispatch: (ChatViewModel.Event) -> Unit, +) { + val listState = rememberLazyListState() + + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.weight(1f), + state = listState, + reverseLayout = true, + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x6, + ), + verticalArrangement = Arrangement.Top, + ) { + items( + count = messages.itemCount, + key = messages.itemKey { item -> item.key }, + contentType = messages.itemContentType { item -> + when (item) { + is ChatItem.Date -> "separators" + is ChatItem.Message -> "messages" + } + } + ) { index -> + when (val item = messages[index]) { + is ChatItem.Date -> DateBubble( + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x2), + date = item.date + ) + is ChatItem.Message -> { + // reverse layout so +1 to get previous + val prev = runCatching { messages[index + 1] } + .map { it as? ChatItem.Message } + .map { it?.chatMessageId } + .getOrNull() + // reverse layout so -1 to get next + val next = runCatching { messages[index - 1] } + .map { it as? ChatItem.Message } + .map { it?.chatMessageId } + .getOrNull() + + MessageNode( + modifier = Modifier.fillMaxWidth(), + contents = item.message, + date = item.date, + isPreviousSameMessage = prev == item.chatMessageId, + isNextSameMessage = next == item.chatMessageId, + ) + } + + else -> Unit + } + } + // add last separator + // this isn't handled by paging separators due to no `beforeItem` to reference against + // at end of list due to reverseLayout + if (messages.itemCount > 0) { + (messages[messages.itemCount - 1] as? ChatItem.Message)?.date?.let { date -> + item { + val dateString = remember(date) { + date.formatDateRelatively() + } + DateBubble( + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x2), + date = dateString + ) + } + } + } + } + + val context = LocalContext.current + val title = state.title.localized + CodeButton( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + val strokeWidth = Dp.Hairline.toPx() + drawLine( + color = BrandLight, + Offset(0f, 0f), + Offset(size.width, 0f), + strokeWidth + ) + }, + onClick = { + BottomBarManager.showMessage( + BottomBarManager.BottomBarMessage( + title = context.getString(if (state.isMuted) R.string.prompt_title_unmute_chat else R.string.prompt_title_mute_chat, title), + subtitle = context.getString(if (state.isMuted) R.string.prompt_description_unmute_chat else R.string.prompt_description_mute_chat, title), + positiveText = context.getString(if (state.isMuted) R.string.action_unmute else R.string.action_mute), + negativeText = context.getString(R.string.action_nevermind), + onPositive = { dispatch(ChatViewModel.Event.OnMuteToggled) }, + isDismissible = false, + ) + ) + }, + shape = RectangleShape, + buttonState = ButtonState.Subtle, + text = stringResource(if (state.isMuted) R.string.action_unmute else R.string.action_mute) + ) + } +} + + +@Composable +private fun DateBubble( + modifier: Modifier = Modifier, + date: String, +) = Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Pill( + text = date, + backgroundColor = BrandDark + ) +} diff --git a/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt new file mode 100644 index 000000000..1700d8b5b --- /dev/null +++ b/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt @@ -0,0 +1,164 @@ +package com.getcode.view.main.chat + +import androidx.lifecycle.viewModelScope +import androidx.paging.cachedIn +import androidx.paging.flatMap +import androidx.paging.insertSeparators +import androidx.paging.map +import com.getcode.R +import com.getcode.manager.BottomBarManager +import com.getcode.model.ID +import com.getcode.model.MessageContent +import com.getcode.model.Title +import com.getcode.network.HistoryController +import com.getcode.network.client.Client +import com.getcode.network.client.fetchChats +import com.getcode.network.client.setMuted +import com.getcode.util.formatDateRelatively +import com.getcode.util.resources.ResourceHelper +import com.getcode.util.toInstantFromMillis +import com.getcode.view.BaseViewModel2 +import com.getcode.view.main.home.PresentationStyle +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.transform +import kotlinx.datetime.Instant +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject + +typealias ChatMessageIndice = Triple + +sealed class ChatItem(val key: Any) { + data class Message( + val id: String = UUID.randomUUID().toString(), + val chatMessageId: ID, + val message: MessageContent, + val date: Instant + ) : ChatItem(id) + + data class Date(val date: String) : ChatItem(date) +} + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class ChatViewModel @Inject constructor( + historyController: HistoryController, +) : BaseViewModel2( + initialState = State(null, null, false), + updateStateForEvent = updateStateForEvent +) { + data class State( + val chatId: ID?, + val title: Title?, + val isMuted: Boolean, + ) + + sealed interface Event { + data class OnChatIdChanged(val id: ID?) : Event + data class OnChatChanged(val title: Title?) : Event + data object OnMuteToggled : Event + data class SetMuted(val muted: Boolean) : Event + } + + init { + stateFlow + .map { it.chatId } + .filterNotNull() + .onEach { historyController.advanceReadPointer(it) } + .flatMapLatest { historyController.chats } + .flowOn(Dispatchers.IO) + .filterNotNull() + .mapNotNull { chats -> chats.firstOrNull { it.id == stateFlow.value.chatId } } + .onEach { + dispatchEvent(Dispatchers.Main, Event.OnChatChanged(it.title)) + dispatchEvent(Dispatchers.Main, Event.SetMuted(it.isMuted)) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { stateFlow.value.chatId to stateFlow.value.isMuted } + .filter { it.first != null } + .map { it.first!! to it.second } + .map { (chatId, muted) -> + dispatchEvent(Event.SetMuted(!muted)) + historyController.setMuted(chatId, !muted) + } + .onEach { result -> + if (result.isSuccess) { + val muted = result.getOrNull() ?: false + Timber.d(if (muted) "Muted chat" else "Unmuted chat") + } else { + result.exceptionOrNull()?.printStackTrace() + dispatchEvent(Event.SetMuted(!stateFlow.value.isMuted)) + } + } + .launchIn(viewModelScope) + } + + val chatMessages = stateFlow + .map { it.chatId } + .filterNotNull() + .flatMapLatest { historyController.chatFlow(it) } + .mapLatest { page -> + page.flatMap { chat -> + chat.contents + .sortedWith(compareBy { it is MessageContent.Localized }) + .map { ChatMessageIndice(it, chat.id, chat.dateMillis.toInstantFromMillis()) } + } + } + .mapLatest { page -> + page.map { (message, id, date) -> + ChatItem.Message( + chatMessageId = id, + message = message, + date = date + ) + } + } + .mapLatest { page -> + page.insertSeparators { before: ChatItem.Message?, after: ChatItem.Message? -> + val beforeDate = before?.date?.formatDateRelatively() + val afterDate = after?.date?.formatDateRelatively() + + if (beforeDate != afterDate) { + beforeDate?.let { ChatItem.Date(it) } + } else { + null + } + } + }.cachedIn(viewModelScope) + + companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.OnChatIdChanged -> { state -> + state.copy(chatId = event.id) + } + + is Event.OnChatChanged -> { state -> + state.copy(title = event.title) + } + + Event.OnMuteToggled -> { state -> state } + + is Event.SetMuted -> { state -> + state.copy(isMuted = event.muted) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/connectivity/ConnectionStatusComponent.kt b/app/src/main/java/com/getcode/view/main/connectivity/ConnectionStatusComponent.kt index b865dca15..6600dce5d 100644 --- a/app/src/main/java/com/getcode/view/main/connectivity/ConnectionStatusComponent.kt +++ b/app/src/main/java/com/getcode/view/main/connectivity/ConnectionStatusComponent.kt @@ -13,7 +13,7 @@ import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.utils.network.ConnectionType import com.getcode.utils.network.NetworkState -import com.getcode.view.components.CodeCircularProgressIndicator +import com.getcode.ui.components.CodeCircularProgressIndicator @Composable fun ConnectionStatus(state: NetworkState) { diff --git a/app/src/main/java/com/getcode/view/main/currency/CurrencySelectionSheet.kt b/app/src/main/java/com/getcode/view/main/currency/CurrencySelectionSheet.kt index 8ebbc2298..aefe4db36 100644 --- a/app/src/main/java/com/getcode/view/main/currency/CurrencySelectionSheet.kt +++ b/app/src/main/java/com/getcode/view/main/currency/CurrencySelectionSheet.kt @@ -54,10 +54,10 @@ import com.getcode.theme.CodeTheme import com.getcode.theme.White05 import com.getcode.theme.White50 import com.getcode.theme.inputColors -import com.getcode.util.RepeatOnLifecycle -import com.getcode.util.rememberedClickable -import com.getcode.view.components.CodeCircularProgressIndicator -import com.getcode.view.components.SwipeableView +import com.getcode.ui.utils.RepeatOnLifecycle +import com.getcode.ui.utils.rememberedClickable +import com.getcode.ui.components.CodeCircularProgressIndicator +import com.getcode.ui.components.SwipeableView import com.getcode.view.main.giveKin.CurrencyListItem import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged @@ -67,7 +67,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import timber.log.Timber @OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable diff --git a/app/src/main/java/com/getcode/view/main/getKin/BuyAndSellKin.kt b/app/src/main/java/com/getcode/view/main/getKin/BuyAndSellKin.kt index fe0ac1a9a..784281436 100644 --- a/app/src/main/java/com/getcode/view/main/getKin/BuyAndSellKin.kt +++ b/app/src/main/java/com/getcode/view/main/getKin/BuyAndSellKin.kt @@ -27,9 +27,9 @@ import androidx.constraintlayout.compose.ConstraintLayout import androidx.lifecycle.viewmodel.compose.viewModel import com.getcode.R import com.getcode.theme.CodeTheme -import com.getcode.util.rememberedClickable -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton +import com.getcode.ui.utils.rememberedClickable +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn diff --git a/app/src/main/java/com/getcode/view/main/getKin/GetKinSheet.kt b/app/src/main/java/com/getcode/view/main/getKin/GetKinSheet.kt index fcba93266..a5f0848d1 100644 --- a/app/src/main/java/com/getcode/view/main/getKin/GetKinSheet.kt +++ b/app/src/main/java/com/getcode/view/main/getKin/GetKinSheet.kt @@ -34,10 +34,10 @@ import com.getcode.navigation.screens.ReferFriendScreen import com.getcode.theme.CodeTheme import com.getcode.theme.White import com.getcode.theme.White05 -import com.getcode.util.RepeatOnLifecycle -import com.getcode.util.addIf -import com.getcode.util.rememberedClickable -import com.getcode.view.components.CodeCircularProgressIndicator +import com.getcode.ui.utils.RepeatOnLifecycle +import com.getcode.ui.utils.addIf +import com.getcode.ui.utils.rememberedClickable +import com.getcode.ui.components.CodeCircularProgressIndicator import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map diff --git a/app/src/main/java/com/getcode/view/main/getKin/GetKinSheetViewModel.kt b/app/src/main/java/com/getcode/view/main/getKin/GetKinSheetViewModel.kt index f5786690d..80e0fbd22 100644 --- a/app/src/main/java/com/getcode/view/main/getKin/GetKinSheetViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/getKin/GetKinSheetViewModel.kt @@ -7,8 +7,8 @@ import com.getcode.manager.TopBarManager import com.getcode.model.KinAmount import com.getcode.model.PrefsBool import com.getcode.network.BalanceController +import com.getcode.network.HistoryController import com.getcode.network.client.Client -import com.getcode.network.client.fetchPaymentHistoryDelta import com.getcode.network.client.receiveFromPrimaryIfWithinLimits import com.getcode.network.client.requestFirstKinAirdrop import com.getcode.network.repository.PrefRepository @@ -21,7 +21,6 @@ import com.getcode.utils.network.NetworkConnectivityListener import com.getcode.view.BaseViewModel2 import dagger.hilt.android.lifecycle.HiltViewModel import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance @@ -31,7 +30,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.reactive.asFlow -import java.util.concurrent.TimeUnit +import timber.log.Timber import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -43,6 +42,7 @@ class GetKinSheetViewModel @Inject constructor( private val client: Client, private val networkObserver: NetworkConnectivityListener, private val resources: ResourceHelper, + private val historyController: HistoryController, ) : BaseViewModel2( initialState = State(), updateStateForEvent = updateStateForEvent @@ -95,7 +95,6 @@ class GetKinSheetViewModel @Inject constructor( ErrorUtils.showNetworkError(resources) return@mapNotNull null } - SessionManager.getKeyPair() }.onEach { dispatchEvent(Event.OnLoadingChanged(true)) } .catchSafely( @@ -108,6 +107,8 @@ class GetKinSheetViewModel @Inject constructor( dispatchEvent(Event.OnKinRequestSuccessful(amount)) balanceController.fetchBalanceSuspend() + + historyController.fetchChats() }, onFailure = { if (it is TransactionRepository.AirdropException.AlreadyClaimedException) { diff --git a/app/src/main/java/com/getcode/view/main/getKin/ReferFriend.kt b/app/src/main/java/com/getcode/view/main/getKin/ReferFriend.kt index 5b5541e5d..737f4b99d 100644 --- a/app/src/main/java/com/getcode/view/main/getKin/ReferFriend.kt +++ b/app/src/main/java/com/getcode/view/main/getKin/ReferFriend.kt @@ -16,8 +16,8 @@ import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import com.getcode.R import com.getcode.theme.CodeTheme -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton @Composable fun ReferFriend() { diff --git a/app/src/main/java/com/getcode/view/main/giveKin/AmountArea.kt b/app/src/main/java/com/getcode/view/main/giveKin/AmountArea.kt index eaeeb8225..f571310e3 100644 --- a/app/src/main/java/com/getcode/view/main/giveKin/AmountArea.kt +++ b/app/src/main/java/com/getcode/view/main/giveKin/AmountArea.kt @@ -18,16 +18,16 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.TextUnit import com.getcode.LocalNetworkObserver import com.getcode.R import com.getcode.theme.Alert import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme -import com.getcode.util.rememberedClickable +import com.getcode.ui.utils.rememberedClickable import com.getcode.utils.network.NetworkState import com.getcode.view.main.connectivity.ConnectionStatus import com.getcode.view.main.connectivity.NetworkStateProvider +import timber.log.Timber @OptIn(ExperimentalAnimationApi::class) @Composable diff --git a/app/src/main/java/com/getcode/view/main/giveKin/AmountText.kt b/app/src/main/java/com/getcode/view/main/giveKin/AmountText.kt index a7656fa28..b2cf5582e 100644 --- a/app/src/main/java/com/getcode/view/main/giveKin/AmountText.kt +++ b/app/src/main/java/com/getcode/view/main/giveKin/AmountText.kt @@ -8,28 +8,40 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.TextUnit import com.getcode.theme.CodeTheme import com.getcode.theme.White -import com.getcode.theme.displayLarge + + +object AmountSizeStore { + private val cachedSizes = mutableMapOf() + fun hasCachedSize(amountText: String) = cachedSizes[amountText] != null + fun remember(amountText: String, size: TextUnit) { + cachedSizes[amountText] = size + } + + fun lookup(amountText: String) = cachedSizes[amountText] +} @Composable fun AmountText( + modifier: Modifier = Modifier, currencyResId: Int?, amountText: String, textStyle: TextStyle = CodeTheme.typography.h1, ) { - val displayLarge = textStyle.copy(textAlign = TextAlign.Center) - var scaledTextStyle by remember { mutableStateOf(displayLarge) } + val centeredText = textStyle.copy(textAlign = TextAlign.Center) + var scaledTextStyle by remember { mutableStateOf(centeredText) } var isReadyToDraw by remember { mutableStateOf(false) } Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().then(modifier), horizontalArrangement = Arrangement.Center ) { if (currencyResId != null && currencyResId > 0) { @@ -48,8 +60,10 @@ fun AmountText( .wrapContentHeight() .padding(start = CodeTheme.dimens.grid.x3) .padding(vertical = CodeTheme.dimens.grid.x3) - .drawWithContent { - if (isReadyToDraw) drawContent() + .drawWithCache { + onDrawWithContent { + if (isReadyToDraw) drawContent() + } }, text = amountText, color = White, @@ -57,11 +71,14 @@ fun AmountText( maxLines = 1, softWrap = false, onTextLayout = { textLayoutResult: TextLayoutResult -> - if (textLayoutResult.didOverflowWidth) { - scaledTextStyle = - scaledTextStyle.copy(fontSize = scaledTextStyle.fontSize * 0.9) - } else { - isReadyToDraw = true + if (!isReadyToDraw) { + if (textLayoutResult.didOverflowWidth) { + scaledTextStyle = + scaledTextStyle.copy(fontSize = scaledTextStyle.fontSize * 0.9) + } else { + AmountSizeStore.remember(amountText, scaledTextStyle.fontSize) + isReadyToDraw = true + } } } ) diff --git a/app/src/main/java/com/getcode/view/main/giveKin/GiveKinSheet.kt b/app/src/main/java/com/getcode/view/main/giveKin/GiveKinSheet.kt index 35f00a71d..a3ecc7ed7 100644 --- a/app/src/main/java/com/getcode/view/main/giveKin/GiveKinSheet.kt +++ b/app/src/main/java/com/getcode/view/main/giveKin/GiveKinSheet.kt @@ -27,9 +27,9 @@ import com.getcode.theme.CodeTheme import com.getcode.theme.displayLarge import com.getcode.util.showNetworkError import com.getcode.utils.ErrorUtils -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton -import com.getcode.view.components.CodeKeyPad +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.CodeKeyPad import kotlinx.coroutines.launch @Preview diff --git a/app/src/main/java/com/getcode/view/main/home/DecorView.kt b/app/src/main/java/com/getcode/view/main/home/DecorView.kt index 50a344dc0..84648f832 100644 --- a/app/src/main/java/com/getcode/view/main/home/DecorView.kt +++ b/app/src/main/java/com/getcode/view/main/home/DecorView.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -39,10 +38,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.getcode.LocalNetworkObserver import com.getcode.R -import com.getcode.theme.Black50 import com.getcode.theme.CodeTheme import com.getcode.theme.xxl -import com.getcode.util.rememberedClickable +import com.getcode.ui.utils.rememberedClickable +import com.getcode.ui.components.Pill import com.getcode.view.main.home.components.HomeBottom @Composable @@ -98,25 +97,15 @@ internal fun DecorView( fadeOut(animationSpec = tween(500, 100)) else fadeOut(animationSpec = tween(0)), ) { - Row( - modifier = Modifier - .wrapContentSize() - .clip(CodeTheme.shapes.xxl) - .background(Black50) - .padding( - horizontal = CodeTheme.dimens.grid.x2, - vertical = CodeTheme.dimens.grid.x1 - ), - ) { - val toast by - remember(dataState.billState.toast) { derivedStateOf { dataState.billState.toast } } - Text( - text = toast?.formattedAmount.orEmpty(), - style = CodeTheme.typography.body2.copy( - fontWeight = FontWeight.Bold - ) - ) + val toast by remember(dataState.billState.toast) { + derivedStateOf { dataState.billState.toast } } + Pill( + text = toast?.formattedAmount.orEmpty(), + textStyle = CodeTheme.typography.body2.copy( + fontWeight = FontWeight.Bold + ) + ) } val networkState by LocalNetworkObserver.current.state.collectAsState() @@ -155,9 +144,9 @@ internal fun DecorView( HomeBottom( modifier = Modifier - .fillMaxWidth() .windowInsetsPadding(WindowInsets.navigationBars) .padding(bottom = CodeTheme.dimens.grid.x3), + state = dataState, onPress = { showBottomSheet(it) }, diff --git a/app/src/main/java/com/getcode/view/main/home/HomeRestricted.kt b/app/src/main/java/com/getcode/view/main/home/HomeRestricted.kt index 6d1e690a7..495c34eba 100644 --- a/app/src/main/java/com/getcode/view/main/home/HomeRestricted.kt +++ b/app/src/main/java/com/getcode/view/main/home/HomeRestricted.kt @@ -23,8 +23,8 @@ import com.getcode.theme.Brand import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.theme.White -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton @Composable fun HomeRestricted( diff --git a/app/src/main/java/com/getcode/view/main/home/HomeScan.kt b/app/src/main/java/com/getcode/view/main/home/HomeScan.kt index d326a0326..893a4ca3f 100644 --- a/app/src/main/java/com/getcode/view/main/home/HomeScan.kt +++ b/app/src/main/java/com/getcode/view/main/home/HomeScan.kt @@ -15,7 +15,6 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -37,7 +36,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.BottomCenter import androidx.compose.ui.Alignment.Companion.Center @@ -58,14 +56,15 @@ import com.getcode.navigation.screens.BalanceModal import com.getcode.navigation.screens.GetKinModal import com.getcode.navigation.screens.GiveKinModal import com.getcode.theme.CodeTheme -import com.getcode.util.AnimationUtils -import com.getcode.util.addIf -import com.getcode.util.measured +import com.getcode.ui.components.OnLifecycleEvent +import com.getcode.ui.components.PermissionCheck +import com.getcode.ui.components.getPermissionLauncher +import com.getcode.ui.components.startupLog +import com.getcode.ui.utils.AnimationUtils +import com.getcode.ui.utils.addIf +import com.getcode.util.isEmulator +import com.getcode.ui.utils.measured import com.getcode.view.camera.KikCodeScannerView -import com.getcode.view.components.OnLifecycleEvent -import com.getcode.view.components.PermissionCheck -import com.getcode.view.components.startupLog -import com.getcode.view.components.getPermissionLauncher import com.getcode.view.main.home.components.BillManagementOptions import com.getcode.view.main.home.components.HomeBill import com.getcode.view.main.home.components.PaymentConfirmation @@ -317,7 +316,9 @@ private fun BillContainer( AnimatedVisibility( modifier = Modifier.fillMaxSize(), - visible = !isCameraReady, + // camera isn't really usable on an emulator so don't fade in wacky + // camera feed + visible = isEmulator || !isCameraReady, enter = fadeIn( animationSpec = tween(AnimationUtils.animationTime) ), diff --git a/app/src/main/java/com/getcode/view/main/home/HomeViewModel.kt b/app/src/main/java/com/getcode/view/main/home/HomeViewModel.kt index 30b7e91c0..666fa5dd0 100644 --- a/app/src/main/java/com/getcode/view/main/home/HomeViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/home/HomeViewModel.kt @@ -39,13 +39,15 @@ import com.getcode.models.PaymentState import com.getcode.models.Valuation import com.getcode.models.amountFloored import com.getcode.network.BalanceController +import com.getcode.network.HistoryController import com.getcode.network.client.Client import com.getcode.network.client.RemoteSendException import com.getcode.network.client.awaitEstablishRelationship import com.getcode.network.client.cancelRemoteSend import com.getcode.network.client.fetchLimits -import com.getcode.network.client.fetchPaymentHistoryDelta +import com.getcode.network.client.receiveFromPrimaryIfWithinLimits import com.getcode.network.client.receiveRemoteSuspend +import com.getcode.network.client.requestFirstKinAirdrop import com.getcode.network.client.sendRemotely import com.getcode.network.client.sendRequestToReceiveBill import com.getcode.network.exchange.Exchange @@ -67,6 +69,7 @@ import com.getcode.util.showNetworkError import com.getcode.util.vibration.Vibrator import com.getcode.utils.ErrorUtils import com.getcode.utils.base64EncodedData +import com.getcode.utils.catchSafely import com.getcode.utils.network.NetworkConnectivityListener import com.getcode.utils.nonce import com.getcode.vendor.Base58 @@ -92,13 +95,18 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import timber.log.Timber @@ -130,6 +138,7 @@ data class HomeUiModel( val billState: BillState = BillState.Default, val restrictionType: RestrictionType? = null, val isRemoteSendLoading: Boolean = false, + val chatUnreadCount: Int = 0, ) sealed interface HomeEvent { @@ -150,6 +159,7 @@ class HomeViewModel @Inject constructor( private val receiveTransactionRepository: ReceiveTransactionRepository, private val paymentRepository: PaymentRepository, private val balanceController: BalanceController, + private val historyController: HistoryController, private val prefRepository: PrefRepository, private val analytics: AnalyticsService, private val authManager: AuthManager, @@ -171,8 +181,9 @@ class HomeViewModel @Inject constructor( private var sendTransactionDisposable: Disposable? = null init { + onDrawn() Database.isInit - .flatMap { prefRepository.get(PrefsBool.DISPLAY_ERRORS) } + .flatMap { prefRepository.getFlowable(PrefsBool.DISPLAY_ERRORS) } .subscribe(ErrorUtils::setDisplayErrors) StatusRepository().getIsUpgradeRequired(BuildConfig.VERSION_CODE) @@ -183,9 +194,37 @@ class HomeViewModel @Inject constructor( } } + prefRepository.observeOrDefault(PrefsBool.IS_ELIGIBLE_GET_FIRST_KIN_AIRDROP, true) + .map { it } + .distinctUntilChanged() + .onEach { Timber.d("airdrop eligible=$it") } + .filter { it } + .mapNotNull { SessionManager.getKeyPair() } + .catchSafely( + action = { owner -> + val amount = client.requestFirstKinAirdrop(owner).getOrThrow() + prefRepository.set(PrefsBool.IS_ELIGIBLE_GET_FIRST_KIN_AIRDROP, false) + balanceController.fetchBalanceSuspend() + + val organizer = SessionManager.getOrganizer() + val receiveWithinLimits = organizer?.let { + client.receiveFromPrimaryIfWithinLimits(it) + } ?: Completable.complete() + receiveWithinLimits.subscribe({}, {}) + + showToast(amount = amount, isDeposit = true) + + historyController.fetchChats() + }, + onFailure = { + Timber.e(t = it, message = "Auto airdrop failed") + } + ) + .launchIn(viewModelScope) + combine( exchange.observeLocalRate(), - balanceController.observe(), + balanceController.observeRawBalance(), ) { rate, balance -> KinAmount.newInstance(Kin.fromKin(balance), rate) }.onEach { balanceInKin -> @@ -194,6 +233,13 @@ class HomeViewModel @Inject constructor( } }.launchIn(viewModelScope) + historyController.unreadCount + .distinctUntilChanged() + .map { it } + .onEach { count -> + uiFlow.update { it.copy(chatUnreadCount = count) } + }.launchIn(viewModelScope) + prefRepository.observeOrDefault(PrefsBool.LOG_SCAN_TIMES, false) .flowOn(Dispatchers.IO) .onEach { log -> @@ -292,12 +338,12 @@ class HomeViewModel @Inject constructor( Completable.concatArray( balanceController.fetchBalance(), client.fetchLimits(isForce = true), - client.fetchPaymentHistoryDelta(owner).ignoreElement() ) } .subscribe({ cancelSend(PresentationStyle.Pop) vibrator.vibrate() + viewModelScope.launch { historyController.fetchChats() } }, { ErrorUtils.handleError(it) cancelSend(style = PresentationStyle.Slide) @@ -523,12 +569,13 @@ class HomeViewModel @Inject constructor( } } - private fun attemptPayment(payload: CodePayload, request: DeepLinkPaymentRequest? = null) = viewModelScope.launch { - val (amount, p) = paymentRepository.attemptRequest(payload) ?: return@launch - BottomBarManager.clear() + private fun attemptPayment(payload: CodePayload, request: DeepLinkPaymentRequest? = null) = + viewModelScope.launch { + val (amount, p) = paymentRepository.attemptRequest(payload) ?: return@launch + BottomBarManager.clear() - presentRequest(amount = amount, payload = p, request = request) - } + presentRequest(amount = amount, payload = p, request = request) + } fun presentRequest( amount: KinAmount, @@ -823,10 +870,9 @@ class HomeViewModel @Inject constructor( Completable.concatArray( balanceController.fetchBalance(), client.fetchLimits(isForce = true), - client.fetchPaymentHistoryDelta(organizer.ownerKeyPair).ignoreElement() ) } - .subscribe({ }, { + .subscribe({ viewModelScope.launch { historyController.fetchChats() } }, { scannedRendezvous.remove(payload.rendezvous.publicKey) ErrorUtils.handleError(it) }) diff --git a/app/src/main/java/com/getcode/view/main/home/components/BillManagementOptions.kt b/app/src/main/java/com/getcode/view/main/home/components/BillManagementOptions.kt index a72be495f..67c9d18a5 100644 --- a/app/src/main/java/com/getcode/view/main/home/components/BillManagementOptions.kt +++ b/app/src/main/java/com/getcode/view/main/home/components/BillManagementOptions.kt @@ -5,13 +5,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer 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.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -25,9 +23,8 @@ import com.getcode.R import com.getcode.theme.CodeTheme import com.getcode.theme.Gray50 import com.getcode.theme.White -import com.getcode.util.debugBounds -import com.getcode.util.rememberedClickable -import com.getcode.view.components.CodeCircularProgressIndicator +import com.getcode.ui.utils.rememberedClickable +import com.getcode.ui.components.CodeCircularProgressIndicator @Composable internal fun BillManagementOptions( diff --git a/app/src/main/java/com/getcode/view/main/home/components/HomeBill.kt b/app/src/main/java/com/getcode/view/main/home/components/HomeBill.kt index 83d02732d..afee9587a 100644 --- a/app/src/main/java/com/getcode/view/main/home/components/HomeBill.kt +++ b/app/src/main/java/com/getcode/view/main/home/components/HomeBill.kt @@ -13,7 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.getcode.models.Bill -import com.getcode.view.components.CustomSwipeToDismiss +import com.getcode.ui.components.CustomSwipeToDismiss import com.getcode.view.main.bill.Bill @OptIn(ExperimentalMaterialApi::class) diff --git a/app/src/main/java/com/getcode/view/main/home/components/HomeBottom.kt b/app/src/main/java/com/getcode/view/main/home/components/HomeBottom.kt index 250fe763e..a6ebb4436 100644 --- a/app/src/main/java/com/getcode/view/main/home/components/HomeBottom.kt +++ b/app/src/main/java/com/getcode/view/main/home/components/HomeBottom.kt @@ -1,121 +1,141 @@ package com.getcode.view.main.home.components import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.MaterialTheme +import androidx.compose.foundation.layout.size import androidx.compose.material.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.graphics.painter.Painter +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layoutId import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.constraintlayout.compose.ConstraintLayout +import androidx.compose.ui.unit.Dp import com.getcode.R import com.getcode.theme.CodeTheme -import com.getcode.util.rememberedClickable +import com.getcode.ui.utils.heightOrZero +import com.getcode.ui.utils.rememberedClickable +import com.getcode.ui.utils.widthOrZero +import com.getcode.ui.components.Badge +import com.getcode.ui.components.Row +import com.getcode.ui.components.chat.ChatNodeDefaults import com.getcode.view.main.home.HomeBottomSheet +import com.getcode.view.main.home.HomeUiModel @Preview @Composable internal fun HomeBottom( modifier: Modifier = Modifier, + state: HomeUiModel = HomeUiModel(), onPress: (homeBottomSheet: HomeBottomSheet) -> Unit = {}, ) { - ConstraintLayout(modifier = modifier.fillMaxWidth()) { - val (button1, button2, button3) = createRefs() + Row( + modifier = Modifier + .fillMaxWidth() + .then(modifier), + verticalAlignment = Alignment.Bottom, + contentPadding = PaddingValues(horizontal = CodeTheme.dimens.grid.x3), + ) { + BottomBarAction( + label = stringResource(R.string.title_getKin), + contentPadding = PaddingValues( + start = CodeTheme.dimens.grid.x3, + end = CodeTheme.dimens.grid.x3, + top = CodeTheme.dimens.grid.x1, + bottom = CodeTheme.dimens.grid.x2, + ), + imageSize = CodeTheme.dimens.grid.x7, + painter = painterResource(R.drawable.ic_wallet), + onClick = { onPress(HomeBottomSheet.GET_KIN) } + ) + Spacer(modifier = Modifier.weight(1f)) + BottomBarAction( + label = stringResource(R.string.action_giveKin), + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.grid.x3, + vertical = CodeTheme.dimens.grid.x2 + ), + imageSize = CodeTheme.dimens.grid.x10, + painter = painterResource(R.drawable.ic_kin_white), + onClick = { onPress(HomeBottomSheet.GIVE_KIN) } + ) + Spacer(modifier = Modifier.weight(1f)) + BottomBarAction( + label = stringResource(R.string.action_balance), + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.grid.x2, + ), + imageSize = CodeTheme.dimens.grid.x9, + painter = painterResource(R.drawable.ic_history), + onClick = { onPress(HomeBottomSheet.BALANCE) }, + badge = { Badge(count = state.chatUnreadCount, color = ChatNodeDefaults.UnreadIndicator) } + ) + } +} - Column(modifier = Modifier - .constrainAs(button2) { - centerHorizontallyTo(parent) - bottom.linkTo(parent.bottom) - } - .clip( - RoundedCornerShape(10.dp) - ) - .rememberedClickable { onPress(HomeBottomSheet.GIVE_KIN) }, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( +@Composable +private fun BottomBarAction( + modifier: Modifier = Modifier, + label: String, + contentPadding: PaddingValues = PaddingValues(), + painter: Painter, + imageSize: Dp, + badge: @Composable () -> Unit = { }, + onClick: () -> Unit, +) { + Layout( + modifier = modifier, + content = { + Column( modifier = Modifier - .padding(horizontal = 15.dp) - .padding(vertical = 11.dp) - .height(51.dp) - .width(51.dp), - painter = painterResource( - R.drawable.ic_kin_white - ), - contentDescription = stringResource(R.string.action_giveKin), - ) - Text( - text = stringResource(R.string.action_giveKin), - style = CodeTheme.typography.body2 - ) - } + .clip(CodeTheme.shapes.medium) + .rememberedClickable { onClick() } + .layoutId("action"), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + modifier = Modifier + .padding(contentPadding) + .size(imageSize), + painter = painter, + contentDescription = null, + ) + Text( + text = label, + style = CodeTheme.typography.body2 + ) + } - Column(modifier = Modifier - .constrainAs(button1) { - start.linkTo(parent.start) - bottom.linkTo(parent.bottom) + Box(modifier = Modifier.layoutId("badge")) { + badge() } - .padding(start = 15.dp) - .clip( - RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp) - ) - .rememberedClickable { - onPress(HomeBottomSheet.GET_KIN) - }, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - modifier = Modifier - .padding(horizontal = 15.dp) - .padding(bottom = 8.dp, top = 5.dp) - .height(32.dp) - .width(32.dp), - painter = painterResource( - R.drawable.ic_wallet - ), - contentDescription = stringResource(R.string.title_getKin), - ) - Text( - text = stringResource(R.string.title_getKin), - style = CodeTheme.typography.body2 - ) } + ) { measurables, incomingConstraints -> + val constraints = incomingConstraints.copy(minWidth = 0, minHeight = 0) + val actionPlaceable = + measurables.find { it.layoutId == "action" }?.measure(constraints) + val badgePlaceable = + measurables.find { it.layoutId == "badge" }?.measure(constraints) - Column(modifier = Modifier - .constrainAs(button3) { - end.linkTo(parent.end) - bottom.linkTo(parent.bottom) - } - .padding(end = 15.dp) - .clip( - RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp) - ) - .rememberedClickable { onPress(HomeBottomSheet.BALANCE) }, - horizontalAlignment = Alignment.CenterHorizontally + val maxWidth = widthOrZero(actionPlaceable) + val maxHeight = heightOrZero(actionPlaceable) // + heightOrZero(badgePlaceable) / 2 + layout( + width = maxWidth, + height = maxHeight, ) { - Image( - modifier = Modifier - .padding(horizontal = 10.dp) - .height(44.dp) - .width(44.dp), - painter = painterResource( - R.drawable.ic_history - ), - contentDescription = stringResource(R.string.action_balance), - ) - Text( - text = stringResource(R.string.action_balance), - style = CodeTheme.typography.body2 + actionPlaceable?.placeRelative(0, 0) + badgePlaceable?.placeRelative( + x = maxWidth - widthOrZero(badgePlaceable), + y = -(heightOrZero(badgePlaceable) / 2) ) } } diff --git a/app/src/main/java/com/getcode/view/main/home/components/PaymentConfirmation.kt b/app/src/main/java/com/getcode/view/main/home/components/PaymentConfirmation.kt index dc2a07d06..29a69322f 100644 --- a/app/src/main/java/com/getcode/view/main/home/components/PaymentConfirmation.kt +++ b/app/src/main/java/com/getcode/view/main/home/components/PaymentConfirmation.kt @@ -50,10 +50,10 @@ import com.getcode.models.PaymentState import com.getcode.theme.Brand import com.getcode.theme.CodeTheme import com.getcode.theme.White50 -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton -import com.getcode.view.components.SlideToConfirm -import com.getcode.view.components.SlideToConfirmDefaults +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.SlideToConfirm +import com.getcode.ui.components.SlideToConfirmDefaults import kotlinx.coroutines.delay @Composable diff --git a/app/src/main/java/com/getcode/view/main/home/components/PermissionsBlockingView.kt b/app/src/main/java/com/getcode/view/main/home/components/PermissionsBlockingView.kt index f5aad8818..f97a58ff4 100644 --- a/app/src/main/java/com/getcode/view/main/home/components/PermissionsBlockingView.kt +++ b/app/src/main/java/com/getcode/view/main/home/components/PermissionsBlockingView.kt @@ -16,10 +16,10 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.getcode.R import com.getcode.theme.CodeTheme -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton -import com.getcode.view.components.PermissionCheck -import com.getcode.view.components.PermissionsLauncher +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.PermissionCheck +import com.getcode.ui.components.PermissionsLauncher @Composable internal fun PermissionsBlockingView( diff --git a/app/src/main/java/com/getcode/view/main/home/components/PriceWithFlag.kt b/app/src/main/java/com/getcode/view/main/home/components/PriceWithFlag.kt index 2ac5a4797..48960d35b 100644 --- a/app/src/main/java/com/getcode/view/main/home/components/PriceWithFlag.kt +++ b/app/src/main/java/com/getcode/view/main/home/components/PriceWithFlag.kt @@ -57,7 +57,6 @@ internal fun PriceWithFlag( tint = Color.Unspecified, contentDescription = currencyCodeName.let { "$it flag" } ) - Timber.d("amount=$amount") text(amount.formatted()) } } diff --git a/app/src/main/java/com/getcode/view/main/home/components/ReceivedKinConfirmation.kt b/app/src/main/java/com/getcode/view/main/home/components/ReceivedKinConfirmation.kt index 0eb278349..f78b2889b 100644 --- a/app/src/main/java/com/getcode/view/main/home/components/ReceivedKinConfirmation.kt +++ b/app/src/main/java/com/getcode/view/main/home/components/ReceivedKinConfirmation.kt @@ -22,8 +22,8 @@ import com.getcode.theme.Brand import com.getcode.theme.CodeTheme import com.getcode.util.flagResId import com.getcode.util.formatted -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton import com.getcode.view.main.giveKin.AmountArea @Composable diff --git a/app/src/main/java/com/getcode/view/main/invites/InvitesContacts.kt b/app/src/main/java/com/getcode/view/main/invites/InvitesContacts.kt index a18035eef..370060754 100644 --- a/app/src/main/java/com/getcode/view/main/invites/InvitesContacts.kt +++ b/app/src/main/java/com/getcode/view/main/invites/InvitesContacts.kt @@ -21,11 +21,11 @@ import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme import com.getcode.theme.White10 import com.getcode.theme.inputColors -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton -import com.getcode.view.components.CodeCircularProgressIndicator -import com.getcode.view.components.getButtonBorder -import com.getcode.view.components.getButtonColors +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.CodeCircularProgressIndicator +import com.getcode.ui.components.getButtonBorder +import com.getcode.ui.components.getButtonColors @Preview @Composable diff --git a/app/src/main/java/com/getcode/view/main/invites/InvitesPermission.kt b/app/src/main/java/com/getcode/view/main/invites/InvitesPermission.kt index aa94b781a..720208be2 100644 --- a/app/src/main/java/com/getcode/view/main/invites/InvitesPermission.kt +++ b/app/src/main/java/com/getcode/view/main/invites/InvitesPermission.kt @@ -11,8 +11,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import com.getcode.view.components.ButtonState -import com.getcode.view.components.CodeButton +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton import com.getcode.R import com.getcode.theme.CodeTheme diff --git a/app/src/main/java/com/getcode/view/main/invites/InvitesSheet.kt b/app/src/main/java/com/getcode/view/main/invites/InvitesSheet.kt index 632fbf30a..88f083276 100644 --- a/app/src/main/java/com/getcode/view/main/invites/InvitesSheet.kt +++ b/app/src/main/java/com/getcode/view/main/invites/InvitesSheet.kt @@ -10,9 +10,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import com.getcode.R import com.getcode.theme.CodeTheme -import com.getcode.view.components.PermissionCheck -import com.getcode.view.components.SheetTitle -import com.getcode.view.components.getPermissionLauncher +import com.getcode.ui.components.PermissionCheck +import com.getcode.ui.components.SheetTitle +import com.getcode.ui.components.getPermissionLauncher @Preview @Composable diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae5f62e85..680d2cc79 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,6 +50,9 @@ Withdraw Kin Wrote the 12 Words Down Instead? Yes + Mute + Unmute + Nevermind Yes, Withdraw Kin Yes, I Wrote Them Down and @@ -155,6 +158,10 @@ Are you sure? View Your Access Key? Are You Sure? + Mute %1$s? + Unmute %1$s? + You will be notified of any new messages from %1$s. You can unmute at any time. + You will not be notified of any new messages from %1$s. You can mute at any time. %s Kin was deposited into your account. The %s you sent yesterday wasn\'t collected. It has been automatically returned to your balance. Get a friend started on Code and get $5 @@ -288,4 +295,19 @@ If enabled, the device will vibrate once to indicate that the camera has registered the code on the bill. If enabled, the device will log additional processing information while grabbing bills. If enabled, Give Kin screen will show requests for entered amounts instead of cash bills. + + Web Payments + Cash Payments + Code Team + Welcome to Code! Here is your first dollar to get you started: + You sent someone their first Kin! Here is your referral bonus: + You gave + You received + You withdrew + You deposited + You sent + You returned + You spent + You paid + You purchased diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 3734223e6..0959076d2 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -11,6 +11,7 @@ object Versions { const val kotlin = "1.9.22" const val kotlinx_coroutines = "1.7.3" const val kotlinx_serialization = "1.6.2" + const val kotlinx_datetime = "0.5.0" const val android_gradle_build_tools = "8.2.2" const val google_services = "4.3.15" @@ -19,7 +20,7 @@ object Versions { const val androidx_lifecycle = "2.6.2" const val androidx_navigation = "2.7.4" const val androidx_browser = "1.4.0" - const val androidx_material = "1.5.0" + const val androidx_paging = "3.2.1" const val androidx_room = "2.6.1" const val sqlcipher = "4.5.1@aar" @@ -31,6 +32,7 @@ object Versions { const val compose_activities: String = "1.8.2" const val compose_view_models: String = "2.6.2" const val compose_navigation: String = "2.7.3" + const val compose_paging = "3.3.0-alpha02" const val hilt = "2.50" const val hilt_jetpack = "1.1.0-beta01" @@ -88,7 +90,6 @@ object Plugins { const val androidx_navigation_safeargs = "androidx.navigation.safeargs.kotlin" const val kotlin_android = "kotlin-android" const val kotlin_parcelize = "kotlin-parcelize" - const val kotlin_jvm = "org.jetbrains.kotlin.jvm" const val kotlin_kapt = "kotlin-kapt" const val kotlin_serialization = "org.jetbrains.kotlin.plugin.serialization" const val hilt = "dagger.hilt.android.plugin" @@ -110,8 +111,8 @@ object Libs { const val androidx_navigation_ui = "androidx.navigation:navigation-ui-ktx:${Versions.androidx_navigation}" const val androidx_browser = "androidx.browser:browser:${Versions.androidx_browser}" - const val android_material = - "com.google.android.material:material:${Versions.androidx_material}" + const val androidx_paging_runtime = "androidx.paging:paging-runtime-ktx:${Versions.androidx_paging}" + const val androidx_room_runtime = "androidx.room:room-runtime:${Versions.androidx_room}" const val androidx_room_rxjava3 = "androidx.room:room-rxjava3:${Versions.androidx_room}" const val androidx_room_compiler = "androidx.room:room-compiler:${Versions.androidx_room}" @@ -131,16 +132,16 @@ object Libs { const val kotlinx_coroutines_test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.kotlinx_coroutines}" const val kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.6" + const val kotlinx_datetime = "org.jetbrains.kotlinx:kotlinx-datetime:${Versions.kotlinx_datetime}" const val kotlinx_serialization_json = "org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.kotlinx_serialization}" - const val okhttp = "com.squareup.okhttp3:okhttp:${Versions.okhttp}" const val okhttp_logging_interceptor = "com.squareup.okhttp3:logging-interceptor:${Versions.okhttp}" const val androidx_constraint_layout_compose = "androidx.constraintlayout:constraintlayout-compose:1.0.1" - //const val androidx_lifecycle_compose = androidx.lifecycle-viewmodel-compose:${Versions.androidx_lifecycle}" + const val androidx_lifecycle_viewmodel = "androidx.lifecycle:lifecycle-viewmodel-ktx:${Versions.androidx_lifecycle}" const val compose_bom = "androidx.compose:compose-bom:${Versions.compose}" const val compose_ui = "androidx.compose.ui:ui" @@ -149,6 +150,7 @@ object Libs { "androidx.compose.ui:ui-tooling-preview" const val compose_foundation = "androidx.compose.foundation:foundation" const val compose_material = "androidx.compose.material:material" + const val compose_materialIconsExtended = "androidx.compose.material:material-icons-extended-android" const val compose_activities = "androidx.activity:activity-compose:${Versions.compose_activities}" const val compose_view_models = @@ -156,6 +158,7 @@ object Libs { const val compose_livedata = "androidx.compose.runtime:runtime-livedata" const val compose_navigation = "androidx.navigation:navigation-compose:${Versions.compose_navigation}" + const val compose_paging = "androidx.paging:paging-compose:${Versions.compose_paging}" const val compose_voyager_navigation = "cafe.adriel.voyager:voyager-navigator:${Versions.voyager}" const val compose_voyager_navigation_hilt = "cafe.adriel.voyager:voyager-hilt:${Versions.voyager}" const val compose_voyager_navigation_bottomsheet = "cafe.adriel.voyager:voyager-bottom-sheet-navigator:${Versions.voyager}"