diff --git a/app/src/main/java/com/beradeep/aiyo/di/RepositoryModule.kt b/app/src/main/java/com/beradeep/aiyo/di/RepositoryModule.kt index 9aa417c..3da7c3d 100644 --- a/app/src/main/java/com/beradeep/aiyo/di/RepositoryModule.kt +++ b/app/src/main/java/com/beradeep/aiyo/di/RepositoryModule.kt @@ -4,10 +4,12 @@ import android.content.Context import com.beradeep.aiyo.data.local.db.dao.ConversationDao import com.beradeep.aiyo.data.local.db.dao.MessageDao import com.beradeep.aiyo.data.remote.DataApiClient +import com.beradeep.aiyo.data.repository.AccountRepositoryImpl import com.beradeep.aiyo.data.repository.ApiKeyRepositoryImpl import com.beradeep.aiyo.data.repository.ChatRepositoryImpl import com.beradeep.aiyo.data.repository.ModelRepositoryImpl import com.beradeep.aiyo.data.repository.SettingRepositoryImpl +import com.beradeep.aiyo.domain.repository.AccountRepository import com.beradeep.aiyo.domain.repository.ApiKeyRepository import com.beradeep.aiyo.domain.repository.ChatRepository import com.beradeep.aiyo.domain.repository.ModelRepository @@ -48,4 +50,10 @@ object RepositoryModule { fun provideSettingsRepository( @ApplicationContext context: Context ): SettingRepository = SettingRepositoryImpl(context) + + @Provides + @Singleton + fun provideAccountRepository( + apiKeyRepository: ApiKeyRepository + ): AccountRepository = AccountRepositoryImpl(apiKeyRepository) } diff --git a/app/src/main/java/com/beradeep/aiyo/di/VmModule.kt b/app/src/main/java/com/beradeep/aiyo/di/VmModule.kt index ae3c584..d09c69d 100644 --- a/app/src/main/java/com/beradeep/aiyo/di/VmModule.kt +++ b/app/src/main/java/com/beradeep/aiyo/di/VmModule.kt @@ -1,6 +1,7 @@ package com.beradeep.aiyo.di import com.beradeep.aiyo.domain.ApiClient +import com.beradeep.aiyo.domain.repository.AccountRepository import com.beradeep.aiyo.domain.repository.ApiKeyRepository import com.beradeep.aiyo.domain.repository.ChatRepository import com.beradeep.aiyo.domain.repository.ModelRepository @@ -34,10 +35,12 @@ constructor( apiKeyRepository: ApiKeyRepository, apiClient: ApiClient, modelRepository: ModelRepository, - settingRepository: SettingRepository + settingRepository: SettingRepository, + accountRepository: AccountRepository ) : SettingsViewModel( apiKeyRepository = apiKeyRepository, modelRepository = modelRepository, settingsRepository = settingRepository, + accountRepository = accountRepository, apiClient = apiClient ) diff --git a/data/build.gradle.kts b/data/build.gradle.kts index f41a0e0..0a70bd1 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -51,6 +51,7 @@ dependencies { // Api Client implementation(platform(libs.openai.client.bom)) implementation(libs.openai.client) + implementation(libs.ktor.client.core) runtimeOnly(libs.ktor.client.okhttp) // Room diff --git a/data/src/main/java/com/beradeep/aiyo/data/remote/OpenRouterCreditsResponse.kt b/data/src/main/java/com/beradeep/aiyo/data/remote/OpenRouterCreditsResponse.kt new file mode 100644 index 0000000..c34ef0d --- /dev/null +++ b/data/src/main/java/com/beradeep/aiyo/data/remote/OpenRouterCreditsResponse.kt @@ -0,0 +1,15 @@ +package com.beradeep.aiyo.data.remote + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OpenRouterCreditsResponse( + val data: OpenRouterCreditsDto +) + +@Serializable +data class OpenRouterCreditsDto( + @SerialName("total_credits") val totalCredits: Double = 0.0, + @SerialName("total_usage") val totalUsage: Double = 0.0 +) diff --git a/data/src/main/java/com/beradeep/aiyo/data/repository/AccountRepositoryImpl.kt b/data/src/main/java/com/beradeep/aiyo/data/repository/AccountRepositoryImpl.kt new file mode 100644 index 0000000..98d476c --- /dev/null +++ b/data/src/main/java/com/beradeep/aiyo/data/repository/AccountRepositoryImpl.kt @@ -0,0 +1,37 @@ +package com.beradeep.aiyo.data.repository + +import com.beradeep.aiyo.data.remote.DataApiClient +import com.beradeep.aiyo.data.remote.OpenRouterCreditsResponse +import com.beradeep.aiyo.domain.model.Credits +import com.beradeep.aiyo.domain.repository.AccountRepository +import com.beradeep.aiyo.domain.repository.ApiKeyRepository +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import kotlinx.serialization.json.Json + +class AccountRepositoryImpl( + private val apiKeyRepository: ApiKeyRepository +) : AccountRepository { + private val json = Json { ignoreUnknownKeys = true } + private val httpClient by lazy { HttpClient() } + + override suspend fun getCredits(): Result = runCatching { + val apiKey = apiKeyRepository.getApiKey() + check(!apiKey.isNullOrBlank()) { "API key is not set" } + val body = httpClient.get(CREDITS_URL) { + header(HttpHeaders.Authorization, "Bearer $apiKey") + }.bodyAsText() + val credits = json.decodeFromString(body).data + Credits( + totalCredits = credits.totalCredits, + totalUsage = credits.totalUsage + ) + } + + companion object { + private const val CREDITS_URL = DataApiClient.BASE_URL + "credits" + } +} diff --git a/domain/src/main/java/com/beradeep/aiyo/domain/model/Credits.kt b/domain/src/main/java/com/beradeep/aiyo/domain/model/Credits.kt new file mode 100644 index 0000000..815f2b3 --- /dev/null +++ b/domain/src/main/java/com/beradeep/aiyo/domain/model/Credits.kt @@ -0,0 +1,9 @@ +package com.beradeep.aiyo.domain.model + +data class Credits( + val totalCredits: Double, + val totalUsage: Double +) { + val remaining: Double + get() = totalCredits - totalUsage +} diff --git a/domain/src/main/java/com/beradeep/aiyo/domain/repository/AccountRepository.kt b/domain/src/main/java/com/beradeep/aiyo/domain/repository/AccountRepository.kt new file mode 100644 index 0000000..07459ab --- /dev/null +++ b/domain/src/main/java/com/beradeep/aiyo/domain/repository/AccountRepository.kt @@ -0,0 +1,8 @@ +package com.beradeep.aiyo.domain.repository + +import com.beradeep.aiyo.domain.model.Credits + +interface AccountRepository { + + suspend fun getCredits(): Result +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8993f7c..ccf9b4b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,6 +43,7 @@ androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-te androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +ktor-client-core = { module = "io.ktor:ktor-client-core" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp" } lumo-composables = { group = "com.nomanr", name = "composables", version.ref = "lumo-composables" } mikepenz-multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRendererAndroid" } diff --git a/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsScreen.kt b/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsScreen.kt index 7ae6bc2..4c33ac3 100644 --- a/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsScreen.kt +++ b/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.filled.InvertColors import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.ModelTraining import androidx.compose.material.icons.filled.FormatSize +import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.material3.MaterialTheme @@ -43,6 +44,7 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.beradeep.aiyo.domain.model.Credits import com.beradeep.aiyo.domain.model.Model import com.beradeep.aiyo.domain.model.ThemeType import com.beradeep.aiyo.domain.repository.SettingRepository @@ -62,6 +64,7 @@ import com.beradeep.aiyo.ui.basics.components.textfield.OutlinedTextField import com.beradeep.aiyo.ui.basics.components.topbar.TopBar import com.beradeep.aiyo.ui.screens.chat.components.ModelSelectorChip import com.beradeep.aiyo.ui.screens.components.ModelSelectionSheet +import java.util.Locale import kotlin.math.roundToInt @Composable @@ -120,6 +123,14 @@ fun SettingsScreen( viewModel.onUiEvent(SettingsUiEvent.OnSetApiKey(newKey)) } ) + CreditsSetting( + credits = uiState.credits, + isLoadingCredits = uiState.isLoadingCredits, + hasApiKey = !uiState.apiKey.isNullOrBlank(), + onRefresh = { + viewModel.onUiEvent(SettingsUiEvent.OnRefreshCredits) + } + ) } HorizontalDivider( @@ -335,6 +346,54 @@ private fun ApiKeySetting( } } +@Composable +private fun CreditsSetting( + credits: Credits?, + isLoadingCredits: Boolean, + hasApiKey: Boolean, + onRefresh: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Credits", + style = LocalTypography.current.body1 + ) + Text( + text = when { + !hasApiKey -> "Set an API key to see your balance." + isLoadingCredits -> "Loading..." + credits != null -> String.format( + Locale.US, + "$%.2f remaining ($%.2f used of $%.2f)", + credits.remaining, + credits.totalUsage, + credits.totalCredits + ) + else -> "Couldn't load balance. Tap refresh to retry." + }, + color = AiyoTheme.colors.textSecondary, + style = AiyoTheme.typography.body3 + ) + } + if (hasApiKey) { + IconButton( + onClick = onRefresh, + variant = IconButtonVariant.PrimaryGhost + ) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = "Refresh credits" + ) + } + } + } +} + @Composable private fun ModelSelectionSetting( selectedModel: Model, diff --git a/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsUiEvent.kt b/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsUiEvent.kt index 00f0251..7beaf5c 100644 --- a/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsUiEvent.kt +++ b/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsUiEvent.kt @@ -6,6 +6,7 @@ sealed class SettingsUiEvent { data class OnSetApiKey(val apiKey: String) : SettingsUiEvent() data class OnModelSelected(val model: com.beradeep.aiyo.domain.model.Model) : SettingsUiEvent() object OnFetchModels : SettingsUiEvent() + object OnRefreshCredits : SettingsUiEvent() object OnShowModelSelectionSheet : SettingsUiEvent() object OnDismissModelSelectionSheet : SettingsUiEvent() diff --git a/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsUiState.kt b/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsUiState.kt index 0b9aa90..c7775d7 100644 --- a/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsUiState.kt +++ b/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsUiState.kt @@ -1,5 +1,6 @@ package com.beradeep.aiyo.ui.screens.settings +import com.beradeep.aiyo.domain.model.Credits import com.beradeep.aiyo.domain.model.Model import com.beradeep.aiyo.domain.model.ThemeType import com.beradeep.aiyo.domain.repository.SettingRepository @@ -8,6 +9,8 @@ data class SettingsUiState( val apiKey: String?, val models: List, val selectedModel: Model, + val credits: Credits?, + val isLoadingCredits: Boolean, val isFetchingModels: Boolean, val showModelSelectionSheet: Boolean, val themeType: ThemeType, @@ -20,6 +23,8 @@ data class SettingsUiState( apiKey = null, models = listOf(defaultModel), selectedModel = defaultModel, + credits = null, + isLoadingCredits = false, isFetchingModels = false, showModelSelectionSheet = false, themeType = ThemeType.System, diff --git a/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsViewModel.kt b/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsViewModel.kt index 2a20e44..2ebeb9d 100644 --- a/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsViewModel.kt +++ b/ui/src/main/java/com/beradeep/aiyo/ui/screens/settings/SettingsViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.beradeep.aiyo.domain.ApiClient import com.beradeep.aiyo.domain.model.Model import com.beradeep.aiyo.domain.model.ThemeType +import com.beradeep.aiyo.domain.repository.AccountRepository import com.beradeep.aiyo.domain.repository.ApiKeyRepository import com.beradeep.aiyo.domain.repository.ModelRepository import com.beradeep.aiyo.domain.repository.SettingRepository @@ -20,6 +21,7 @@ open class SettingsViewModel( private val apiKeyRepository: ApiKeyRepository, private val modelRepository: ModelRepository, private val settingsRepository: SettingRepository, + private val accountRepository: AccountRepository, private val apiClient: ApiClient ) : ViewModel() { @@ -38,6 +40,7 @@ open class SettingsViewModel( is SettingsUiEvent.OnSetApiKey -> setApiKey(settingsUiEvent.apiKey) is SettingsUiEvent.OnModelSelected -> selectModel(settingsUiEvent.model) SettingsUiEvent.OnFetchModels -> fetchModels() + SettingsUiEvent.OnRefreshCredits -> loadCredits() SettingsUiEvent.OnShowModelSelectionSheet -> showModelSelectionSheet() SettingsUiEvent.OnDismissModelSelectionSheet -> dismissModelSelectionSheet() is SettingsUiEvent.OnUpdateThemeType -> setThemeType(settingsUiEvent.themeType) @@ -52,6 +55,15 @@ open class SettingsViewModel( loadApiKey() loadDefaultModel() fetchModels() + loadCredits() + } + + private fun loadCredits() { + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { it.copy(isLoadingCredits = true) } + val credits = accountRepository.getCredits().getOrNull() + _uiState.update { it.copy(credits = credits, isLoadingCredits = false) } + } } private fun loadThemeType() {