From 73efc9001133d9c9ffc063ccf88346fdd92637e1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 21 Aug 2024 16:58:22 -0400 Subject: [PATCH] feat: improve network resiliency when starting in a no network state Signed-off-by: Brandon McAnsh --- .../java/com/getcode/db/ConversationDao.kt | 6 + .../com/getcode/mapper/ConversationMapper.kt | 1 - .../main/java/com/getcode/model/Feature.kt | 8 +- .../main/java/com/getcode/model/PrefBool.kt | 4 +- .../main/java/com/getcode/model/chat/Chat.kt | 5 + .../com/getcode/model/chat/ChatMessage.kt | 4 + .../com/getcode/network/BalanceController.kt | 51 +++--- ...Controller.kt => ChatHistoryController.kt} | 45 +++--- .../getcode/network/ConversationController.kt | 2 +- .../com/getcode/network/api/CurrencyApi.kt | 10 +- .../com/getcode/network/exchange/Exchange.kt | 145 ++++++++---------- .../network/repository/BetaFlagsRepository.kt | 16 +- .../network/repository/FeatureRepository.kt | 10 +- .../network/service/CurrencyService.kt | 54 +++++++ .../java/com/getcode/utils/network/Retry.kt | 58 +++++++ app/build.gradle.kts | 1 + .../main/java/com/getcode/inject/ApiModule.kt | 7 +- .../java/com/getcode/inject/DataModule.kt | 5 +- .../java/com/getcode/manager/AuthManager.kt | 5 +- .../getcode/navigation/screens/ChatScreens.kt | 105 ++----------- .../getcode/navigation/screens/MainScreens.kt | 102 ++++++++++++ .../notifications/CodePushMessagingService.kt | 4 +- .../com/getcode/ui/components/CodeButton.kt | 4 +- .../getcode/ui/components/chat/ChatNode.kt | 11 +- .../ui/components/chat/TipChatActions.kt | 6 +- .../java/com/getcode/util/AccountUtils.kt | 73 ++++----- .../java/com/getcode/view/login/LoginHome.kt | 4 +- .../view/main/account/BetaFlagsScreen.kt | 18 +-- .../AccountWithdrawSummaryViewModel.kt | 4 +- .../main/balance/BalanceSheetViewModel.kt | 9 +- .../getcode/view/main/chat/ChatViewModel.kt | 6 +- .../conversation/ConversationViewModel.kt | 10 +- .../view/main/chat/list/ChatListScreen.kt | 39 +++++ .../view/main/chat/list/ChatListViewModel.kt | 44 ++++++ .../com/getcode/view/main/home/HomeScan.kt | 5 +- .../getcode/view/main/home/HomeViewModel.kt | 50 +++--- .../view/main/home/components/HomeBottom.kt | 35 ++++- app/src/main/res/drawable/ic_chat.xml | 9 ++ app/src/main/res/values/strings-universal.xml | 8 +- app/src/main/res/values/strings.xml | 3 + common/resources/build.gradle.kts | 12 +- .../util/resources/icons/ImageVector.kt | 77 ++++++++++ .../util/resources/icons/MessageCircle.kt | 52 +++++++ 43 files changed, 749 insertions(+), 378 deletions(-) rename api/src/main/java/com/getcode/network/{HistoryController.kt => ChatHistoryController.kt} (91%) create mode 100644 api/src/main/java/com/getcode/network/service/CurrencyService.kt create mode 100644 api/src/main/java/com/getcode/utils/network/Retry.kt create mode 100644 app/src/main/java/com/getcode/view/main/chat/list/ChatListScreen.kt create mode 100644 app/src/main/java/com/getcode/view/main/chat/list/ChatListViewModel.kt create mode 100644 app/src/main/res/drawable/ic_chat.xml create mode 100644 common/resources/src/main/java/com/getcode/util/resources/icons/ImageVector.kt create mode 100644 common/resources/src/main/java/com/getcode/util/resources/icons/MessageCircle.kt diff --git a/api/src/main/java/com/getcode/db/ConversationDao.kt b/api/src/main/java/com/getcode/db/ConversationDao.kt index dac6e0c89..ff98c92ad 100644 --- a/api/src/main/java/com/getcode/db/ConversationDao.kt +++ b/api/src/main/java/com/getcode/db/ConversationDao.kt @@ -1,5 +1,7 @@ package com.getcode.db +import androidx.paging.PagingData +import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert @@ -19,6 +21,10 @@ interface ConversationDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertConversations(vararg conversation: Conversation) + @RewriteQueriesToDropUnusedColumns + @Query("SELECT * FROM conversations") + fun observeConversations(): PagingSource + @RewriteQueriesToDropUnusedColumns @Query("SELECT * FROM conversations LEFT JOIN conversation_pointers ON conversations.idBase58 = conversation_pointers.conversationIdBase58 WHERE conversations.idBase58 = :id") fun observeConversation(id: String): Flow diff --git a/api/src/main/java/com/getcode/mapper/ConversationMapper.kt b/api/src/main/java/com/getcode/mapper/ConversationMapper.kt index d814f13f8..548c58dc9 100644 --- a/api/src/main/java/com/getcode/mapper/ConversationMapper.kt +++ b/api/src/main/java/com/getcode/mapper/ConversationMapper.kt @@ -4,7 +4,6 @@ import com.getcode.model.Conversation import com.getcode.model.chat.Chat import com.getcode.model.chat.ChatType import com.getcode.model.chat.self -import com.getcode.network.TipController import com.getcode.network.localized import com.getcode.network.repository.base58 import com.getcode.util.resources.ResourceHelper diff --git a/api/src/main/java/com/getcode/model/Feature.kt b/api/src/main/java/com/getcode/model/Feature.kt index b62461377..f1102cfd0 100644 --- a/api/src/main/java/com/getcode/model/Feature.kt +++ b/api/src/main/java/com/getcode/model/Feature.kt @@ -22,13 +22,13 @@ data class TipCardOnHomeScreenFeature( override val available: Boolean = true, // always available ): Feature -data class TipChatFeature( - override val enabled: Boolean = BetaOptions.Defaults.tipsChatEnabled, +data class ConversationsFeature( + override val enabled: Boolean = BetaOptions.Defaults.conversationsEnabled, override val available: Boolean = true, // always available ): Feature -data class TipChatCashFeature( - override val enabled: Boolean = BetaOptions.Defaults.tipsChatCashEnabled, +data class ConversationCashFeature( + override val enabled: Boolean = BetaOptions.Defaults.conversationCashEnabled, override val available: Boolean = true, // always available ): Feature diff --git a/api/src/main/java/com/getcode/model/PrefBool.kt b/api/src/main/java/com/getcode/model/PrefBool.kt index 7645b92c7..185daa3ac 100644 --- a/api/src/main/java/com/getcode/model/PrefBool.kt +++ b/api/src/main/java/com/getcode/model/PrefBool.kt @@ -46,8 +46,8 @@ sealed class PrefsBool(val value: String) { data object BUY_MODULE_ENABLED : PrefsBool("buy_kin_enabled"), BetaFlag data object CHAT_UNSUB_ENABLED: PrefsBool("chat_unsub_enabled"), BetaFlag data object TIPS_ENABLED : PrefsBool("tips_enabled"), BetaFlag - data object TIPS_CHAT_ENABLED: PrefsBool("tips_chat_enabled"), BetaFlag - data object TIPS_CHAT_CASH_ENABLED: PrefsBool("tips_chat_cash_enabled"), BetaFlag + data object CONVERSATIONS_ENABLED: PrefsBool("conversations_enabled"), BetaFlag + data object CONVERSATION_CASH_ENABLED: PrefsBool("convo_cash_enabled"), BetaFlag data object BALANCE_CURRENCY_SELECTION_ENABLED: PrefsBool("balance_currency_enabled"), BetaFlag data object KADO_WEBVIEW_ENABLED : PrefsBool("kado_inapp_enabled"), BetaFlag data object SHARE_TWEET_TO_TIP : PrefsBool("share_tweet_to_tip"), BetaFlag diff --git a/api/src/main/java/com/getcode/model/chat/Chat.kt b/api/src/main/java/com/getcode/model/chat/Chat.kt index 944af211d..624fe8893 100644 --- a/api/src/main/java/com/getcode/model/chat/Chat.kt +++ b/api/src/main/java/com/getcode/model/chat/Chat.kt @@ -2,6 +2,7 @@ package com.getcode.model.chat import com.getcode.model.Cursor import com.getcode.model.ID +import kotlinx.serialization.Serializable import java.util.UUID /** @@ -18,6 +19,7 @@ import java.util.UUID * @param cursor [Cursor] value for this chat for reference in subsequent GetChatsRequest * @param messages List of messages within this chat */ +@Serializable data class Chat( val id: ID, val type: ChatType, @@ -135,6 +137,9 @@ data class Chat( val Chat.isV2: Boolean get() = members.isNotEmpty() +val Chat.isNotification: Boolean + get() = type == ChatType.Notification + val Chat.isConversation: Boolean get() = type == ChatType.TwoWay diff --git a/api/src/main/java/com/getcode/model/chat/ChatMessage.kt b/api/src/main/java/com/getcode/model/chat/ChatMessage.kt index 752d375e4..977f3db87 100644 --- a/api/src/main/java/com/getcode/model/chat/ChatMessage.kt +++ b/api/src/main/java/com/getcode/model/chat/ChatMessage.kt @@ -4,6 +4,8 @@ import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.model.Cursor import com.getcode.model.ID import com.getcode.model.MessageStatus +import com.getcode.utils.serializer.UUIDSerializer +import kotlinx.serialization.Serializable import java.util.UUID /** @@ -18,8 +20,10 @@ import java.util.UUID * @param contents Ordered message content. A message may have more than one piece of content. * @param status Derived [MessageStatus] from [Pointer]'s in [ChatMember]. */ +@Serializable data class ChatMessage( val id: ID, // time based UUID in v2 + @Serializable(with = UUIDSerializer::class) val senderId: UUID?, val isFromSelf: Boolean, val cursor: Cursor, diff --git a/api/src/main/java/com/getcode/network/BalanceController.kt b/api/src/main/java/com/getcode/network/BalanceController.kt index c1aff4af5..74905e397 100644 --- a/api/src/main/java/com/getcode/network/BalanceController.kt +++ b/api/src/main/java/com/getcode/network/BalanceController.kt @@ -13,6 +13,7 @@ import com.getcode.solana.organizer.Organizer import com.getcode.solana.organizer.Tray import com.getcode.utils.FormatUtils import com.getcode.utils.network.NetworkConnectivityListener +import com.getcode.utils.network.retryable import com.getcode.utils.trace import io.reactivex.rxjava3.core.Completable import kotlinx.coroutines.CoroutineScope @@ -23,6 +24,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -38,8 +40,7 @@ data class BalanceDisplay( val marketValue: Double = 0.0, val formattedValue: String = "", val currency: Currency? = null, - - ) +) open class BalanceController @Inject constructor( exchange: Exchange, @@ -65,25 +66,34 @@ open class BalanceController @Inject constructor( .stateIn(scope, SharingStarted.Eagerly, BalanceDisplay()) init { - combine( - exchange.observeLocalRate() - .flowOn(Dispatchers.IO) - .onEach { - val display = _balanceDisplay.value ?: BalanceDisplay() - _balanceDisplay.value = display.copy(currency = getCurrencyFromCode(it.currency)) + networkObserver.state + .map { it.connected } + .onEach { connected -> + if (connected) { + retryable({ fetchBalanceSuspend() }) + } + } + .flatMapLatest { + combine( + exchange.observeLocalRate() + .flowOn(Dispatchers.IO) + .onEach { + val display = _balanceDisplay.value ?: BalanceDisplay() + _balanceDisplay.value = + display.copy(currency = getCurrencyFromCode(it.currency)) + } + .onEach { exchange.fetchRatesIfNeeded() }, + balanceRepository.balanceFlow, + ) { rate, balance -> + rate to balance.coerceAtLeast(0.0) + }.map { (rate, balance) -> + refreshBalance(balance, rate) } - .onEach { exchange.fetchRatesIfNeeded() }, - balanceRepository.balanceFlow, - networkObserver.state - ) { rate, balance, _ -> - rate to balance.coerceAtLeast(0.0) - }.map { (rate, balance) -> - refreshBalance(balance, rate) - }.distinctUntilChanged().onEach { (marketValue, amountText) -> - val display = _balanceDisplay.value ?: BalanceDisplay() - _balanceDisplay.value = - display.copy(marketValue = marketValue, formattedValue = amountText) - }.launchIn(scope) + }.distinctUntilChanged().onEach { (marketValue, amountText) -> + val display = _balanceDisplay.value ?: BalanceDisplay() + _balanceDisplay.value = + display.copy(marketValue = marketValue, formattedValue = amountText) + }.launchIn(scope) } fun setTray(organizer: Organizer, tray: Tray) { @@ -155,6 +165,7 @@ open class BalanceController @Inject constructor( suspend fun fetchBalanceSuspend() { + Timber.d("fetching balance") if (SessionManager.isAuthenticated() != true) { Timber.d("FetchBalance - Not authenticated") return diff --git a/api/src/main/java/com/getcode/network/HistoryController.kt b/api/src/main/java/com/getcode/network/ChatHistoryController.kt similarity index 91% rename from api/src/main/java/com/getcode/network/HistoryController.kt rename to api/src/main/java/com/getcode/network/ChatHistoryController.kt index 5fde54aca..77d520c1d 100644 --- a/api/src/main/java/com/getcode/network/HistoryController.kt +++ b/api/src/main/java/com/getcode/network/ChatHistoryController.kt @@ -17,13 +17,12 @@ import com.getcode.model.Cursor import com.getcode.model.ID import com.getcode.model.MessageStatus import com.getcode.model.chat.ChatMember -import com.getcode.model.chat.ChatType import com.getcode.model.chat.Identity import com.getcode.model.chat.Platform import com.getcode.model.chat.Title import com.getcode.model.chat.isConversation +import com.getcode.model.chat.isNotification import com.getcode.model.chat.selfId -import com.getcode.model.description import com.getcode.network.client.Client import com.getcode.network.client.advancePointer import com.getcode.network.client.fetchChats @@ -34,7 +33,6 @@ import com.getcode.network.repository.encodeBase64 import com.getcode.network.source.ChatMessagePagingSource import com.getcode.util.resources.ResourceHelper import com.getcode.util.resources.ResourceType -import com.getcode.utils.ErrorUtils import com.getcode.utils.TraceType import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope @@ -42,31 +40,35 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import okhttp3.internal.toImmutableList import timber.log.Timber import java.util.Locale import javax.inject.Inject import javax.inject.Singleton -// TODO: See if we can merge this into [ChatController] @Singleton -class HistoryController @Inject constructor( +class ChatHistoryController @Inject constructor( private val client: Client, - private val resources: ResourceHelper, private val tipController: TipController, private val conversationMapper: ConversationMapper, private val conversationMessageMapper: ConversationMessageMapper, ) : CoroutineScope by CoroutineScope(Dispatchers.IO) { - private val _chats = MutableStateFlow?>(null) + private val chatEntries = MutableStateFlow?>(null) + val notifications: StateFlow?> + get() = chatEntries + .map { it?.filter { entry -> entry.isNotification } } + .stateIn(this, SharingStarted.Eagerly, emptyList()) + val chats: StateFlow?> - get() = _chats.asStateFlow() + get() = chatEntries + .map { it?.filter { entry -> entry.isConversation } } + .stateIn(this, SharingStarted.Eagerly, emptyList()) var loadingMessages: Boolean = false @@ -86,9 +88,9 @@ class HistoryController @Inject constructor( pagerMap[chatId] ?: ChatMessagePagingSource( client = client, owner = owner()!!, - chat = _chats.value?.find { it.id == chatId }, + chat = chatEntries.value?.find { it.id == chatId }, onMessagesFetched = { messages -> - val chat = _chats.value?.find { it.id == chatId } ?: return@ChatMessagePagingSource + val chat = chatEntries.value?.find { it.id == chatId } ?: return@ChatMessagePagingSource updateChatWithMessages(chat, messages) } ).also { @@ -99,14 +101,14 @@ class HistoryController @Inject constructor( fun updateChatWithMessages(chat: Chat, messages: List) { val updatedMessages = (chat.messages + messages).distinctBy { it.id } val updatedChat = chat.copy(messages = updatedMessages) - val chats = _chats.value?.map { + val chats = chatEntries.value?.map { if (it.id == updatedChat.id) { updatedChat } else { it } }?.sortedByDescending { it.lastMessageMillis } - _chats.update { chats } + chatEntries.update { chats } } fun chatFlow(chatId: ID) = @@ -132,7 +134,7 @@ class HistoryController @Inject constructor( if (!update) { pagerMap.clear() chatFlows.clear() - _chats.value = containers + chatEntries.value = containers loadingMessages = true } @@ -151,13 +153,13 @@ class HistoryController @Inject constructor( } loadingMessages = false - _chats.value = updatedWithMessages.sortedByDescending { it.lastMessageMillis } + chatEntries.value = updatedWithMessages.sortedByDescending { it.lastMessageMillis } } suspend fun advanceReadPointer(chatId: ID) { val owner = owner() ?: return - _chats.update { + chatEntries.update { it?.toMutableList()?.apply chats@{ indexOfFirst { chat -> chat.id == chatId } .takeIf { index -> index >= 0 } @@ -180,7 +182,7 @@ class HistoryController @Inject constructor( } fun advanceReadPointerUpTo(chatId: ID, timestamp: Long) { - _chats.update { + chatEntries.update { it?.toMutableList()?.apply chats@{ indexOfFirst { chat -> chat.id == chatId } .takeIf { index -> index >= 0 } @@ -198,7 +200,7 @@ class HistoryController @Inject constructor( suspend fun setMuted(chat: Chat, muted: Boolean): Result { val owner = owner() ?: return Result.failure(Throwable("No owner detected")) - _chats.update { + chatEntries.update { it?.toMutableList()?.apply chats@{ indexOfFirst { item -> item.id == chat.id } .takeIf { index -> index >= 0 } @@ -216,7 +218,7 @@ class HistoryController @Inject constructor( suspend fun setSubscribed(chat: Chat, subscribed: Boolean): Result { val owner = owner() ?: return Result.failure(Throwable("No owner detected")) - _chats.update { + chatEntries.update { it?.toMutableList()?.apply chats@{ indexOfFirst { item -> item.id == chat.id } .takeIf { index -> index >= 0 } @@ -250,7 +252,6 @@ class HistoryController @Inject constructor( MessageStatus.Delivered ) } - } .onFailure { Timber.e(t = it, "Failed to fetch messages for $encodedId.") diff --git a/api/src/main/java/com/getcode/network/ConversationController.kt b/api/src/main/java/com/getcode/network/ConversationController.kt index 77b866944..0e0dfe017 100644 --- a/api/src/main/java/com/getcode/network/ConversationController.kt +++ b/api/src/main/java/com/getcode/network/ConversationController.kt @@ -47,7 +47,7 @@ interface ConversationController { } class ConversationStreamController @Inject constructor( - private val historyController: HistoryController, + private val historyController: ChatHistoryController, private val exchange: Exchange, private val chatService: ChatServiceV2, private val conversationMapper: ConversationMapper, diff --git a/api/src/main/java/com/getcode/network/api/CurrencyApi.kt b/api/src/main/java/com/getcode/network/api/CurrencyApi.kt index b9ed682e8..cd433cb1d 100644 --- a/api/src/main/java/com/getcode/network/api/CurrencyApi.kt +++ b/api/src/main/java/com/getcode/network/api/CurrencyApi.kt @@ -7,16 +7,18 @@ import io.grpc.ManagedChannel import io.reactivex.rxjava3.core.Scheduler import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn import javax.inject.Inject class CurrencyApi @Inject constructor( managedChannel: ManagedChannel, - private val scheduler: Scheduler = Schedulers.io() ) : GrpcApi(managedChannel) { private val api = CurrencyGrpc.newStub(managedChannel) - fun getRates(request: CurrencyService.GetAllRatesRequest = CurrencyService.GetAllRatesRequest.getDefaultInstance()): Single = + fun getRates(request: CurrencyService.GetAllRatesRequest = CurrencyService.GetAllRatesRequest.getDefaultInstance()): Flow = api::getAllRates - .callAsSingle(request) - .subscribeOn(scheduler) + .callAsCancellableFlow(request) + .flowOn(Dispatchers.IO) } 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 604c813ee..c2c6d4242 100644 --- a/api/src/main/java/com/getcode/network/exchange/Exchange.kt +++ b/api/src/main/java/com/getcode/network/exchange/Exchange.kt @@ -9,9 +9,12 @@ import com.getcode.model.Rate import com.getcode.network.api.CurrencyApi import com.getcode.network.core.NetworkOracle import com.getcode.network.repository.PrefRepository +import com.getcode.network.service.ApiRateResult +import com.getcode.network.service.CurrencyService import com.getcode.utils.ErrorUtils import com.getcode.utils.TraceType import com.getcode.utils.format +import com.getcode.utils.network.retryable import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -29,6 +32,7 @@ import timber.log.Timber import java.util.Date import javax.inject.Inject import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException import kotlin.time.Duration.Companion.convert import kotlin.time.Duration.Companion.minutes import kotlin.time.DurationUnit @@ -88,8 +92,7 @@ class ExchangeNull : Exchange { } class CodeExchange @Inject constructor( - private val currencyApi: CurrencyApi, - private val networkOracle: NetworkOracle, + private val currencyService: CurrencyService, prefs: PrefRepository, private val preferredCurrency: suspend () -> Currency?, private val defaultCurrency: suspend () -> Currency?, @@ -126,6 +129,7 @@ class CodeExchange @Inject constructor( private val isStale: Boolean get() { + if (rates.rates.isEmpty()) return true // Remember, the exchange rates date is the server-provided // date-of-rate and not the time the rate was fetched. It // might be reasonable for the server to return a date that @@ -145,7 +149,7 @@ class CodeExchange @Inject constructor( .mapNotNull { preferred -> preferred ?: CurrencyCode.tryValueOf(defaultCurrency()?.code.orEmpty()) }.onEach { setEntryCurrency(it) } - .launchIn(this) + .launchIn(this@CodeExchange) } launch { @@ -169,22 +173,26 @@ class CodeExchange @Inject constructor( override suspend fun fetchRatesIfNeeded() { if (isStale) { - runCatching { fetchExchangeRates() } - .onSuccess { (updatedRates, date) -> - db?.exchangeDao()?.insert(rates = updatedRates, syncedAt = date) - set(RatesBox(date, updatedRates)) - }.onFailure { - it.printStackTrace() + retryable( + call = { + currencyService.getRates() + .onSuccess { (updatedRates, date) -> + db?.exchangeDao()?.insert(rates = updatedRates, syncedAt = date) + set(RatesBox(date, updatedRates)) + } } + ) } + + updateRates() } - private suspend fun setEntryCurrency(currency: CurrencyCode) { + private fun setEntryCurrency(currency: CurrencyCode) { entryCurrency = currency updateRates() } - private suspend fun setLocalCurrency(currency: CurrencyCode) { + private fun setLocalCurrency(currency: CurrencyCode) { localCurrency = currency updateRates() } @@ -211,86 +219,67 @@ class CodeExchange @Inject constructor( override fun rateForUsd(): Rate? = rates.rateForUsd() - private suspend fun updateRates() { + private fun updateRates() { if (rates.isEmpty) { return } val localRate = localCurrency?.let { rates.rateFor(it) } - _localRate.value = if (localRate != null) { - trace( - message = "Updated the local currency: $localCurrency, " + - "Staleness ${System.currentTimeMillis() - rates.dateMillis} ms, " + - "Date: ${Date(rates.dateMillis)}", - type = TraceType.Silent - ) - localRate - } else { - trace( - message = "local:: Rate for $localCurrency not found. Defaulting to USD.", - type = TraceType.Silent - ) - rates.rateForUsd()!! + val localChanged = _localRate.value != localRate + if (localChanged) { + _localRate.value = if (localRate != null) { + trace( + tag = "Background", + message = "Updated the local currency: $localCurrency, " + + "Staleness ${System.currentTimeMillis() - rates.dateMillis} ms, " + + "Date: ${Date(rates.dateMillis)}", + type = TraceType.Process + ) + localRate + } else { + trace( + tag = "Background", + message = "local:: Rate for $localCurrency not found. Defaulting to USD.", + type = TraceType.Process + ) + rates.rateForUsd()!! + } } val entryRate = entryCurrency?.let { rates.rateFor(it) } - _entryRate.value = if (entryRate != null) { - trace( - message = "Updated the entry currency: $entryCurrency, " + - "Staleness ${System.currentTimeMillis() - rates.dateMillis} ms, " + - "Date: ${Date(rates.dateMillis)}", - type = TraceType.Silent - ) - entryRate - } else { - trace( - message = "entry:: Rate for $entryCurrency not found. Defaulting to USD.", - type = TraceType.Silent - ) - rates.rateForUsd()!! - } - - trace(tag = "Background", - message = "Updated rates", - type = TraceType.Process, - metadata = { - "date" to Instant.fromEpochMilliseconds(rates.dateMillis).format("yyyy-MM-dd HH:mm:ss") + val entryChanged = _entryRate.value != entryRate + if (entryChanged) { + _entryRate.value = if (entryRate != null) { + trace( + tag = "Background", + message = "Updated the entry currency: $entryCurrency, " + + "Staleness ${System.currentTimeMillis() - rates.dateMillis} ms, " + + "Date: ${Date(rates.dateMillis)}", + type = TraceType.Process + ) + entryRate + } else { + trace( + tag = "Background", + message = "entry:: Rate for $entryCurrency not found. Defaulting to USD.", + type = TraceType.Process + ) + rates.rateForUsd()!! } - ) - - } + } - @OptIn(ExperimentalTime::class) - @SuppressLint("CheckResult") - private suspend fun fetchExchangeRates() = suspendCancellableCoroutine { cont -> - Timber.d("fetching rates") - currencyApi.getRates() - .let { networkOracle.managedRequest(it) } - .subscribe({ response -> - val rates = response.ratesMap.mapNotNull { (key, value) -> - val currency = CurrencyCode.tryValueOf(key) ?: return@mapNotNull null - Rate(fx = value, currency = currency) - }.toMutableList() - - if (rates.none { it.currency == CurrencyCode.KIN }) { - rates.add(Rate(fx = 1.0, currency = CurrencyCode.KIN)) + if (localChanged || entryChanged) { + trace(tag = "Background", + message = "Updated rates", + type = TraceType.Process, + metadata = { + "date" to Instant.fromEpochMilliseconds(rates.dateMillis) + .format("yyyy-MM-dd HH:mm:ss") } - - cont.resume( - rates.toList() to convert( - value = response.asOf.seconds.toDouble(), - sourceUnit = DurationUnit.SECONDS, - targetUnit = DurationUnit.MILLISECONDS - ).toLong() - ) - }, { - ErrorUtils.handleError(it) - cont.resume(emptyList() to System.currentTimeMillis()) - }) + ) + } } - - } private data class RatesBox(val dateMillis: Long, val rates: Map) { diff --git a/api/src/main/java/com/getcode/network/repository/BetaFlagsRepository.kt b/api/src/main/java/com/getcode/network/repository/BetaFlagsRepository.kt index ba9c121ef..7d6a9f0da 100644 --- a/api/src/main/java/com/getcode/network/repository/BetaFlagsRepository.kt +++ b/api/src/main/java/com/getcode/network/repository/BetaFlagsRepository.kt @@ -15,8 +15,8 @@ data class BetaOptions( val buyModuleEnabled: Boolean, val chatUnsubEnabled: Boolean, val tipsEnabled: Boolean, - val tipsChatEnabled: Boolean, - val tipsChatCashEnabled: Boolean, + val conversationsEnabled: Boolean, + val conversationCashEnabled: Boolean, val balanceCurrencySelectionEnabled: Boolean, val kadoWebViewEnabled: Boolean, val shareTweetToTip: Boolean, @@ -34,8 +34,8 @@ data class BetaOptions( buyModuleEnabled = true, chatUnsubEnabled = false, tipsEnabled = true, - tipsChatEnabled = false, - tipsChatCashEnabled = false, + conversationsEnabled = false, + conversationCashEnabled = false, balanceCurrencySelectionEnabled = true, kadoWebViewEnabled = false, shareTweetToTip = false, @@ -70,8 +70,8 @@ class BetaFlagsRepository @Inject constructor( observeBetaFlag(PrefsBool.BUY_MODULE_ENABLED, default = defaults.buyModuleEnabled), observeBetaFlag(PrefsBool.CHAT_UNSUB_ENABLED, default = defaults.chatUnsubEnabled), observeBetaFlag(PrefsBool.TIPS_ENABLED, default = defaults.tipsEnabled), - observeBetaFlag(PrefsBool.TIPS_CHAT_ENABLED, default = defaults.tipsChatEnabled), - observeBetaFlag(PrefsBool.TIPS_CHAT_CASH_ENABLED, default = defaults.tipsChatCashEnabled), + observeBetaFlag(PrefsBool.CONVERSATIONS_ENABLED, default = defaults.conversationsEnabled), + observeBetaFlag(PrefsBool.CONVERSATION_CASH_ENABLED, default = defaults.conversationCashEnabled), observeBetaFlag(PrefsBool.BALANCE_CURRENCY_SELECTION_ENABLED, defaults.balanceCurrencySelectionEnabled), observeBetaFlag(PrefsBool.DISPLAY_ERRORS, default = defaults.displayErrors), observeBetaFlag(PrefsBool.KADO_WEBVIEW_ENABLED, default = defaults.kadoWebViewEnabled), @@ -87,8 +87,8 @@ class BetaFlagsRepository @Inject constructor( buyModuleEnabled = it[5], chatUnsubEnabled = it[6], tipsEnabled = it[7], - tipsChatEnabled = it[8], - tipsChatCashEnabled = it[9], + conversationsEnabled = it[8], + conversationCashEnabled = it[9], balanceCurrencySelectionEnabled = it[10], displayErrors = it[11], kadoWebViewEnabled = it[12], diff --git a/api/src/main/java/com/getcode/network/repository/FeatureRepository.kt b/api/src/main/java/com/getcode/network/repository/FeatureRepository.kt index b6a0e3af7..680b157fc 100644 --- a/api/src/main/java/com/getcode/network/repository/FeatureRepository.kt +++ b/api/src/main/java/com/getcode/network/repository/FeatureRepository.kt @@ -1,15 +1,13 @@ package com.getcode.network.repository import com.getcode.model.BalanceCurrencyFeature -import com.getcode.model.BetaFlag import com.getcode.model.BuyModuleFeature -import com.getcode.model.Feature import com.getcode.model.PrefsBool import com.getcode.model.RequestKinFeature import com.getcode.model.TipCardFeature import com.getcode.model.TipCardOnHomeScreenFeature -import com.getcode.model.TipChatCashFeature -import com.getcode.model.TipChatFeature +import com.getcode.model.ConversationCashFeature +import com.getcode.model.ConversationsFeature import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -28,8 +26,8 @@ class FeatureRepository @Inject constructor( val tipCards = betaFlags.observe().map { TipCardFeature(it.tipsEnabled) } val tipCardOnHomeScreen = betaFlags.observe().map { TipCardOnHomeScreenFeature(it.tipCardOnHomeScreen) } - val tipChat = betaFlags.observe().map { TipChatFeature(it.tipsChatEnabled) } - val tipChatCash = betaFlags.observe().map { TipChatCashFeature(it.tipsChatCashEnabled) } + val conversations = betaFlags.observe().map { ConversationsFeature(it.conversationsEnabled) } + val conversationsCash = betaFlags.observe().map { ConversationCashFeature(it.conversationCashEnabled) } val requestKin = betaFlags.observe().map { RequestKinFeature(it.giveRequestsEnabled) } diff --git a/api/src/main/java/com/getcode/network/service/CurrencyService.kt b/api/src/main/java/com/getcode/network/service/CurrencyService.kt new file mode 100644 index 000000000..fd99cda8e --- /dev/null +++ b/api/src/main/java/com/getcode/network/service/CurrencyService.kt @@ -0,0 +1,54 @@ +package com.getcode.network.service + +import com.getcode.model.CurrencyCode +import com.getcode.model.Rate +import com.getcode.network.api.CurrencyApi +import com.getcode.network.core.NetworkOracle +import com.getcode.utils.ErrorUtils +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import timber.log.Timber +import javax.inject.Inject +import kotlin.time.Duration.Companion.convert +import kotlin.time.DurationUnit +import kotlin.time.ExperimentalTime + +data class ApiRateResult( + val rates: List, + val dateMillis: Long, +) + +class CurrencyService @Inject constructor( + private val api: CurrencyApi, + private val networkOracle: NetworkOracle, +) { + @OptIn(ExperimentalTime::class) + suspend fun getRates(): Result { + Timber.d("fetching rates") + return try { + networkOracle.managedRequest(api.getRates()) + .map { response -> + val rates = response.ratesMap.mapNotNull { (key, value) -> + val currency = CurrencyCode.tryValueOf(key) ?: return@mapNotNull null + Rate(fx = value, currency = currency) + }.toMutableList() + + if (rates.none { it.currency == CurrencyCode.KIN }) { + rates.add(Rate(fx = 1.0, currency = CurrencyCode.KIN)) + } + + Result.success(ApiRateResult( + rates = rates.toList(), + dateMillis = convert( + value = response.asOf.seconds.toDouble(), + sourceUnit = DurationUnit.SECONDS, + targetUnit = DurationUnit.MILLISECONDS + ).toLong() + )) + }.first() + } catch (e: Exception) { + ErrorUtils.handleError(e) + Result.failure(e) + } + } +} \ No newline at end of file diff --git a/api/src/main/java/com/getcode/utils/network/Retry.kt b/api/src/main/java/com/getcode/utils/network/Retry.kt new file mode 100644 index 000000000..e5c6128e0 --- /dev/null +++ b/api/src/main/java/com/getcode/utils/network/Retry.kt @@ -0,0 +1,58 @@ +package com.getcode.utils.network + +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import kotlinx.coroutines.delay +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +suspend fun retryable( + call: suspend () -> T, + maxRetries: Int = 3, + delayDuration: Duration = 2.seconds, + onRetry: (Int) -> Unit = { currentAttempt -> + trace( + message = "Retrying call", + metadata = { + "count" to currentAttempt + }, + type = TraceType.Process, + ) + }, + onError: (startTime: TimeSource.Monotonic.ValueTimeMark) -> Unit = { startTime -> + trace( + "Failed to get a success after $maxRetries attempts in ${startTime.elapsedNow().inWholeMilliseconds} ms", + type = TraceType.Error + ) + }, +): T? { + var currentAttempt = 0 + val startTime = TimeSource.Monotonic.markNow() + + while (currentAttempt < maxRetries) { + val result = try { + call() + } catch (e: Exception) { + trace( + message = "Attempt $currentAttempt failed with exception: ${e.message}", + error = e, + type = TraceType.Error + ) + null + } + + if (result != null) { + return result + } else { + currentAttempt++ + if (currentAttempt < maxRetries) { + onRetry(currentAttempt) + delay(delayDuration.inWholeMilliseconds) + } + } + } + + onError(startTime) + return null +} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c99c148b3..f6196b92d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -116,6 +116,7 @@ dependencies { implementation(project(":api")) implementation(project(":crypto:ed25519")) + implementation(project(":crypto:kin")) implementation(project(":common:resources")) implementation(project(":common:theme")) implementation(project(":vendor:tipkit:tipkit-m2")) diff --git a/app/src/main/java/com/getcode/inject/ApiModule.kt b/app/src/main/java/com/getcode/inject/ApiModule.kt index 9a7648902..f503bba84 100644 --- a/app/src/main/java/com/getcode/inject/ApiModule.kt +++ b/app/src/main/java/com/getcode/inject/ApiModule.kt @@ -31,6 +31,7 @@ import com.getcode.util.AccountAuthenticator import com.getcode.util.locale.LocaleHelper import com.getcode.utils.network.NetworkConnectivityListener import com.getcode.network.service.ChatServiceV2 +import com.getcode.network.service.CurrencyService import com.getcode.network.service.DeviceService import com.getcode.util.CurrencyUtils import com.getcode.util.resources.ResourceHelper @@ -191,14 +192,12 @@ object ApiModule { @Singleton @Provides fun providesExchange( - currencyApi: CurrencyApi, - networkOracle: NetworkOracle, + currencyService: CurrencyService, locale: LocaleHelper, currencyUtils: CurrencyUtils, prefRepository: PrefRepository, ): Exchange = CodeExchange( - currencyApi = currencyApi, - networkOracle = networkOracle, + currencyService = currencyService, prefs = prefRepository, preferredCurrency = { val preferredCurrencyCode = prefRepository.get( diff --git a/app/src/main/java/com/getcode/inject/DataModule.kt b/app/src/main/java/com/getcode/inject/DataModule.kt index e22594d62..b02ccb473 100644 --- a/app/src/main/java/com/getcode/inject/DataModule.kt +++ b/app/src/main/java/com/getcode/inject/DataModule.kt @@ -4,8 +4,7 @@ import com.getcode.mapper.ConversationMapper import com.getcode.mapper.ConversationMessageWithContentMapper import com.getcode.network.ConversationController import com.getcode.network.ConversationStreamController -import com.getcode.network.HistoryController -import com.getcode.network.client.Client +import com.getcode.network.ChatHistoryController import com.getcode.network.exchange.Exchange import com.getcode.network.service.ChatServiceV2 import dagger.Module @@ -21,7 +20,7 @@ object DataModule { @Provides @Singleton fun providesConversationController( - historyController: HistoryController, + historyController: ChatHistoryController, chatServiceV2: ChatServiceV2, exchange: Exchange, conversationMapper: ConversationMapper, diff --git a/app/src/main/java/com/getcode/manager/AuthManager.kt b/app/src/main/java/com/getcode/manager/AuthManager.kt index 65ac31228..bdbec8c76 100644 --- a/app/src/main/java/com/getcode/manager/AuthManager.kt +++ b/app/src/main/java/com/getcode/manager/AuthManager.kt @@ -1,7 +1,6 @@ package com.getcode.manager import android.annotation.SuppressLint -import android.app.Activity import android.content.Context import com.bugsnag.android.Bugsnag import com.getcode.BuildConfig @@ -13,7 +12,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.ChatHistoryController import com.getcode.network.exchange.Exchange import com.getcode.network.repository.BetaFlagsRepository import com.getcode.network.repository.IdentityRepository @@ -56,7 +55,7 @@ class AuthManager @Inject constructor( private val betaFlags: BetaFlagsRepository, private val exchange: Exchange, private val balanceController: BalanceController, - private val historyController: HistoryController, + private val historyController: ChatHistoryController, private val inMemoryDao: InMemoryDao, private val analytics: AnalyticsService, private val mnemonicManager: MnemonicManager, diff --git a/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt b/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt index e275e4be9..2f7959bb9 100644 --- a/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt @@ -4,57 +4,40 @@ import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -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.material.Icon import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.BubbleChart 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed import androidx.compose.ui.zIndex import androidx.paging.compose.collectAsLazyPagingItems -import cafe.adriel.voyager.core.lifecycle.LifecycleEffect import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.hilt.getViewModel -import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest -import coil3.request.error import com.getcode.R import com.getcode.model.ID import com.getcode.model.chat.Reference import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme -import com.getcode.ui.components.SheetTitleDefaults -import com.getcode.ui.components.SheetTitleText -import com.getcode.ui.components.chat.AnonymousAvatar import com.getcode.ui.components.chat.UserAvatar import com.getcode.ui.components.chat.utils.localized -import com.getcode.ui.utils.getActivityScopedViewModel import com.getcode.util.formatDateRelatively -import com.getcode.view.main.balance.BalanceScreen -import com.getcode.view.main.balance.BalanceSheetViewModel import com.getcode.view.main.chat.ChatScreen import com.getcode.view.main.chat.ChatViewModel import com.getcode.view.main.chat.conversation.ChatConversationScreen import com.getcode.view.main.chat.conversation.ConversationViewModel -import com.getcode.view.main.home.HomeViewModel +import com.getcode.view.main.chat.list.ChatListScreen +import com.getcode.view.main.chat.list.ChatListViewModel import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -63,92 +46,23 @@ import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize -data object BalanceModal : ChatGraph, ModalRoot { +data object ChatListModal: ChatGraph, ModalRoot { + @IgnoredOnParcel override val key: ScreenKey = uniqueScreenKey - override val name: String - @Composable get() = stringResource(id = R.string.title_balance) + @Composable get() = stringResource(id = R.string.title_chat) @Composable override fun Content() { - val navigator = LocalCodeNavigator.current - - val viewModel = getActivityScopedViewModel() - val state by viewModel.stateFlow.collectAsState() - val isViewingBuckets by remember(state.isBucketDebuggerVisible) { - derivedStateOf { state.isBucketDebuggerVisible } - } - - val backButton = @Composable { - when { - isViewingBuckets -> SheetTitleDefaults.BackButton() - !isViewingBuckets && state.isBucketDebuggerEnabled -> { - Icon( - imageVector = Icons.Rounded.BubbleChart, - contentDescription = "", - tint = Color.White, - ) - } - - else -> Unit - } - } - ModalContainer( - navigator = navigator, - onLogoClicked = {}, - backButton = backButton, - backButtonEnabled = { isViewingBuckets || state.isBucketDebuggerEnabled }, - onBackClicked = when { - isViewingBuckets -> { - { - viewModel.dispatchEvent( - BalanceSheetViewModel.Event.OnDebugBucketsVisible(false) - ) - } - } - - state.isBucketDebuggerEnabled -> { - { - viewModel.dispatchEvent( - BalanceSheetViewModel.Event.OnDebugBucketsVisible(true) - ) - } - } - - else -> null - }, - closeButtonEnabled = close@{ - if (viewModel.stateFlow.value.isBucketDebuggerVisible) return@close false - if (navigator.isVisible) { - it is BalanceModal - } else { - navigator.progress > 0f - } - }, - onCloseClicked = null, + closeButtonEnabled = { it is ChatListModal }, ) { - BalanceScreen(state = state, dispatch = viewModel::dispatchEvent) + val viewModel = getViewModel() +// val conversations = viewModel.conversations.collectAsLazyPagingItems() + ChatListScreen(dispatch = {}) } - - LifecycleEffect( - onStarted = { - val disposedScreen = navigator.lastItem - if (disposedScreen !is BalanceModal) { - viewModel.dispatchEvent(BalanceSheetViewModel.Event.OnOpened) - } - }, - onDisposed = { - val disposedScreen = navigator.lastItem - if (disposedScreen !is BalanceModal) { - viewModel.dispatchEvent( - BalanceSheetViewModel.Event.OnDebugBucketsVisible(false) - ) - } - } - ) } } @@ -198,7 +112,6 @@ data class ChatMessageConversationScreen( @Composable override fun Content() { val navigator = LocalCodeNavigator.current - val homeViewModel = getViewModel() val vm = getViewModel() val state by vm.stateFlow.collectAsState() 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 e57ad689d..e3e5ab27d 100644 --- a/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt @@ -1,8 +1,17 @@ package com.getcode.navigation.screens +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.BubbleChart 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.graphics.Color import androidx.compose.ui.res.stringResource import androidx.lifecycle.Lifecycle +import cafe.adriel.voyager.core.lifecycle.LifecycleEffect import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.hilt.getViewModel @@ -10,12 +19,15 @@ import com.getcode.R import com.getcode.model.KinAmount import com.getcode.models.DeepLinkRequest import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.ui.components.SheetTitleDefaults import com.getcode.ui.utils.RepeatOnLifecycle import com.getcode.ui.utils.getActivityScopedViewModel import com.getcode.utils.trace import com.getcode.view.download.ShareDownloadScreen import com.getcode.view.main.account.AccountHome import com.getcode.view.main.account.AccountSheetViewModel +import com.getcode.view.main.balance.BalanceScreen +import com.getcode.view.main.balance.BalanceSheetViewModel import com.getcode.view.main.giveKin.GiveKinScreen import com.getcode.view.main.home.HomeScreen import com.getcode.view.main.home.HomeViewModel @@ -192,6 +204,96 @@ data object ShareDownloadLinkModal : MainGraph, ModalRoot { } } +@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.isBucketDebuggerVisible) { + derivedStateOf { state.isBucketDebuggerVisible } + } + + val backButton = @Composable { + when { + isViewingBuckets -> SheetTitleDefaults.BackButton() + !isViewingBuckets && state.isBucketDebuggerEnabled -> { + Icon( + imageVector = Icons.Rounded.BubbleChart, + contentDescription = "", + tint = Color.White, + ) + } + + else -> Unit + } + } + + ModalContainer( + navigator = navigator, + onLogoClicked = {}, + backButton = backButton, + backButtonEnabled = { isViewingBuckets || state.isBucketDebuggerEnabled }, + onBackClicked = when { + isViewingBuckets -> { + { + viewModel.dispatchEvent( + BalanceSheetViewModel.Event.OnDebugBucketsVisible(false) + ) + } + } + + state.isBucketDebuggerEnabled -> { + { + viewModel.dispatchEvent( + BalanceSheetViewModel.Event.OnDebugBucketsVisible(true) + ) + } + } + + else -> null + }, + closeButtonEnabled = close@{ + if (viewModel.stateFlow.value.isBucketDebuggerVisible) return@close false + if (navigator.isVisible) { + it is BalanceModal + } else { + navigator.progress > 0f + } + }, + onCloseClicked = null, + ) { + BalanceScreen(state = state, dispatch = viewModel::dispatchEvent) + } + + LifecycleEffect( + onStarted = { + val disposedScreen = navigator.lastItem + if (disposedScreen !is BalanceModal) { + viewModel.dispatchEvent(BalanceSheetViewModel.Event.OnOpened) + } + }, + onDisposed = { + val disposedScreen = navigator.lastItem + if (disposedScreen !is BalanceModal) { + viewModel.dispatchEvent( + BalanceSheetViewModel.Event.OnDebugBucketsVisible(false) + ) + } + } + ) + } +} + @Composable fun AppScreen.OnScreenResult(block: (T) -> Unit) { diff --git a/app/src/main/java/com/getcode/notifications/CodePushMessagingService.kt b/app/src/main/java/com/getcode/notifications/CodePushMessagingService.kt index 2c8973560..f6c9c7303 100644 --- a/app/src/main/java/com/getcode/notifications/CodePushMessagingService.kt +++ b/app/src/main/java/com/getcode/notifications/CodePushMessagingService.kt @@ -17,7 +17,7 @@ import com.getcode.manager.SessionManager import com.getcode.model.notifications.NotificationType import com.getcode.model.notifications.parse import com.getcode.network.BalanceController -import com.getcode.network.HistoryController +import com.getcode.network.ChatHistoryController import com.getcode.network.TipController import com.getcode.network.repository.AccountRepository import com.getcode.network.repository.PushRepository @@ -73,7 +73,7 @@ class CodePushMessagingService : FirebaseMessagingService(), lateinit var balanceController: BalanceController @Inject - lateinit var historyController: HistoryController + lateinit var historyController: ChatHistoryController @Inject lateinit var tipController: TipController diff --git a/app/src/main/java/com/getcode/ui/components/CodeButton.kt b/app/src/main/java/com/getcode/ui/components/CodeButton.kt index 6bc5c515a..ea1c93525 100644 --- a/app/src/main/java/com/getcode/ui/components/CodeButton.kt +++ b/app/src/main/java/com/getcode/ui/components/CodeButton.kt @@ -38,9 +38,8 @@ enum class ButtonState { @Composable fun CodeButton( - modifier: Modifier = Modifier, - onClick: () -> Unit, text: String, + modifier: Modifier = Modifier, isLoading: Boolean = false, isSuccess: Boolean = false, enabled: Boolean = true, @@ -51,6 +50,7 @@ fun CodeButton( buttonState: ButtonState = ButtonState.Bordered, textColor: Color = Color.Unspecified, shape: Shape = CodeTheme.shapes.small, + onClick: () -> Unit, ) { CodeButton( modifier = modifier, 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 index ee9f79a62..19e7de5ad 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/ChatNode.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/ChatNode.kt @@ -20,14 +20,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest -import coil3.request.error import com.getcode.LocalBetaFlags -import com.getcode.R +import com.getcode.model.Conversation import com.getcode.model.chat.Chat import com.getcode.model.chat.MessageContent import com.getcode.theme.BrandLight @@ -38,7 +32,6 @@ import com.getcode.ui.components.chat.utils.localizedText import com.getcode.ui.utils.rememberedClickable import com.getcode.util.DateUtils import com.getcode.util.formatTimeRelatively -import java.util.UUID object ChatNodeDefaults { val UnreadIndicator: Color = Color(0xFF31BB00) @@ -62,7 +55,7 @@ fun ChatNode( horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), verticalAlignment = Alignment.CenterVertically ) { - if (betaFlags.tipsChatEnabled) { + if (betaFlags.conversationsEnabled) { val imageModifier = Modifier .size(CodeTheme.dimens.staticGrid.x10) .clip(CircleShape) diff --git a/app/src/main/java/com/getcode/ui/components/chat/TipChatActions.kt b/app/src/main/java/com/getcode/ui/components/chat/TipChatActions.kt index d79b196ef..6a1412f07 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/TipChatActions.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/TipChatActions.kt @@ -3,10 +3,6 @@ package com.getcode.ui.components.chat import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -25,7 +21,7 @@ internal fun TipChatActions( showTipActions: Boolean, openMessageChat: () -> Unit ) { - val tipChatsEnabled = LocalBetaFlags.current.tipsChatEnabled + val tipChatsEnabled = LocalBetaFlags.current.conversationsEnabled if (showTipActions) { if (tipChatsEnabled && contents.verb is Verb.ReceivedTip) { diff --git a/app/src/main/java/com/getcode/util/AccountUtils.kt b/app/src/main/java/com/getcode/util/AccountUtils.kt index 7c4c3a079..0d1c2b1d9 100644 --- a/app/src/main/java/com/getcode/util/AccountUtils.kt +++ b/app/src/main/java/com/getcode/util/AccountUtils.kt @@ -9,20 +9,17 @@ import android.os.HandlerThread import androidx.core.os.bundleOf import com.getcode.BuildConfig import com.getcode.utils.TraceType +import com.getcode.utils.network.retryable import com.getcode.utils.trace import io.reactivex.rxjava3.annotations.NonNull import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.subjects.SingleSubject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.datetime.Clock import kotlin.coroutines.resume -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds -import kotlin.time.TimeSource object AccountUtils { @@ -55,7 +52,19 @@ object AccountUtils { val subject = SingleSubject.create>() return subject.doOnSubscribe { CoroutineScope(Dispatchers.IO).launch { - val result = getAccountInternal(context) + val result = retryable( + call = { getAccountNoActivity(context) }, + onRetry = { currentAttempt -> + trace( + tag = "Account", + message = "Retrying call", + metadata = { + "count" to currentAttempt + }, + type = TraceType.Process, + ) + } + ) subject.onSuccess(result ?: (null to null)) } } @@ -70,45 +79,6 @@ object AccountUtils { } } - private suspend fun getAccountInternal( - context: Context, - maxRetries: Int = 3, - delayDuration: Duration = 2.seconds - ): Pair? { - var currentAttempt = 0 - val startTime = TimeSource.Monotonic.markNow() - - while (currentAttempt < maxRetries) { - val result = try { - getAccountNoActivity(context) - } catch (e: Exception) { - trace(message = "Attempt $currentAttempt failed with exception: ${e.message}", error = e, type = TraceType.Error) - null - } - - if (result != null) { - return result - } else { - currentAttempt++ - if (currentAttempt < maxRetries) { - trace( - tag = "Account", - message = "Retrying login", - metadata = { - "count" to currentAttempt - }, - type = TraceType.Process, - ) - trace("Retrying after ${delayDuration.inWholeMilliseconds} ms...", type = TraceType.Log) - delay(delayDuration.inWholeMilliseconds) - } - } - } - - trace("Failed to get account after $maxRetries attempts in ${startTime.elapsedNow().inWholeMilliseconds} ms", type = TraceType.Error) - return null - } - private suspend fun getAccountNoActivity( context: Context ): Pair? = suspendCancellableCoroutine { cont -> @@ -152,6 +122,19 @@ object AccountUtils { } } - return getAccountInternal(context)?.first + return retryable( + call = { getAccountNoActivity(context) }, + onRetry = { currentAttempt -> + trace( + tag = "Account", + message = "Retrying call", + metadata = { + "count" to currentAttempt + }, + type = TraceType.Process, + ) + } + + )?.first } } \ No newline at end of file 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 360665e9b..08933b03c 100644 --- a/app/src/main/java/com/getcode/view/login/LoginHome.kt +++ b/app/src/main/java/com/getcode/view/login/LoginHome.kt @@ -87,7 +87,7 @@ fun LoginHome( ) CodeButton( - Modifier + modifier = Modifier .fillMaxWidth() .constrainAs(buttonCreate) { top.linkTo(logo.bottom) //possibly remove!! @@ -99,7 +99,7 @@ fun LoginHome( buttonState = ButtonState.Filled, ) CodeButton( - Modifier + modifier = Modifier .fillMaxWidth() .constrainAs(buttonLogin) { top.linkTo(buttonCreate.bottom) 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 544a079de..2e52c383c 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 @@ -96,16 +96,16 @@ fun BetaFlagsScreen( state.tipCardOnHomeScreen, ), BetaFeature( - PrefsBool.TIPS_CHAT_ENABLED, - R.string.beta_tipchats, - stringResource(id = R.string.beta_tipchats_description), - state.tipsChatEnabled, + PrefsBool.CONVERSATIONS_ENABLED, + R.string.beta_conversations, + stringResource(id = R.string.beta_conversations_description), + state.conversationsEnabled, ), BetaFeature( - PrefsBool.TIPS_CHAT_CASH_ENABLED, - R.string.beta_tipchats_cash, - stringResource(id = R.string.beta_tipchats_cash_description), - state.tipsChatCashEnabled, + PrefsBool.CONVERSATION_CASH_ENABLED, + R.string.beta_conversations_cash, + stringResource(id = R.string.beta_conversations_cash_description), + state.conversationCashEnabled, ), BetaFeature( PrefsBool.KADO_WEBVIEW_ENABLED, @@ -161,7 +161,7 @@ private fun BetaOptions.canMutate(flag: PrefsBool): Boolean { PrefsBool.BUY_MODULE_ENABLED -> false PrefsBool.BALANCE_CURRENCY_SELECTION_ENABLED -> false PrefsBool.TIPS_ENABLED -> false - PrefsBool.TIPS_CHAT_CASH_ENABLED -> tipsChatEnabled + PrefsBool.CONVERSATION_CASH_ENABLED -> conversationsEnabled else -> true } } diff --git a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummaryViewModel.kt b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummaryViewModel.kt index 13ad7d0be..ba0f21248 100644 --- a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummaryViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawSummaryViewModel.kt @@ -15,7 +15,7 @@ import com.getcode.model.Rate import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.screens.HomeScreen import com.getcode.navigation.screens.WithdrawalArgs -import com.getcode.network.HistoryController +import com.getcode.network.ChatHistoryController import com.getcode.network.client.* import com.getcode.util.resources.ResourceHelper import com.getcode.utils.ErrorUtils @@ -44,7 +44,7 @@ data class AccountWithdrawSummaryUiModel( class AccountWithdrawSummaryViewModel @Inject constructor( private val analytics: AnalyticsService, private val client: Client, - private val historyController: HistoryController, + private val historyController: ChatHistoryController, private val resources: ResourceHelper, ) : BaseViewModel(resources) { val uiFlow = MutableStateFlow(AccountWithdrawSummaryUiModel()) 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 20eafc7a2..03f43098f 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 @@ -9,8 +9,7 @@ import com.getcode.model.Feature import com.getcode.model.PrefsBool import com.getcode.model.Rate import com.getcode.network.BalanceController -import com.getcode.network.HistoryController -import com.getcode.network.repository.BetaFlagsRepository +import com.getcode.network.ChatHistoryController import com.getcode.network.repository.FeatureRepository import com.getcode.network.repository.PrefRepository import com.getcode.util.Kin @@ -31,7 +30,7 @@ import javax.inject.Inject @HiltViewModel class BalanceSheetViewModel @Inject constructor( balanceController: BalanceController, - historyController: HistoryController, + historyController: ChatHistoryController, prefsRepository: PrefRepository, features: FeatureRepository, networkObserver: NetworkConnectivityListener, @@ -103,7 +102,7 @@ class BalanceSheetViewModel @Inject constructor( } .launchIn(viewModelScope) - historyController.chats + historyController.notifications .onEach { if (it == null || (it.isEmpty() && !networkObserver.isConnected)) { dispatchEvent(Dispatchers.Main, Event.OnChatsLoading(true)) @@ -126,7 +125,7 @@ class BalanceSheetViewModel @Inject constructor( eventFlow .filterIsInstance() - .filter { features.isEnabled(PrefsBool.TIPS_CHAT_ENABLED) } + .filter { features.isEnabled(PrefsBool.CONVERSATIONS_ENABLED) } .onEach { historyController.fetchChats(true) } .launchIn(viewModelScope) } 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 index 673101e2f..1630fbd94 100644 --- a/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/chat/ChatViewModel.kt @@ -12,7 +12,7 @@ import com.getcode.model.chat.Reference import com.getcode.model.chat.Title import com.getcode.model.chat.Verb import com.getcode.network.ConversationController -import com.getcode.network.HistoryController +import com.getcode.network.ChatHistoryController import com.getcode.network.repository.BetaFlagsRepository import com.getcode.network.repository.base58 import com.getcode.ui.components.chat.utils.ChatItem @@ -40,7 +40,7 @@ import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class ChatViewModel @Inject constructor( - historyController: HistoryController, + historyController: ChatHistoryController, conversationController: ConversationController, betaFlags: BetaFlagsRepository, ) : BaseViewModel2( @@ -87,7 +87,7 @@ class ChatViewModel @Inject constructor( .onEach { Timber.d("chatid=${it?.base58}") } .filterNotNull() .onEach { historyController.advanceReadPointer(it) } - .flatMapLatest { historyController.chats } + .flatMapLatest { historyController.notifications } .flowOn(Dispatchers.IO) .filterNotNull() .mapNotNull { chats -> chats.firstOrNull { it.id == stateFlow.value.chatId } } diff --git a/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt index a7b67569e..f96c74a29 100644 --- a/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt @@ -12,12 +12,11 @@ import androidx.paging.map import com.getcode.BuildConfig import com.getcode.R import com.getcode.manager.BottomBarManager -import com.getcode.manager.TopBarManager import com.getcode.model.ConversationWithLastPointers import com.getcode.model.Feature import com.getcode.model.ID import com.getcode.model.MessageStatus -import com.getcode.model.TipChatCashFeature +import com.getcode.model.ConversationCashFeature import com.getcode.model.TwitterUser import com.getcode.model.chat.ChatType import com.getcode.model.chat.Platform @@ -35,16 +34,13 @@ import com.getcode.utils.timestamp import com.getcode.view.BaseViewModel2 import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -91,7 +87,7 @@ class ConversationViewModel @Inject constructor( val Default = State( conversationId = null, reference = null, - tipChatCash = TipChatCashFeature(), + tipChatCash = ConversationCashFeature(), title = "Anonymous Tipper", textFieldState = TextFieldState(), identityAvailable = false, @@ -186,7 +182,7 @@ class ConversationViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnConversationChanged(it)) } .launchIn(viewModelScope) - features.tipChatCash + features.conversationsCash .onEach { dispatchEvent(Event.OnTipsChatCashChanged(it)) } .launchIn(viewModelScope) diff --git a/app/src/main/java/com/getcode/view/main/chat/list/ChatListScreen.kt b/app/src/main/java/com/getcode/view/main/chat/list/ChatListScreen.kt new file mode 100644 index 000000000..01c2210bb --- /dev/null +++ b/app/src/main/java/com/getcode/view/main/chat/list/ChatListScreen.kt @@ -0,0 +1,39 @@ +package com.getcode.view.main.chat.list + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.ButtonState +import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.CodeScaffold + +@Composable +fun ChatListScreen( + dispatch: (ChatListViewModel.Event) -> Unit, +) { + CodeScaffold( + bottomBar = { + Box(modifier = Modifier.fillMaxWidth()) { + CodeButton( + modifier = Modifier.fillMaxWidth().padding(horizontal = CodeTheme.dimens.inset), + buttonState = ButtonState.Filled, + text = "Start a New Chat" + ) { + + } + } + } + ) { padding -> + LazyColumn(modifier = Modifier.padding(padding)) { +// items(conversations.itemCount) { index -> +// conversations[index]?.let { chat -> +// ChatNode(chat = chat) { } +// } +// } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/chat/list/ChatListViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/list/ChatListViewModel.kt new file mode 100644 index 000000000..7be3733e6 --- /dev/null +++ b/app/src/main/java/com/getcode/view/main/chat/list/ChatListViewModel.kt @@ -0,0 +1,44 @@ +package com.getcode.view.main.chat.list + +import androidx.paging.PagingData +import androidx.paging.map +import com.getcode.model.Conversation +import com.getcode.network.ConversationController +import com.getcode.network.TipController +import com.getcode.view.BaseViewModel2 +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +class ChatListViewModel @Inject constructor( + conversationController: ConversationController, +): BaseViewModel2( + initialState = State(), + updateStateForEvent = updateStateForEvent +) { + data class State( + val x: String = "" + ) + + sealed interface Event { + data object Noop: Event + } + + + companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + Event.Noop -> { state -> state } + } + } + } +} + +data class ConversationWithMetadata( + val conversation: Conversation, + val image: String?, + val latestMessage: String?, + val latestMessageMillis: Long? +) \ No newline at end of file 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 ba6a17283..977e07c67 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 @@ -48,6 +48,7 @@ import com.getcode.navigation.screens.AccountModal import com.getcode.navigation.screens.BalanceModal import com.getcode.navigation.screens.BuyMoreKinModal import com.getcode.navigation.screens.BuySellScreen +import com.getcode.navigation.screens.ChatListModal import com.getcode.navigation.screens.ConnectAccount import com.getcode.navigation.screens.EnterTipModal import com.getcode.navigation.screens.GetKinModal @@ -83,7 +84,8 @@ enum class HomeAction { GET_KIN, BALANCE, SHARE_DOWNLOAD, - TIP_CARD + TIP_CARD, + CHAT } @Composable @@ -201,6 +203,7 @@ private fun HomeScan( } } HomeAction.NONE -> Unit + HomeAction.CHAT -> navigator.show(ChatListModal) } } } 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 c629b9906..e38d03990 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 @@ -31,8 +31,6 @@ import com.getcode.model.Kind import com.getcode.model.PrefsBool import com.getcode.model.Rate import com.getcode.model.RequestKinFeature -import com.getcode.model.TipCardFeature -import com.getcode.model.TipCardOnHomeScreenFeature import com.getcode.model.TwitterUser import com.getcode.model.Username import com.getcode.models.Bill @@ -46,7 +44,7 @@ import com.getcode.models.PaymentValuation import com.getcode.models.TipConfirmation import com.getcode.models.amountFloored import com.getcode.network.BalanceController -import com.getcode.network.HistoryController +import com.getcode.network.ChatHistoryController import com.getcode.network.TipController import com.getcode.network.client.Client import com.getcode.network.client.RemoteSendException @@ -64,6 +62,7 @@ import com.getcode.network.client.sendRequestToReceiveBill import com.getcode.network.exchange.Exchange import com.getcode.network.repository.AppSettingsRepository import com.getcode.network.repository.BetaFlagsRepository +import com.getcode.network.repository.BetaOptions import com.getcode.network.repository.FeatureRepository import com.getcode.network.repository.PaymentRepository import com.getcode.network.repository.PrefRepository @@ -82,7 +81,6 @@ import com.getcode.util.showNetworkError import com.getcode.util.vibration.Vibrator import com.getcode.utils.ErrorUtils import com.getcode.utils.TraceType -import com.getcode.utils.base64EncodedData import com.getcode.utils.catchSafely import com.getcode.utils.network.NetworkConnectivityListener import com.getcode.utils.nonce @@ -149,7 +147,6 @@ data class HomeUiModel( val chatUnreadCount: Int = 0, val buyModule: Feature = BuyModuleFeature(), val requestKin: Feature = RequestKinFeature(), - val tips: Feature = TipCardFeature(), val actions: List = listOf(HomeAction.GIVE_KIN, HomeAction.TIP_CARD, HomeAction.BALANCE), val tipCardConnected: Boolean = false, ) @@ -172,7 +169,7 @@ class HomeViewModel @Inject constructor( private val receiveTransactionRepository: ReceiveTransactionRepository, private val paymentRepository: PaymentRepository, private val balanceController: BalanceController, - private val historyController: HistoryController, + private val historyController: ChatHistoryController, private val tipController: TipController, private val prefRepository: PrefRepository, private val analytics: AnalyticsService, @@ -186,6 +183,7 @@ class HomeViewModel @Inject constructor( private val mnemonicManager: MnemonicManager, private val cashLinkManager: CashLinkManager, appSettings: AppSettingsRepository, + betaFlagsRepository: BetaFlagsRepository, features: FeatureRepository, ) : BaseViewModel(resources), ScreenModel { val uiFlow = MutableStateFlow(HomeUiModel()) @@ -208,26 +206,27 @@ class HomeViewModel @Inject constructor( }.launchIn(viewModelScope) features.buyModule + .distinctUntilChanged() .onEach { module -> uiFlow.update { it.copy(buyModule = module) } }.launchIn(viewModelScope) - features.tipCardOnHomeScreen - .onEach { module -> - uiFlow.update { - it.copy(actions = buildActions(module.enabled)) - } - }.launchIn(viewModelScope) - features.requestKin + .distinctUntilChanged() .onEach { module -> uiFlow.update { it.copy(requestKin = module) } }.launchIn(viewModelScope) + betaFlagsRepository.observe() + .distinctUntilChanged() + .onEach { betaFlags -> + uiFlow.update { it.copy(actions = buildActions(betaFlags)) } + }.launchIn(viewModelScope) + tipController.showTwitterSplat .filter { it } .onEach { delay(500) } @@ -375,17 +374,22 @@ class HomeViewModel @Inject constructor( } private fun buildActions( - tipCardOnHomeScreen: Boolean, + betaOptions: BetaOptions, ): List { - return listOf( - HomeAction.GIVE_KIN, - if (tipCardOnHomeScreen) { - HomeAction.TIP_CARD - } else { - HomeAction.GET_KIN - }, - HomeAction.BALANCE - ) + val actions = mutableListOf(HomeAction.GIVE_KIN) + actions += if (betaOptions.tipCardOnHomeScreen) { + HomeAction.TIP_CARD + } else { + HomeAction.GET_KIN + } + + if (betaOptions.conversationsEnabled) { + actions += HomeAction.CHAT + } + + actions += HomeAction.BALANCE + + return actions } fun onCameraScanning(scanning: Boolean) { 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 4f2b92032..25e7645f2 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 @@ -8,7 +8,6 @@ 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.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -16,7 +15,9 @@ 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.alpha import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.layoutId import androidx.compose.ui.res.painterResource @@ -33,6 +34,7 @@ import com.getcode.ui.components.chat.ChatNodeDefaults import com.getcode.ui.utils.heightOrZero import com.getcode.ui.utils.unboundedClickable import com.getcode.ui.utils.widthOrZero +import com.getcode.util.resources.icons.AutoMirroredMessageCircle import com.getcode.view.main.home.HomeAction import com.getcode.view.main.home.HomeUiModel @@ -60,6 +62,7 @@ internal fun HomeBottom( onClick = { onPress(action) } ) } + HomeAction.GET_KIN -> { BottomBarAction( modifier = Modifier.weight(1f), @@ -68,6 +71,7 @@ internal fun HomeBottom( onClick = { onPress(action) }, ) } + HomeAction.BALANCE -> { BottomBarAction( modifier = Modifier.weight(1f), @@ -89,6 +93,7 @@ internal fun HomeBottom( } ) } + HomeAction.TIP_CARD -> { BottomBarAction( modifier = Modifier.weight(1f), @@ -97,7 +102,24 @@ internal fun HomeBottom( onClick = { onPress(action) }, ) } - else -> Unit + + HomeAction.CHAT -> { + BottomBarAction( + modifier = Modifier.weight(1f), + label = stringResource(R.string.action_chat), + painter = rememberVectorPainter(AutoMirroredMessageCircle), + onClick = { onPress(action) }, + ) + } + + else -> { + BottomBarAction( + modifier = Modifier.weight(1f).alpha(0f), + label = "", + painter = painterResource(R.drawable.ic_tip_card), + onClick = null, + ) + } } } } @@ -113,14 +135,14 @@ private fun BottomBarAction( painter: Painter, imageSize: Dp = CodeTheme.dimens.staticGrid.x10, badge: @Composable () -> Unit = { }, - onClick: () -> Unit, + onClick: (() -> Unit)?, ) { Layout( modifier = modifier, content = { Column( modifier = Modifier - .unboundedClickable(rippleRadius = imageSize) { onClick() } + .unboundedClickable(enabled = onClick != null, rippleRadius = imageSize) { onClick?.invoke() } .layoutId("action"), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -149,7 +171,7 @@ private fun BottomBarAction( measurables.find { it.layoutId == "badge" }?.measure(constraints) val maxWidth = widthOrZero(actionPlaceable) - val maxHeight = heightOrZero(actionPlaceable) // + heightOrZero(badgePlaceable) / 2 + val maxHeight = heightOrZero(actionPlaceable) layout( width = maxWidth, height = maxHeight, @@ -161,4 +183,5 @@ private fun BottomBarAction( ) } } -} \ No newline at end of file +} + diff --git a/app/src/main/res/drawable/ic_chat.xml b/app/src/main/res/drawable/ic_chat.xml new file mode 100644 index 000000000..d1d8828da --- /dev/null +++ b/app/src/main/res/drawable/ic_chat.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings-universal.xml b/app/src/main/res/values/strings-universal.xml index 328c3d16c..9cbb2942c 100644 --- a/app/src/main/res/values/strings-universal.xml +++ b/app/src/main/res/values/strings-universal.xml @@ -21,8 +21,8 @@ Show Connectivity Status Tip Card Tip Card on Home Screen - Tip Chats - Tip Chats Cash + Chats + Cash Transfers in Chat Currency Selection in Balance Buy Kin Internally Share Tweets to Tip @@ -37,8 +37,8 @@ If enabled, an option to unsubscribe from a chat will appear for supported chats. If enabled, you\'ll gain the ability to share a tip card. If enabled, your tip card will replace Get Cash on the home screen. - If enabled, you\'ll gain the ability to chat with tippers. - If enabled, you\'ll gain the ability to send Kin in Tip Chats. + If enabled, you\'ll gain the ability to chat with with other code users. + If enabled, you\'ll gain the ability to send cash in conversations. If enabled, the Buy Kin flow will open in an internal WebView. If enabled, you\'ll gain the ability to share tweets directly from Twitter to Code to tip the author. %1$s %2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a52bd9cbd..192f7066e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,4 +49,7 @@ Connect Account Connecting your X account allows you to reveal your identity to the people you tip. To connect your account post to X. Use Kado Sandbox + + Chat + Chat diff --git a/common/resources/build.gradle.kts b/common/resources/build.gradle.kts index 8374f9d0d..e8cac4b91 100644 --- a/common/resources/build.gradle.kts +++ b/common/resources/build.gradle.kts @@ -14,6 +14,14 @@ android { testInstrumentationRunner = Android.testInstrumentationRunner } + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } + kotlinOptions { jvmTarget = Versions.java freeCompilerArgs += listOf( @@ -37,5 +45,7 @@ dependencies { api(Libs.kotlinx_coroutines_core) api(Libs.kotlinx_coroutines_rx3) - api(Libs.kin_sdk) + implementation(platform(Libs.compose_bom)) + implementation(Libs.compose_ui) + implementation(Libs.compose_foundation) } diff --git a/common/resources/src/main/java/com/getcode/util/resources/icons/ImageVector.kt b/common/resources/src/main/java/com/getcode/util/resources/icons/ImageVector.kt new file mode 100644 index 000000000..c7aaf8f29 --- /dev/null +++ b/common/resources/src/main/java/com/getcode/util/resources/icons/ImageVector.kt @@ -0,0 +1,77 @@ +package com.getcode.util.resources.icons + +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.VectorGroup +import androidx.compose.ui.graphics.vector.VectorNode +import androidx.compose.ui.graphics.vector.VectorPath +import androidx.compose.ui.graphics.vector.group + +fun ImageVector.Companion.copyFrom( + src: ImageVector, + mirror: Boolean = false, + rotation: Float = src.root.rotation, + pivotX: Float = src.defaultWidth.value / 2, + pivotY: Float = src.defaultHeight.value / 2, +) = ImageVector.Builder( + name = src.name, + defaultWidth = src.defaultWidth, + defaultHeight = src.defaultHeight, + viewportWidth = src.viewportWidth, + viewportHeight = src.viewportHeight, + tintColor = src.tintColor, + tintBlendMode = src.tintBlendMode, + autoMirror = src.autoMirror, +).addGroup( + src = src.root, + rotation = rotation, + pivotX = pivotX, + pivotY = pivotY, + scaleX = if (mirror) -1f else 1f, + scaleY = if (mirror) 1f else 1f, +).build() + +private fun ImageVector.Builder.addNode(node: VectorNode) { + when (node) { + is VectorGroup -> addGroup(node) + is VectorPath -> addPath(node) + } +} + +private fun ImageVector.Builder.addGroup( + src: VectorGroup, + rotation: Float = src.rotation, + pivotX: Float = src.pivotX, + pivotY: Float = src.pivotY, + scaleX: Float = src.scaleX, + scaleY: Float = src.scaleY, +) = apply { + group( + name = src.name, + rotate = rotation, + pivotX = pivotX, + pivotY = pivotY, + scaleX = scaleX, + scaleY = scaleY, + translationX = src.translationX, + translationY = src.translationY, + clipPathData = src.clipPathData, + ) { + src.forEach { addNode(it) } + } +} + +private fun ImageVector.Builder.addPath(src: VectorPath) = apply { + addPath( + pathData = src.pathData, + pathFillType = src.pathFillType, + name = src.name, + fill = src.fill, + fillAlpha = src.fillAlpha, + stroke = src.stroke, + strokeAlpha = src.strokeAlpha, + strokeLineWidth = src.strokeLineWidth, + strokeLineCap = src.strokeLineCap, + strokeLineJoin = src.strokeLineJoin, + strokeLineMiter = src.strokeLineMiter, + ) +} \ No newline at end of file diff --git a/common/resources/src/main/java/com/getcode/util/resources/icons/MessageCircle.kt b/common/resources/src/main/java/com/getcode/util/resources/icons/MessageCircle.kt new file mode 100644 index 000000000..961853221 --- /dev/null +++ b/common/resources/src/main/java/com/getcode/util/resources/icons/MessageCircle.kt @@ -0,0 +1,52 @@ +package com.getcode.util.resources.icons + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +private var _MessageCircle: ImageVector? = null +val MessageCircle: ImageVector + get() { + if (_MessageCircle != null) { + return _MessageCircle!! + } + _MessageCircle = ImageVector.Builder( + name = "MessageCircle", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = null, + fillAlpha = 1.0f, + stroke = SolidColor(Color(0xFFFFFFFF)), + strokeAlpha = 1.0f, + strokeLineWidth = 2f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero + ) { + moveTo(7.9f, 20f) + arcTo(9f, 9f, 0f, isMoreThanHalf = true, isPositiveArc = false, 4f, 16.1f) + lineTo(2f, 22f) + close() + } + }.build() + return _MessageCircle!! + } + +val AutoMirroredMessageCircle: ImageVector + @Composable get() = ImageVector.copyFrom(MessageCircle, mirror = LocalLayoutDirection.current == LayoutDirection.Ltr) + +val MirroredMessageCircle: ImageVector + get() = ImageVector.copyFrom(MessageCircle, mirror = true) \ No newline at end of file