From 2de59a6cdbdb43364199a0fd7d9034cfecbccdd0 Mon Sep 17 00:00:00 2001 From: Lino Veloso Date: Tue, 17 Jun 2025 22:54:49 -0300 Subject: [PATCH 1/2] Integrate Flipper for debugging in debug builds; update OkHttpClient to include Flipper network interceptor --- .idea/deploymentTargetSelector.xml | 11 +++++++--- app/build.gradle.kts | 6 ++++++ .../cryptoexchangesapp/FlipperInitializer.kt | 20 +++++++++++++++++++ .../com/velosobr/cryptoexchangesapp/App.kt | 7 ++++--- data/build.gradle.kts | 3 ++- .../java/com/velosobr/data/di/DataModule.kt | 12 ++++++++++- .../data/icon/ExchangeIconDataStore.kt | 6 ------ .../remote/interceptor/LoggingInterceptor.kt | 2 +- gradle/libs.versions.toml | 8 ++++++-- 9 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 app/src/debug/java/com/velosobr/cryptoexchangesapp/FlipperInitializer.kt diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 07e6c5e..4391427 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,6 +4,14 @@ - - diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d7dcbda..3e214b9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,6 +35,7 @@ android { "proguard-rules.pro" ) buildConfigField("String", "API_KEY", "\"$coinApiKey\"") + signingConfig = signingConfigs.getByName("debug") } debug { @@ -110,6 +111,11 @@ dependencies { //Log implementation(libs.timber) + debugImplementation(libs.flipper) + debugImplementation(libs.flipper.network) + debugImplementation(libs.flipper.leakcanary) + debugImplementation(libs.soloader) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/debug/java/com/velosobr/cryptoexchangesapp/FlipperInitializer.kt b/app/src/debug/java/com/velosobr/cryptoexchangesapp/FlipperInitializer.kt new file mode 100644 index 0000000..ec9a847 --- /dev/null +++ b/app/src/debug/java/com/velosobr/cryptoexchangesapp/FlipperInitializer.kt @@ -0,0 +1,20 @@ +package com.velosobr.cryptoexchangesapp + +import android.content.Context +import com.facebook.flipper.android.AndroidFlipperClient +import com.facebook.flipper.plugins.inspector.DescriptorMapping +import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin +import com.facebook.flipper.plugins.network.NetworkFlipperPlugin +import com.facebook.soloader.SoLoader +import org.koin.core.context.GlobalContext.get + +object FlipperInitializer { + fun init(context: Context) { + SoLoader.init(context, false) + val client = AndroidFlipperClient.getInstance(context) + val networkPlugin = get().get() + client.addPlugin(InspectorFlipperPlugin(context, DescriptorMapping.withDefaults())) + client.addPlugin(networkPlugin) + client.start() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/velosobr/cryptoexchangesapp/App.kt b/app/src/main/java/com/velosobr/cryptoexchangesapp/App.kt index 55bcdf5..ab62c7a 100644 --- a/app/src/main/java/com/velosobr/cryptoexchangesapp/App.kt +++ b/app/src/main/java/com/velosobr/cryptoexchangesapp/App.kt @@ -13,9 +13,6 @@ class App : Application() { override fun onCreate() { super.onCreate() - if (BuildConfig.DEBUG) { - Timber.plant(Timber.DebugTree()) - } startKoin { androidContext(this@App) modules( @@ -27,6 +24,10 @@ class App : Application() { ) ) } + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + FlipperInitializer.init(this) + } } } diff --git a/data/build.gradle.kts b/data/build.gradle.kts index cfbaa2e..b10c1a9 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -53,7 +53,8 @@ dependencies { implementation(libs.koin.core) implementation(libs.androidx.datastore.preferences) implementation(libs.kotlinx.serialization.json) - + debugImplementation(libs.flipper) + debugImplementation(libs.flipper.network) ksp(libs.moshi.codegen) testImplementation(libs.junit) testImplementation(libs.mockk) diff --git a/data/src/main/java/com/velosobr/data/di/DataModule.kt b/data/src/main/java/com/velosobr/data/di/DataModule.kt index 5c7b61b..c735aba 100644 --- a/data/src/main/java/com/velosobr/data/di/DataModule.kt +++ b/data/src/main/java/com/velosobr/data/di/DataModule.kt @@ -1,5 +1,7 @@ package com.velosobr.data.di +import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor +import com.facebook.flipper.plugins.network.NetworkFlipperPlugin import com.velosobr.cryptoexchangesapp.data.BuildConfig import com.velosobr.data.remote.api.ExchangeApiService import com.velosobr.data.remote.interceptor.LoggingInterceptor @@ -10,13 +12,21 @@ import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory val dataModule = module { - factory { + + single { + check(BuildConfig.DEBUG) { "NetworkFlipperPlugin só deve ser injetado no modo DEBUG" } + NetworkFlipperPlugin() + } + + single { OkHttpClient.Builder().apply { if (BuildConfig.DEBUG) { addInterceptor(LoggingInterceptor()) + addNetworkInterceptor(FlipperOkhttpInterceptor(get())) } }.build() } + single { Retrofit.Builder() .baseUrl("https://rest.coinapi.io/") diff --git a/data/src/main/java/com/velosobr/data/icon/ExchangeIconDataStore.kt b/data/src/main/java/com/velosobr/data/icon/ExchangeIconDataStore.kt index 014b69f..df59b6b 100644 --- a/data/src/main/java/com/velosobr/data/icon/ExchangeIconDataStore.kt +++ b/data/src/main/java/com/velosobr/data/icon/ExchangeIconDataStore.kt @@ -26,10 +26,4 @@ class ExchangeIconDataStore(private val context: Context) { .map { prefs -> prefs[iconKey(exchangeId)] } .first() - - suspend fun clearIcon(exchangeId: String) { - context.dataStore.edit { prefs -> - prefs.remove(iconKey(exchangeId)) - } - } } \ No newline at end of file diff --git a/data/src/main/java/com/velosobr/data/remote/interceptor/LoggingInterceptor.kt b/data/src/main/java/com/velosobr/data/remote/interceptor/LoggingInterceptor.kt index b384f6a..69326ff 100644 --- a/data/src/main/java/com/velosobr/data/remote/interceptor/LoggingInterceptor.kt +++ b/data/src/main/java/com/velosobr/data/remote/interceptor/LoggingInterceptor.kt @@ -15,7 +15,7 @@ class LoggingInterceptor : Interceptor { val durationMs = (endTime - startTime) / 1e6 Timber.tag("Network") - .d("→ ${request.method()} ${request.url()} | ← ${response.code()} (${durationMs}ms)") + .d("→ ${request.method} ${request.url} | ← ${response.code} (${durationMs}ms)") return response } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 72d0212..fa5f2e1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,8 @@ datastore = "1.0.0" kotlinx-serialization = "1.6.0" accompanist = "0.34.0" turbine = "1.1.0" +flipper = "0.234.0" +soloader = "0.10.5" [libraries] @@ -76,7 +78,10 @@ moshi = { group = "com.squareup.moshi", name = "moshi", version.ref = "moshi" } moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" } moshi-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } - +flipper = { module = "com.facebook.flipper:flipper", version.ref = "flipper" } +flipper-network = { module = "com.facebook.flipper:flipper-network-plugin", version.ref = "flipper" } +flipper-leakcanary = { module = "com.facebook.flipper:flipper-leakcanary2-plugin", version.ref = "flipper" } +soloader = { module = "com.facebook.soloader:soloader", version.ref = "soloader" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -84,5 +89,4 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" } android-library = { id = "com.android.library", version.ref = "agp" } -kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version = "2.0.21-1.0.28" } \ No newline at end of file From 4b267f2fb5d96c974943d7b7d8337f9282f586a6 Mon Sep 17 00:00:00 2001 From: Lino Veloso Date: Tue, 17 Jun 2025 23:56:20 -0300 Subject: [PATCH 2/2] Add swipe-to-refresh functionality to ExchangeListScreen; update ViewModel to manage refresh state --- .../java/com/velosobr/data/di/DataModule.kt | 2 +- feature/exchange_list/build.gradle.kts | 2 +- .../presentation/ExchangeListScreen.kt | 120 +++++++++++------- .../presentation/ExchangeListViewModel.kt | 12 ++ gradle/libs.versions.toml | 2 +- 5 files changed, 87 insertions(+), 51 deletions(-) diff --git a/data/src/main/java/com/velosobr/data/di/DataModule.kt b/data/src/main/java/com/velosobr/data/di/DataModule.kt index c735aba..641e9bf 100644 --- a/data/src/main/java/com/velosobr/data/di/DataModule.kt +++ b/data/src/main/java/com/velosobr/data/di/DataModule.kt @@ -31,7 +31,7 @@ val dataModule = module { Retrofit.Builder() .baseUrl("https://rest.coinapi.io/") .addConverterFactory(MoshiConverterFactory.create()) - .client(OkHttpClient.Builder().build()) + .client(get()) .build() .create(ExchangeApiService::class.java) } diff --git a/feature/exchange_list/build.gradle.kts b/feature/exchange_list/build.gradle.kts index 7bfd692..d6e53f4 100644 --- a/feature/exchange_list/build.gradle.kts +++ b/feature/exchange_list/build.gradle.kts @@ -65,7 +65,7 @@ dependencies { implementation(libs.koin.core) implementation(libs.koin.android) implementation(libs.koin.androidx.compose) - + implementation(libs.accompanist.swiperefresh) debugImplementation(libs.androidx.ui.tooling) implementation(libs.androidx.lifecycle.viewmodel.ktx) diff --git a/feature/exchange_list/src/main/java/com/velosobr/exchange_list/presentation/ExchangeListScreen.kt b/feature/exchange_list/src/main/java/com/velosobr/exchange_list/presentation/ExchangeListScreen.kt index 9d9b184..ca35b27 100644 --- a/feature/exchange_list/src/main/java/com/velosobr/exchange_list/presentation/ExchangeListScreen.kt +++ b/feature/exchange_list/src/main/java/com/velosobr/exchange_list/presentation/ExchangeListScreen.kt @@ -1,5 +1,6 @@ package com.velosobr.exchange_list.presentation +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -15,6 +16,8 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.velosobr.core.state.UiState import com.velosobr.cryptoexchangesapp.data.BuildConfig import com.velosobr.designsystem.components.DSExchangeCardComponent @@ -34,6 +37,7 @@ fun ExchangeListScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val iconMap by viewModel.exchangeIcons.collectAsStateWithLifecycle() + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() ExchangeListContent( uiState = uiState, @@ -41,7 +45,12 @@ fun ExchangeListScreen( onExchangeClick = onExchangeClick, onRetryClick = { viewModel.fetchExchanges() - }) + }, + isRefreshing = isRefreshing, + onRefresh = { + viewModel.refreshExchanges() + } + ) } @Composable @@ -49,63 +58,78 @@ fun ExchangeListContent( uiState: UiState>, iconMap: Map, onExchangeClick: (String, String) -> Unit, + isRefreshing: Boolean, + onRefresh: () -> Unit, onRetryClick: () -> Unit ) { - Scaffold(topBar = { - Text( - text = "Exchanges List", - style = DSAppTypography.titleLarge, - color = DSColor.DarkText, - modifier = Modifier - .padding(horizontal = DSSpacing.md, vertical = DSSpacing.lg) - ) - }) { paddingValues -> - Box( - modifier = Modifier - .padding(paddingValues) - .background(DSColor.DarkBackground) - .fillMaxSize() + + val swipeRefreshState = rememberSwipeRefreshState(isRefreshing) + + Scaffold( + topBar = { + Text( + text = "Exchanges List", + style = DSAppTypography.titleLarge, + color = DSColor.DarkText, + modifier = Modifier + .padding(horizontal = DSSpacing.md, vertical = DSSpacing.lg) + ) + } + ) { paddingValues -> + SwipeRefresh( + state = swipeRefreshState, + onRefresh = onRefresh, + indicatorPadding = paddingValues, + modifier = Modifier.fillMaxSize() ) { - when (uiState) { - is UiState.Loading -> { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } + Box( + modifier = Modifier + .padding(paddingValues) + .background(DSColor.DarkBackground) + .fillMaxSize() + ) { + when (uiState) { + is UiState.Loading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } - is UiState.Success -> { - val exchanges = uiState.data - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = DSSpacing.xxxl) - ) { - items(items = exchanges, key = { it.exchangeId }) { exchange -> - val iconUrl = iconMap[exchange.exchangeId] ?: BuildConfig.DEFAULT_ICON_URL - val model = exchange.toCardModel() + is UiState.Success -> { + val exchanges = uiState.data + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = DSSpacing.xxxl) + ) { + items(items = exchanges, key = { it.exchangeId }) { exchange -> + val iconUrl = + iconMap[exchange.exchangeId] ?: BuildConfig.DEFAULT_ICON_URL + val model = exchange.toCardModel() - DSExchangeCardComponent( - name = model.name, - id = model.id, - volume = model.volume, - iconUrl = iconUrl, - onClick = { onExchangeClick(model.id, iconUrl) }, - modifier = Modifier - .padding(horizontal = DSSpacing.md) - .animateItem( - fadeInSpec = androidx.compose.animation.core.tween(300), - fadeOutSpec = androidx.compose.animation.core.tween(300), - ) - ) + DSExchangeCardComponent( + name = model.name, + id = model.id, + volume = model.volume, + iconUrl = iconUrl, + onClick = { onExchangeClick(model.id, iconUrl) }, + modifier = Modifier + .padding(horizontal = DSSpacing.md) + .animateItem( + fadeInSpec = tween(300), + fadeOutSpec = tween(300), + ) + ) + } } } - } - is UiState.Error -> { - ErrorBox( - title = "Oops! Something went wrong.", - message = uiState.message, - onRetry = onRetryClick - ) + is UiState.Error -> { + ErrorBox( + title = "Oops! Something went wrong.", + message = uiState.message, + onRetry = onRetryClick + ) + } } } } diff --git a/feature/exchange_list/src/main/java/com/velosobr/exchange_list/presentation/ExchangeListViewModel.kt b/feature/exchange_list/src/main/java/com/velosobr/exchange_list/presentation/ExchangeListViewModel.kt index 6b3f67e..1f2a387 100644 --- a/feature/exchange_list/src/main/java/com/velosobr/exchange_list/presentation/ExchangeListViewModel.kt +++ b/feature/exchange_list/src/main/java/com/velosobr/exchange_list/presentation/ExchangeListViewModel.kt @@ -7,6 +7,7 @@ import com.velosobr.core.state.UiState import com.velosobr.domain.model.Exchange import com.velosobr.domain.usecase.GetExchangeIconUrlUseCase import com.velosobr.domain.usecase.GetExchangesUseCase +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -22,6 +23,8 @@ class ExchangeListViewModel( val uiState: StateFlow>> = _uiState.asStateFlow() private val _exchangeIcons = MutableStateFlow>(emptyMap()) val exchangeIcons: StateFlow> = _exchangeIcons + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing init { fetchExchanges() @@ -51,4 +54,13 @@ class ExchangeListViewModel( } } } + + fun refreshExchanges() { + viewModelScope.launch { + _isRefreshing.value = true + fetchExchanges() + delay(500) + _isRefreshing.value = false + } + } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fa5f2e1..8b3ddeb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,9 +32,9 @@ turbine = "1.1.0" flipper = "0.234.0" soloader = "0.10.5" - [libraries] accompanist-navigation-animation = { module = "com.google.accompanist:accompanist-navigation-animation", version.ref = "accompanist" } +accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" } androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version = "2.6.2" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }