From eef0e03ece582afb24efeb8d353a70db154697fc Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Thu, 26 Feb 2026 15:47:35 +0500 Subject: [PATCH 1/6] feat(profile): Implement login functionality and enhance account UI This commit adds support for navigating to the authentication screen from the profile and significantly improves the profile's account section UI, including support for unauthenticated states. - **feat(profile)**: Added `OnLoginClick` action to `ProfileAction` and updated `ProfileViewModel` and `ProfileRoot` to handle navigation to the authentication screen. - **feat(profile)**: Redesigned `AccountSection` to show a "Sign in to GitHub" prompt with a login button when the user is not logged in. - **feat(profile)**: Enhanced the logged-in profile view with a larger avatar, statistics cards (Repos, Followers, Following), and improved typography for the user's name, username, and bio. - **refactor(profile)**: Updated `UserProfile` domain model to make the `bio` field nullable. - **refactor(navigation)**: Integrated `onNavigateToAuthentication` callback into `AppNavigation` for the `ProfileRoot`. --- .../app/navigation/AppNavigation.kt | 3 + .../profile/domain/model/UserProfile.kt | 2 +- .../profile/presentation/ProfileAction.kt | 1 + .../profile/presentation/ProfileRoot.kt | 5 + .../profile/presentation/ProfileViewModel.kt | 4 + .../components/sections/AccountSection.kt | 207 ++++++++++++++++-- 6 files changed, 208 insertions(+), 14 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt index 483858a50..35791c247 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt @@ -213,6 +213,9 @@ fun AppNavigation( ProfileRoot( onNavigateBack = { navController.navigateUp() + }, + onNavigateToAuthentication = { + navController.navigate(GithubStoreGraph.AuthenticationScreen) } ) } diff --git a/feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/model/UserProfile.kt b/feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/model/UserProfile.kt index e7f3756c2..d40079133 100644 --- a/feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/model/UserProfile.kt +++ b/feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/model/UserProfile.kt @@ -5,7 +5,7 @@ data class UserProfile( val imageUrl: String, val name: String, val username: String, - val bio: String, + val bio: String?, val repositoryCount: Int, val followers: Int, val following: Int, diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt index 6aae32eef..39724e3b2 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt @@ -12,6 +12,7 @@ sealed interface ProfileAction { data object OnLogoutConfirmClick : ProfileAction data object OnLogoutDismiss : ProfileAction data object OnHelpClick : ProfileAction + data object OnLoginClick : ProfileAction data class OnFontThemeSelected(val fontTheme: FontTheme) : ProfileAction data class OnProxyTypeSelected(val type: ProxyType) : ProfileAction data class OnProxyHostChanged(val host: String) : ProfileAction diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt index a378a8f5b..492ecb9a9 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt @@ -49,6 +49,7 @@ import zed.rainxch.profile.presentation.components.sections.settings @Composable fun ProfileRoot( onNavigateBack: () -> Unit, + onNavigateToAuthentication: () -> Unit, viewModel: ProfileViewModel = koinViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -93,6 +94,10 @@ fun ProfileRoot( onNavigateBack() } + ProfileAction.OnLoginClick -> { + onNavigateToAuthentication() + } + else -> { viewModel.onAction(action) } diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt index a0b06064a..2cf8ae9a8 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt @@ -193,6 +193,10 @@ class ProfileViewModel( /* Handed in composable */ } + ProfileAction.OnLoginClick -> { + /* Handed in composable */ + } + is ProfileAction.OnFontThemeSelected -> { viewModelScope.launch { themesRepository.setFontTheme(action.fontTheme) diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt index a1b689a59..9e6dbeba4 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt @@ -1,57 +1,214 @@ package zed.rainxch.profile.presentation.components.sections +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.ui.tooling.preview.Preview import zed.rainxch.core.presentation.components.GitHubStoreImage +import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.core.presentation.theme.GithubStoreTheme +import zed.rainxch.profile.domain.model.UserProfile import zed.rainxch.profile.presentation.ProfileAction import zed.rainxch.profile.presentation.ProfileState +@OptIn(ExperimentalMaterial3ExpressiveApi::class) fun LazyListScope.accountSection( state: ProfileState, onAction: (ProfileAction) -> Unit, ) { item { - Column ( + Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - GitHubStoreImage( - imageModel = { - if (state.userProfile == null) { - Icons.Outlined.AccountCircle - } else { + if (state.userProfile == null) { + Icon( + imageVector = Icons.Filled.AccountCircle, + contentDescription = null, + modifier = Modifier + .size(100.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(20.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } else { + GitHubStoreImage( + imageModel = { state.userProfile.imageUrl - } - }, - modifier = Modifier - .size(100.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - ) + }, + modifier = Modifier + .size(128.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + ) + + Spacer(Modifier.height(8.dp)) + } + + if (state.userProfile?.name != null) { + Text( + text = state.userProfile.name, + style = MaterialTheme.typography.titleLargeEmphasized, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center + ) + + Text( + text = "@${state.userProfile.username}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + state.userProfile.bio?.let { bio -> + Text( + text = bio, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } else { + Spacer(Modifier.height(8.dp)) + + Text( + text = "Sign in to GitHub", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(4.dp)) + + Text( + text = "Unlock the full experience. Manage your apps, sync your preference, and browser faster.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + + if (state.userProfile != null) { + Spacer(Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatCard( + label = "Repos", + value = "24", + modifier = Modifier.weight(1f) + ) + StatCard( + label = "Followers", + value = "1.2K", + modifier = Modifier.weight(1f) + ) + + StatCard( + label = "Following", + value = "56", + modifier = Modifier.weight(1f) + ) + } + } + + if (state.userProfile == null) { + Spacer(Modifier.height(8.dp)) + + GithubStoreButton( + text = "Login", + onClick = { + onAction(ProfileAction.OnLoginClick) + }, + modifier = Modifier + .width(480.dp) + .padding(horizontal = 8.dp) + ) + } } } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun StatCard( + label: String, + value: String, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = MaterialTheme.colorScheme.onSurface + ), + shape = RoundedCornerShape(32.dp), + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.secondary + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = value, + maxLines = 1, + style = MaterialTheme.typography.titleLargeEmphasized, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + + Text( + text = label, + maxLines = 1, + style = MaterialTheme.typography.bodyLargeEmphasized, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + @Preview(showBackground = true) @Composable fun AccountSectionPreview() { @@ -63,4 +220,28 @@ fun AccountSectionPreview() { ) } } +} + +@Preview(showBackground = true) +@Composable +fun AccountSectionUserPreview() { + GithubStoreTheme { + LazyColumn { + accountSection( + state = ProfileState( + userProfile = UserProfile( + id = 1, + imageUrl = "", + name = "Octocat", + username = "the_octocat", + bio = " Language Savant. If your repository's language is being reported incorrectly, send us a pull request! ", + repositoryCount = 8, + followers = 21900, + following = 9 + ) + ), + onAction = { } + ) + } + } } \ No newline at end of file From f695f76e31cad618c80f1dc1f402769bb69f853a Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Thu, 26 Feb 2026 16:10:20 +0500 Subject: [PATCH 2/6] feat(profile): Add navigation to Starred and Favourite repositories This commit introduces the ability to navigate to Starred and Favourite repositories from the profile screen, including new UI components and navigation logic. - **feat(profile)**: Added `OnStarredReposClick` and `OnFavouriteReposClick` actions to `ProfileAction` and handled them in `ProfileViewModel` and `ProfileRoot`. - **feat(profile)**: Created `Options.kt` section containing cards for "Stars" (GitHub starred repos) and "Favourites" (locally saved repos). - **feat(profile)**: Integrated the new `options` section into `ProfileSection.kt`. - **feat(navigation)**: Updated `AppNavigation.kt` to handle navigation to `StarredReposScreen` and `FavouritesScreen`. - **style(profile)**: Adjusted spacing and layout constants in `ProfileRoot` for better visual consistency. --- .../app/navigation/AppNavigation.kt | 6 + .../profile/presentation/ProfileAction.kt | 2 + .../profile/presentation/ProfileRoot.kt | 12 +- .../profile/presentation/ProfileViewModel.kt | 8 ++ .../components/sections/Options.kt | 136 ++++++++++++++++++ .../components/sections/ProfileSection.kt | 13 ++ 6 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt index 35791c247..0f0037822 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt @@ -216,6 +216,12 @@ fun AppNavigation( }, onNavigateToAuthentication = { navController.navigate(GithubStoreGraph.AuthenticationScreen) + }, + onNavigateToStarredRepos = { + navController.navigate(GithubStoreGraph.StarredReposScreen) + }, + onNavigateToFavouriteRepos = { + navController.navigate(GithubStoreGraph.FavouritesScreen) } ) } diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt index 39724e3b2..a71c7b439 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt @@ -10,6 +10,8 @@ sealed interface ProfileAction { data class OnDarkThemeChange(val isDarkTheme: Boolean?) : ProfileAction data object OnLogoutClick : ProfileAction data object OnLogoutConfirmClick : ProfileAction + data object OnStarredReposClick : ProfileAction + data object OnFavouriteReposClick : ProfileAction data object OnLogoutDismiss : ProfileAction data object OnHelpClick : ProfileAction data object OnLoginClick : ProfileAction diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt index 492ecb9a9..dd8ee30c5 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt @@ -50,6 +50,8 @@ import zed.rainxch.profile.presentation.components.sections.settings fun ProfileRoot( onNavigateBack: () -> Unit, onNavigateToAuthentication: () -> Unit, + onNavigateToStarredRepos: () -> Unit, + onNavigateToFavouriteRepos: () -> Unit, viewModel: ProfileViewModel = koinViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -98,6 +100,14 @@ fun ProfileRoot( onNavigateToAuthentication() } + ProfileAction.OnFavouriteReposClick -> { + onNavigateToFavouriteRepos() + } + + ProfileAction.OnStarredReposClick -> { + onNavigateToStarredRepos() + } + else -> { viewModel.onAction(action) } @@ -152,7 +162,7 @@ fun ProfileScreen( ) item { - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(16.dp)) } settings( diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt index 2cf8ae9a8..c43383e08 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt @@ -197,6 +197,14 @@ class ProfileViewModel( /* Handed in composable */ } + ProfileAction.OnFavouriteReposClick -> { + /* Handed in composable */ + } + + ProfileAction.OnStarredReposClick -> { + /* Handed in composable */ + } + is ProfileAction.OnFontThemeSelected -> { viewModelScope.launch { themesRepository.setFontTheme(action.fontTheme) diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt new file mode 100644 index 000000000..da2908089 --- /dev/null +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt @@ -0,0 +1,136 @@ +package zed.rainxch.profile.presentation.components.sections + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import zed.rainxch.profile.presentation.ProfileAction + +fun LazyListScope.options( + isUserLoggedIn: Boolean, + onAction: (ProfileAction) -> Unit, +) { + item { + OptionCard( + icon = Icons.Default.Star, + label = "Stars", + description = "Your Starred Repositories from GitHub", + onClick = { + onAction(ProfileAction.OnStarredReposClick) + }, + enabled = isUserLoggedIn + ) + + Spacer(Modifier.height(4.dp)) + + OptionCard( + icon = Icons.Default.Favorite, + label = "Favourites", + description = "Your Favourite Repositories saved locally", + onClick = { + onAction(ProfileAction.OnFavouriteReposClick) + } + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun OptionCard( + icon: ImageVector, + label: String, + description: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + Card( + modifier = modifier, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = MaterialTheme.colorScheme.onSurface, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = .7f), + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .7f), + ), + onClick = onClick, + shape = RoundedCornerShape(36.dp), + border = BorderStroke( + width = .5.dp, + color = MaterialTheme.colorScheme.surface + ), + enabled = enabled + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background( + Brush.linearGradient( + listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.secondary, + ) + ) + ) + .padding(6.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(12.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start + ) { + Text( + text = label, + maxLines = 1, + style = MaterialTheme.typography.titleMedium, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + + Text( + text = description, + maxLines = 2, + style = MaterialTheme.typography.bodyLargeEmphasized, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} \ No newline at end of file diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/ProfileSection.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/ProfileSection.kt index 0cefc41ee..e3c7b3a9f 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/ProfileSection.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/ProfileSection.kt @@ -1,6 +1,10 @@ package zed.rainxch.profile.presentation.components.sections +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import zed.rainxch.profile.presentation.ProfileAction import zed.rainxch.profile.presentation.ProfileState @@ -12,4 +16,13 @@ fun LazyListScope.profile( state = state, onAction = onAction ) + + item { + Spacer(Modifier.height(20.dp)) + } + + options( + isUserLoggedIn = state.isUserLoggedIn, + onAction = onAction + ) } \ No newline at end of file From 3db20f08c06fdb5e866c5592cffb796557a9effd Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Thu, 26 Feb 2026 17:04:10 +0500 Subject: [PATCH 3/6] feat(core): Implement robust caching system with Room and Memory Cache This commit introduces a tiered caching architecture (memory + database) to improve performance and reduce API calls. It adds a `CacheManager` to handle data persistence with TTL support and integrates it into the home repository. - **feat(core/data)**: Added `CacheManager` with dual memory/database storage and TTL management. - **feat(core/data)**: Introduced `CacheDao` and `CacheEntryEntity` using Room for persistent JSON caching. - **feat(home)**: Integrated `CacheManager` into `HomeRepositoryImpl` to cache trending, hot release, and popular repository lists. - **refactor(domain)**: Marked several domain models (`GithubRelease`, `GithubAsset`, `UserProfile`, etc.) as `@Serializable` to support cache serialization. - **chore(db)**: Incremented database version to 4 and added `MIGRATION_3_4` to create the `cache_entries` table. - **chore(build)**: Added `kotlinx-datetime` dependency to the core data module. --- core/data/build.gradle.kts | 2 + .../core/data/local/db/initDatabase.kt | 2 + .../data/local/db/migrations/MIGRATION_3_4.kt | 18 +++ .../rainxch/core/data/cache/CacheManager.kt | 102 +++++++++++++++ .../zed/rainxch/core/data/di/SharedModule.kt | 10 ++ .../rainxch/core/data/local/db/AppDatabase.kt | 8 +- .../core/data/local/db/dao/CacheDao.kt | 34 +++++ .../local/db/entities/CacheEntryEntity.kt | 13 ++ .../rainxch/core/domain/model/GithubAsset.kt | 3 + .../core/domain/model/GithubRelease.kt | 3 + .../core/domain/model/GithubUserProfile.kt | 3 + .../model/PaginatedDiscoveryRepositories.kt | 3 + .../rainxch/details/domain/model/RepoStats.kt | 3 + .../zed/rainxch/home/data/di/SharedModule.kt | 1 + .../data/repository/HomeRepositoryImpl.kt | 122 ++++++++++++------ .../profile/domain/model/UserProfile.kt | 3 + 16 files changed, 285 insertions(+), 45 deletions(-) create mode 100644 core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_3_4.kt create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/CacheDao.kt create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/CacheEntryEntity.kt diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index b40906efd..3b34df309 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -19,6 +19,8 @@ kotlin { implementation(libs.datastore) implementation(libs.datastore.preferences) + + implementation(libs.kotlinx.datetime) } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt index b18f829ee..40ff48f94 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt @@ -5,6 +5,7 @@ import androidx.room.Room import kotlinx.coroutines.Dispatchers import zed.rainxch.core.data.local.db.migrations.MIGRATION_1_2 import zed.rainxch.core.data.local.db.migrations.MIGRATION_2_3 +import zed.rainxch.core.data.local.db.migrations.MIGRATION_3_4 fun initDatabase(context: Context): AppDatabase { val appContext = context.applicationContext @@ -18,6 +19,7 @@ fun initDatabase(context: Context): AppDatabase { .addMigrations( MIGRATION_1_2, MIGRATION_2_3, + MIGRATION_3_4, ) .build() } \ No newline at end of file diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_3_4.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_3_4.kt new file mode 100644 index 000000000..bc59dbc38 --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_3_4.kt @@ -0,0 +1,18 @@ +package zed.rainxch.core.data.local.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL(""" + CREATE TABLE IF NOT EXISTS cache_entries ( + `key` TEXT NOT NULL, + jsonData TEXT NOT NULL, + cachedAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + PRIMARY KEY(`key`) + ) + """.trimIndent()) + } +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt new file mode 100644 index 000000000..2dfacadc4 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt @@ -0,0 +1,102 @@ +package zed.rainxch.core.data.cache + +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import zed.rainxch.core.data.local.db.dao.CacheDao +import zed.rainxch.core.data.local.db.entities.CacheEntryEntity +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours + +object CacheTtl { + val HOME_REPOS = 3.hours.inWholeMilliseconds + val REPO_DETAILS = 6.hours.inWholeMilliseconds + val RELEASES = 6.hours.inWholeMilliseconds + val README = 12.hours.inWholeMilliseconds + val USER_PROFILE = 6.hours.inWholeMilliseconds + val SEARCH_RESULTS = 1.hours.inWholeMilliseconds + val REPO_STATS = 6.hours.inWholeMilliseconds +} + +class CacheManager( + val cacheDao: CacheDao +) { + val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + } + + val memoryCache = HashMap>() + + fun now(): Long = Clock.System.now().toEpochMilliseconds() + + suspend inline fun get(key: String): T? { + val currentTime = now() + + memoryCache[key]?.let { (expiresAt, jsonData) -> + if (expiresAt > currentTime) { + return try { + json.decodeFromString(serializer(), jsonData) + } catch (_: Exception) { + memoryCache.remove(key) + null + } + } else { + memoryCache.remove(key) + } + } + + val entry = cacheDao.getValid(key, currentTime) ?: return null + memoryCache[key] = entry.expiresAt to entry.jsonData + + return try { + json.decodeFromString(serializer(), entry.jsonData) + } catch (_: Exception) { + cacheDao.delete(key) + memoryCache.remove(key) + null + } + } + + suspend inline fun getStale(key: String): T? { + val entry = cacheDao.getAny(key) ?: return null + return try { + json.decodeFromString(serializer(), entry.jsonData) + } catch (_: Exception) { + null + } + } + + suspend inline fun put(key: String, value: T, ttlMillis: Long) { + val currentTime = now() + val jsonData = json.encodeToString(serializer(), value) + val expiresAt = currentTime + ttlMillis + + memoryCache[key] = expiresAt to jsonData + + cacheDao.put( + CacheEntryEntity( + key = key, + jsonData = jsonData, + cachedAt = currentTime, + expiresAt = expiresAt + ) + ) + } + + suspend fun invalidate(key: String) { + memoryCache.remove(key) + cacheDao.delete(key) + } + + suspend fun invalidateByPrefix(prefix: String) { + memoryCache.keys.removeAll { it.startsWith(prefix) } + cacheDao.deleteByPrefix(prefix) + } + + suspend fun cleanupExpired() { + val currentTime = now() + memoryCache.entries.removeAll { it.value.first <= currentTime } + cacheDao.deleteExpired(currentTime) + } +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index 40f778eb1..a97408e71 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -8,9 +8,11 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.koin.dsl.module +import zed.rainxch.core.data.cache.CacheManager import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.data_source.impl.DefaultTokenStore import zed.rainxch.core.data.local.db.AppDatabase +import zed.rainxch.core.data.local.db.dao.CacheDao import zed.rainxch.core.data.local.db.dao.FavoriteRepoDao import zed.rainxch.core.data.local.db.dao.InstalledAppDao import zed.rainxch.core.data.local.db.dao.StarredRepoDao @@ -104,6 +106,10 @@ val coreModule = module { logger = get() ) } + + single { + CacheManager(cacheDao = get()) + } } val networkModule = module { @@ -175,4 +181,8 @@ val databaseModule = module { single { get().updateHistoryDao } + + single { + get().cacheDao + } } \ No newline at end of file diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt index 1928ab19d..961d6514b 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt @@ -2,10 +2,12 @@ package zed.rainxch.core.data.local.db import androidx.room.Database import androidx.room.RoomDatabase +import zed.rainxch.core.data.local.db.dao.CacheDao import zed.rainxch.core.data.local.db.dao.FavoriteRepoDao import zed.rainxch.core.data.local.db.dao.InstalledAppDao import zed.rainxch.core.data.local.db.dao.StarredRepoDao import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao +import zed.rainxch.core.data.local.db.entities.CacheEntryEntity import zed.rainxch.core.data.local.db.entities.FavoriteRepoEntity import zed.rainxch.core.data.local.db.entities.InstalledAppEntity import zed.rainxch.core.data.local.db.entities.StarredRepositoryEntity @@ -17,8 +19,9 @@ import zed.rainxch.core.data.local.db.entities.UpdateHistoryEntity FavoriteRepoEntity::class, UpdateHistoryEntity::class, StarredRepositoryEntity::class, + CacheEntryEntity::class, ], - version = 3, + version = 4, exportSchema = true ) abstract class AppDatabase : RoomDatabase() { @@ -26,4 +29,5 @@ abstract class AppDatabase : RoomDatabase() { abstract val favoriteRepoDao: FavoriteRepoDao abstract val updateHistoryDao: UpdateHistoryDao abstract val starredReposDao: StarredRepoDao -} \ No newline at end of file + abstract val cacheDao: CacheDao +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/CacheDao.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/CacheDao.kt new file mode 100644 index 000000000..161f4586e --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/CacheDao.kt @@ -0,0 +1,34 @@ +package zed.rainxch.core.data.local.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import zed.rainxch.core.data.local.db.entities.CacheEntryEntity + +@Dao +interface CacheDao { + @Query("SELECT * FROM cache_entries WHERE `key` = :key AND expiresAt > :now LIMIT 1") + suspend fun getValid(key: String, now: Long): CacheEntryEntity? + + @Query("SELECT * FROM cache_entries WHERE `key` = :key LIMIT 1") + suspend fun getAny(key: String): CacheEntryEntity? + + @Query("SELECT * FROM cache_entries WHERE `key` LIKE :prefix || '%' AND expiresAt > :now") + suspend fun getValidByPrefix(prefix: String, now: Long): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun put(entry: CacheEntryEntity) + + @Query("DELETE FROM cache_entries WHERE `key` = :key") + suspend fun delete(key: String) + + @Query("DELETE FROM cache_entries WHERE `key` LIKE :prefix || '%'") + suspend fun deleteByPrefix(prefix: String) + + @Query("DELETE FROM cache_entries WHERE expiresAt <= :now") + suspend fun deleteExpired(now: Long) + + @Query("DELETE FROM cache_entries") + suspend fun deleteAll() +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/CacheEntryEntity.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/CacheEntryEntity.kt new file mode 100644 index 000000000..e3036aec3 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/CacheEntryEntity.kt @@ -0,0 +1,13 @@ +package zed.rainxch.core.data.local.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "cache_entries") +data class CacheEntryEntity( + @PrimaryKey + val key: String, + val jsonData: String, + val cachedAt: Long, + val expiresAt: Long +) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubAsset.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubAsset.kt index a5e32c877..08a09e53e 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubAsset.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubAsset.kt @@ -1,5 +1,8 @@ package zed.rainxch.core.domain.model +import kotlinx.serialization.Serializable + +@Serializable data class GithubAsset( val id: Long, val name: String, diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubRelease.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubRelease.kt index 56c0a2959..d7cacd757 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubRelease.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubRelease.kt @@ -1,5 +1,8 @@ package zed.rainxch.core.domain.model +import kotlinx.serialization.Serializable + +@Serializable data class GithubRelease( val id: Long, val tagName: String, diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubUserProfile.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubUserProfile.kt index 433d02165..da46a58f0 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubUserProfile.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubUserProfile.kt @@ -1,5 +1,8 @@ package zed.rainxch.core.domain.model +import kotlinx.serialization.Serializable + +@Serializable data class GithubUserProfile( val id: Long, val login: String, diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt index 9f4a7bc88..b4f263a1f 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt @@ -1,5 +1,8 @@ package zed.rainxch.core.domain.model +import kotlinx.serialization.Serializable + +@Serializable data class PaginatedDiscoveryRepositories( val repos: List, val hasMore: Boolean, diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/RepoStats.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/RepoStats.kt index e1ddb50f4..5c84f5775 100644 --- a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/RepoStats.kt +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/RepoStats.kt @@ -1,5 +1,8 @@ package zed.rainxch.details.domain.model +import kotlinx.serialization.Serializable + +@Serializable data class RepoStats( val stars: Int, val forks: Int, diff --git a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/di/SharedModule.kt b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/di/SharedModule.kt index cac75212b..8e92b57ca 100644 --- a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/di/SharedModule.kt +++ b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/di/SharedModule.kt @@ -13,6 +13,7 @@ val homeModule = module { httpClient = get(), platform = get(), logger = get(), + cacheManager = get() ) } diff --git a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt index d271b9baf..139a97367 100644 --- a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt +++ b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt @@ -23,6 +23,8 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import zed.rainxch.core.data.cache.CacheManager +import zed.rainxch.core.data.cache.CacheTtl import zed.rainxch.core.data.dto.GithubRepoNetworkModel import zed.rainxch.core.data.dto.GithubRepoSearchResponse import zed.rainxch.core.data.mappers.toSummary @@ -43,9 +45,13 @@ class HomeRepositoryImpl( private val httpClient: HttpClient, private val platform: Platform, private val cachedDataSource: CachedRepositoriesDataSource, - private val logger: GitHubStoreLogger + private val logger: GitHubStoreLogger, + private val cacheManager: CacheManager ) : HomeRepository { + private fun cacheKey(category: String, page: Int): String = + "home:${category}:${platform.name}:page$page" + @OptIn(ExperimentalTime::class) override fun getTrendingRepositories(page: Int): Flow = flow { if (page == 1) { @@ -54,24 +60,30 @@ class HomeRepositoryImpl( val cachedData = cachedDataSource.getCachedTrendingRepos() if (cachedData != null && cachedData.repositories.isNotEmpty()) { - logger.debug("Using cached data: ${cachedData.repositories.size} repos") + logger.debug("Using mirror cached data: ${cachedData.repositories.size} repos") val repos = cachedData.repositories.map { it.toGithubRepoSummary() } - emit( - PaginatedDiscoveryRepositories( - repos = repos, - hasMore = false, - nextPageIndex = 2 - ) + val result = PaginatedDiscoveryRepositories( + repos = repos, + hasMore = false, + nextPageIndex = 2 ) - + cacheManager.put(cacheKey("trending", page), result, CacheTtl.HOME_REPOS) + emit(result) return@flow } else { - logger.debug("No cached data available, falling back to live API") + logger.debug("No mirror data, checking local cache...") } } + val localCached = cacheManager.get(cacheKey("trending", page)) + if (localCached != null && localCached.repos.isNotEmpty()) { + logger.debug("Using locally cached trending repos: ${localCached.repos.size}") + emit(localCached) + return@flow + } + val thirtyDaysAgo = Clock.System.now() .minus(30.days) .toLocalDateTime(TimeZone.UTC) @@ -82,7 +94,8 @@ class HomeRepositoryImpl( baseQuery = "stars:>50 archived:false pushed:>=$thirtyDaysAgo", sort = "stars", order = "desc", - startPage = page + startPage = page, + category = "trending" ) ) }.flowOn(Dispatchers.IO) @@ -95,24 +108,30 @@ class HomeRepositoryImpl( val cachedData = cachedDataSource.getCachedHotReleaseRepos() if (cachedData != null && cachedData.repositories.isNotEmpty()) { - logger.debug("Using cached data: ${cachedData.repositories.size} repos") + logger.debug("Using mirror cached data: ${cachedData.repositories.size} repos") val repos = cachedData.repositories.map { it.toGithubRepoSummary() } - emit( - PaginatedDiscoveryRepositories( - repos = repos, - hasMore = false, - nextPageIndex = 2 - ) + val result = PaginatedDiscoveryRepositories( + repos = repos, + hasMore = false, + nextPageIndex = 2 ) - + cacheManager.put(cacheKey("hot_release", page), result, CacheTtl.HOME_REPOS) + emit(result) return@flow } else { - logger.debug("No cached data available, falling back to live API") + logger.debug("No mirror data, checking local cache...") } } + val localCached = cacheManager.get(cacheKey("hot_release", page)) + if (localCached != null && localCached.repos.isNotEmpty()) { + logger.debug("Using locally cached hot release repos: ${localCached.repos.size}") + emit(localCached) + return@flow + } + val fourteenDaysAgo = Clock.System.now() .minus(14.days) .toLocalDateTime(TimeZone.UTC) @@ -123,7 +142,8 @@ class HomeRepositoryImpl( baseQuery = "stars:>10 archived:false pushed:>=$fourteenDaysAgo", sort = "updated", order = "desc", - startPage = page + startPage = page, + category = "hot_release" ) ) }.flowOn(Dispatchers.IO) @@ -136,24 +156,30 @@ class HomeRepositoryImpl( val cachedData = cachedDataSource.getCachedMostPopularRepos() if (cachedData != null && cachedData.repositories.isNotEmpty()) { - logger.debug("Using cached data: ${cachedData.repositories.size} repos") + logger.debug("Using mirror cached data: ${cachedData.repositories.size} repos") val repos = cachedData.repositories.map { it.toGithubRepoSummary() } - emit( - PaginatedDiscoveryRepositories( - repos = repos, - hasMore = false, - nextPageIndex = 2 - ) + val result = PaginatedDiscoveryRepositories( + repos = repos, + hasMore = false, + nextPageIndex = 2 ) - + cacheManager.put(cacheKey("most_popular", page), result, CacheTtl.HOME_REPOS) + emit(result) return@flow } else { - logger.debug("No cached data available, falling back to live API") + logger.debug("No mirror data, checking local cache...") } } + val localCached = cacheManager.get(cacheKey("most_popular", page)) + if (localCached != null && localCached.repos.isNotEmpty()) { + logger.debug("Using locally cached most popular repos: ${localCached.repos.size}") + emit(localCached) + return@flow + } + val sixMonthsAgo = Clock.System.now() .minus(180.days) .toLocalDateTime(TimeZone.UTC) @@ -169,7 +195,8 @@ class HomeRepositoryImpl( baseQuery = "stars:>1000 archived:false created:<$sixMonthsAgo pushed:>=$oneYearAgo", sort = "stars", order = "desc", - startPage = page + startPage = page, + category = "most_popular" ) ) }.flowOn(Dispatchers.IO) @@ -179,6 +206,7 @@ class HomeRepositoryImpl( sort: String, order: String, startPage: Int, + category: String, desiredCount: Int = 10 ): Flow = flow { val results = mutableListOf() @@ -247,13 +275,12 @@ class HomeRepositoryImpl( val newItems = results.subList(lastEmittedCount, results.size) if (newItems.isNotEmpty()) { - emit( - PaginatedDiscoveryRepositories( - repos = newItems.toList(), - hasMore = true, - nextPageIndex = currentApiPage + 1 - ) + val paginatedResult = PaginatedDiscoveryRepositories( + repos = newItems.toList(), + hasMore = true, + nextPageIndex = currentApiPage + 1 ) + emit(paginatedResult) logger.debug("Emitted ${newItems.size} repos (total: ${results.size})") lastEmittedCount = results.size } @@ -290,13 +317,12 @@ class HomeRepositoryImpl( if (results.size > lastEmittedCount) { val finalBatch = results.subList(lastEmittedCount, results.size) val finalHasMore = pagesFetchedCount < maxPagesToFetch && results.size >= desiredCount - emit( - PaginatedDiscoveryRepositories( - repos = finalBatch.toList(), - hasMore = finalHasMore, - nextPageIndex = if (finalHasMore) currentApiPage + 1 else currentApiPage - ) + val finalResult = PaginatedDiscoveryRepositories( + repos = finalBatch.toList(), + hasMore = finalHasMore, + nextPageIndex = if (finalHasMore) currentApiPage + 1 else currentApiPage ) + emit(finalResult) logger.debug("Final emit: ${finalBatch.size} repos (total: ${results.size})") } else if (results.isEmpty()) { emit( @@ -308,6 +334,16 @@ class HomeRepositoryImpl( ) logger.debug("No results found") } + + if (results.isNotEmpty()) { + val allResults = PaginatedDiscoveryRepositories( + repos = results.toList(), + hasMore = pagesFetchedCount < maxPagesToFetch && results.size >= desiredCount, + nextPageIndex = currentApiPage + 1 + ) + cacheManager.put(cacheKey(category, startPage), allResults, CacheTtl.HOME_REPOS) + logger.debug("Cached ${results.size} repos for $category page $startPage") + } }.flowOn(Dispatchers.IO) private fun buildSimplifiedQuery(baseQuery: String): String { diff --git a/feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/model/UserProfile.kt b/feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/model/UserProfile.kt index d40079133..6dbf0dc64 100644 --- a/feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/model/UserProfile.kt +++ b/feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/model/UserProfile.kt @@ -1,5 +1,8 @@ package zed.rainxch.profile.domain.model +import kotlinx.serialization.Serializable + +@Serializable data class UserProfile( val id: Int, val imageUrl: String, From faf0483877ea3acd5c93d9c7d603139dd00c9043 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Thu, 26 Feb 2026 21:11:21 +0500 Subject: [PATCH 4/6] feat(cache): Implement data caching and persistence across modules This commit introduces a caching layer using `CacheManager` to improve performance and provide offline support across the search, profile, and repository detail features. - **feat(profile)**: Implemented fetching and caching of the current user's profile in `ProfileRepositoryImpl`. - Added logic to `ProfileViewModel` to automatically load the user profile upon login and clear it upon logout. - Updated `SharedModule` and build dependencies to support Ktor and Coroutines in the profile data module. - **feat(search)**: Added caching for repository search results in `SearchRepositoryImpl` using a hashed query key. - **feat(details)**: Integrated caching for repository details, releases, README content, stats, and user profiles. - Implemented stale-cache fallbacks for several repository detail operations to maintain functionality during network failures. - **refactor**: Updated Koin modules across `profile`, `search`, and `details` features to inject the `CacheManager`. --- .../rainxch/details/data/di/SharedModule.kt | 3 +- .../data/repository/DetailsRepositoryImpl.kt | 269 +++++++++++++----- feature/profile/data/build.gradle.kts | 2 + .../rainxch/profile/data/di/SharedModule.kt | 5 +- .../data/repository/ProfileRepositoryImpl.kt | 66 ++++- .../profile/presentation/ProfileViewModel.kt | 16 +- .../rainxch/search/data/di/SharedModule.kt | 1 + .../data/repository/SearchRepositoryImpl.kt | 38 ++- 8 files changed, 319 insertions(+), 81 deletions(-) diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt index 1615fe82c..d997f6f8b 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt @@ -9,7 +9,8 @@ val detailsModule = module { DetailsRepositoryImpl( logger = get(), httpClient = get(), - localizationManager = get() + localizationManager = get(), + cacheManager = get() ) } } \ No newline at end of file diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt index d29ba1e6a..f5d1d42db 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt @@ -9,6 +9,9 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.Serializable +import zed.rainxch.core.data.cache.CacheManager +import zed.rainxch.core.data.cache.CacheTtl import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.data.services.LocalizationManager import zed.rainxch.core.domain.model.GithubRelease @@ -29,9 +32,17 @@ import zed.rainxch.details.domain.repository.DetailsRepository class DetailsRepositoryImpl( private val httpClient: HttpClient, private val localizationManager: LocalizationManager, - private val logger: GitHubStoreLogger + private val logger: GitHubStoreLogger, + private val cacheManager: CacheManager ) : DetailsRepository { + @Serializable + private data class CachedReadme( + val content: String, + val languageCode: String?, + val path: String + ) + private val readmeHelper = ReadmeLocalizationHelper(localizationManager) private fun RepoByIdNetwork.toGithubRepoSummary(): GithubRepoSummary { @@ -58,19 +69,47 @@ class DetailsRepositoryImpl( } override suspend fun getRepositoryById(id: Long): GithubRepoSummary { - return httpClient.executeRequest { + val cacheKey = "details:repo_id:$id" + + cacheManager.get(cacheKey)?.let { cached -> + logger.debug("Cache hit for repo id=$id") + return cached + } + + val result = httpClient.executeRequest { get("/repositories/$id") { header(HttpHeaders.Accept, "application/vnd.github+json") } }.getOrThrow().toGithubRepoSummary() + + cacheManager.put(cacheKey, result, CacheTtl.REPO_DETAILS) + return result } override suspend fun getRepositoryByOwnerAndName(owner: String, name: String): GithubRepoSummary { - return httpClient.executeRequest { - get("/repos/$owner/$name") { - header(HttpHeaders.Accept, "application/vnd.github+json") + val cacheKey = "details:repo:$owner/$name" + + cacheManager.get(cacheKey)?.let { cached -> + logger.debug("Cache hit for repo $owner/$name") + return cached + } + + return try { + val result = httpClient.executeRequest { + get("/repos/$owner/$name") { + header(HttpHeaders.Accept, "application/vnd.github+json") + } + }.getOrThrow().toGithubRepoSummary() + + cacheManager.put(cacheKey, result, CacheTtl.REPO_DETAILS) + result + } catch (e: Exception) { + cacheManager.getStale(cacheKey)?.let { stale -> + logger.debug("Network error, using stale cache for $owner/$name") + return stale } - }.getOrThrow().toGithubRepoSummary() + throw e + } } override suspend fun getLatestPublishedRelease( @@ -78,22 +117,40 @@ class DetailsRepositoryImpl( repo: String, defaultBranch: String ): GithubRelease? { - val releases = httpClient.executeRequest> { - get("/repos/$owner/$repo/releases") { - header(HttpHeaders.Accept, "application/vnd.github+json") - parameter("per_page", 10) - } - }.getOrNull() ?: return null + val cacheKey = "details:latest_release:$owner/$repo" - val latest = releases - .asSequence() - .filter { (it.draft != true) && (it.prerelease != true) } - .maxByOrNull { it.publishedAt ?: it.createdAt ?: "" } - ?: return null + cacheManager.get(cacheKey)?.let { cached -> + logger.debug("Cache hit for latest release $owner/$repo") + return cached + } - return latest.copy( - body = processReleaseBody(latest.body, owner, repo, defaultBranch) - ).toDomain() + return try { + val releases = httpClient.executeRequest> { + get("/repos/$owner/$repo/releases") { + header(HttpHeaders.Accept, "application/vnd.github+json") + parameter("per_page", 10) + } + }.getOrNull() ?: return null + + val latest = releases + .asSequence() + .filter { (it.draft != true) && (it.prerelease != true) } + .maxByOrNull { it.publishedAt ?: it.createdAt ?: "" } + ?: return null + + val result = latest.copy( + body = processReleaseBody(latest.body, owner, repo, defaultBranch) + ).toDomain() + + cacheManager.put(cacheKey, result, CacheTtl.RELEASES) + result + } catch (e: Exception) { + cacheManager.getStale(cacheKey)?.let { stale -> + logger.debug("Network error, using stale cache for latest release $owner/$repo") + return stale + } + throw e + } } override suspend fun getAllReleases( @@ -101,21 +158,43 @@ class DetailsRepositoryImpl( repo: String, defaultBranch: String ): List { - val releases = httpClient.executeRequest> { - get("/repos/$owner/$repo/releases") { - header(HttpHeaders.Accept, "application/vnd.github+json") - parameter("per_page", 30) + val cacheKey = "details:releases:$owner/$repo" + + cacheManager.get>(cacheKey)?.let { cached -> + if (cached.isNotEmpty()) { + logger.debug("Cache hit for all releases $owner/$repo: ${cached.size} releases") + return cached } - }.getOrNull() ?: return emptyList() - - return releases - .filter { it.draft != true } - .map { release -> - release.copy( - body = processReleaseBody(release.body, owner, repo, defaultBranch) - ).toDomain() + } + + return try { + val releases = httpClient.executeRequest> { + get("/repos/$owner/$repo/releases") { + header(HttpHeaders.Accept, "application/vnd.github+json") + parameter("per_page", 30) + } + }.getOrNull() ?: return emptyList() + + val result = releases + .filter { it.draft != true } + .map { release -> + release.copy( + body = processReleaseBody(release.body, owner, repo, defaultBranch) + ).toDomain() + } + .sortedByDescending { it.publishedAt } + + if (result.isNotEmpty()) { + cacheManager.put(cacheKey, result, CacheTtl.RELEASES) + } + result + } catch (e: Exception) { + cacheManager.getStale>(cacheKey)?.let { stale -> + logger.debug("Network error, using stale cache for releases $owner/$repo") + return stale } - .sortedByDescending { it.publishedAt } + throw e + } } private fun processReleaseBody( @@ -142,6 +221,32 @@ class DetailsRepositoryImpl( owner: String, repo: String, defaultBranch: String + ): Triple? { + val cacheKey = "details:readme:$owner/$repo" + + cacheManager.get(cacheKey)?.let { cached -> + logger.debug("Cache hit for readme $owner/$repo") + return Triple(cached.content, cached.languageCode, cached.path) + } + + val result = fetchReadmeFromApi(owner, repo, defaultBranch) + + if (result != null) { + val cachedReadme = CachedReadme( + content = result.first, + languageCode = result.second, + path = result.third + ) + cacheManager.put(cacheKey, cachedReadme, CacheTtl.README) + } + + return result + } + + private suspend fun fetchReadmeFromApi( + owner: String, + repo: String, + defaultBranch: String ): Triple? { val attempts = readmeHelper.generateReadmeAttempts() val baseUrl = "https://raw.githubusercontent.com/$owner/$repo/$defaultBranch/" @@ -263,40 +368,76 @@ class DetailsRepositoryImpl( } override suspend fun getRepoStats(owner: String, repo: String): RepoStats { - val info = httpClient.executeRequest { - get("/repos/$owner/$repo") { - header(HttpHeaders.Accept, "application/vnd.github+json") - } - }.getOrThrow() + val cacheKey = "details:stats:$owner/$repo" - return RepoStats( - stars = info.stars, - forks = info.forks, - openIssues = info.openIssues, - ) + cacheManager.get(cacheKey)?.let { cached -> + logger.debug("Cache hit for repo stats $owner/$repo") + return cached + } + + return try { + val info = httpClient.executeRequest { + get("/repos/$owner/$repo") { + header(HttpHeaders.Accept, "application/vnd.github+json") + } + }.getOrThrow() + + val result = RepoStats( + stars = info.stars, + forks = info.forks, + openIssues = info.openIssues, + ) + + cacheManager.put(cacheKey, result, CacheTtl.REPO_STATS) + result + } catch (e: Exception) { + cacheManager.getStale(cacheKey)?.let { stale -> + logger.debug("Network error, using stale cache for stats $owner/$repo") + return stale + } + throw e + } } override suspend fun getUserProfile(username: String): GithubUserProfile { - val user = httpClient.executeRequest { - get("/users/$username") { - header(HttpHeaders.Accept, "application/vnd.github+json") + val cacheKey = "details:profile:$username" + + cacheManager.get(cacheKey)?.let { cached -> + logger.debug("Cache hit for user profile $username") + return cached + } + + return try { + val user = httpClient.executeRequest { + get("/users/$username") { + header(HttpHeaders.Accept, "application/vnd.github+json") + } + }.getOrThrow() + + val result = GithubUserProfile( + id = user.id, + login = user.login, + name = user.name, + bio = user.bio, + avatarUrl = user.avatarUrl, + htmlUrl = user.htmlUrl, + followers = user.followers, + following = user.following, + publicRepos = user.publicRepos, + location = user.location, + company = user.company, + blog = user.blog, + twitterUsername = user.twitterUsername + ) + + cacheManager.put(cacheKey, result, CacheTtl.USER_PROFILE) + result + } catch (e: Exception) { + cacheManager.getStale(cacheKey)?.let { stale -> + logger.debug("Network error, using stale cache for profile $username") + return stale } - }.getOrThrow() - - return GithubUserProfile( - id = user.id, - login = user.login, - name = user.name, - bio = user.bio, - avatarUrl = user.avatarUrl, - htmlUrl = user.htmlUrl, - followers = user.followers, - following = user.following, - publicRepos = user.publicRepos, - location = user.location, - company = user.company, - blog = user.blog, - twitterUsername = user.twitterUsername - ) + throw e + } } -} \ No newline at end of file +} diff --git a/feature/profile/data/build.gradle.kts b/feature/profile/data/build.gradle.kts index 83c828a03..f99fc3783 100644 --- a/feature/profile/data/build.gradle.kts +++ b/feature/profile/data/build.gradle.kts @@ -14,6 +14,8 @@ kotlin { implementation(projects.feature.profile.domain) implementation(libs.bundles.koin.common) + implementation(libs.bundles.ktor.common) + implementation(libs.kotlinx.coroutines.core) } } diff --git a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt index 7b77b2475..376486514 100644 --- a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt +++ b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt @@ -8,7 +8,10 @@ val settingsModule = module { single { ProfileRepositoryImpl( authenticationState = get(), - tokenStore = get() + tokenStore = get(), + httpClient = get(), + cacheManager = get(), + logger = get() ) } } \ No newline at end of file diff --git a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt index e37e89339..91734bc4e 100644 --- a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt +++ b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt @@ -1,27 +1,80 @@ package zed.rainxch.profile.data.repository +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import zed.rainxch.core.data.cache.CacheManager +import zed.rainxch.core.data.cache.CacheTtl import zed.rainxch.core.data.data_source.TokenStore +import zed.rainxch.core.data.dto.UserProfileNetwork +import zed.rainxch.core.data.network.executeRequest +import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.feature.profile.data.BuildKonfig +import zed.rainxch.profile.data.mappers.toUserProfile import zed.rainxch.profile.domain.model.UserProfile import zed.rainxch.profile.domain.repository.ProfileRepository class ProfileRepositoryImpl( private val authenticationState: AuthenticationState, - private val tokenStore: TokenStore + private val tokenStore: TokenStore, + private val httpClient: HttpClient, + private val cacheManager: CacheManager, + private val logger: GitHubStoreLogger ) : ProfileRepository { + + companion object { + private const val CACHE_KEY = "profile:me" + } + override val isUserLoggedIn: Flow get() = authenticationState .isUserLoggedIn() .flowOn(Dispatchers.IO) - override fun getUser(): Flow { - return flowOf(null) - } + override fun getUser(): Flow = flow { + val token = tokenStore.currentToken() + if (token == null) { + cacheManager.invalidate(CACHE_KEY) + emit(null) + return@flow + } + + val cached = cacheManager.get(CACHE_KEY) + if (cached != null) { + logger.debug("Profile cache hit") + emit(cached) + return@flow + } + + try { + val networkProfile = httpClient.executeRequest { + get("/user") { + header(HttpHeaders.Accept, "application/vnd.github+json") + } + }.getOrThrow() + + val userProfile = networkProfile.toUserProfile() + cacheManager.put(CACHE_KEY, userProfile, CacheTtl.USER_PROFILE) + logger.debug("Fetched and cached user profile: ${userProfile.username}") + emit(userProfile) + } catch (e: Exception) { + logger.error("Failed to fetch user profile: ${e.message}") + + val stale = cacheManager.getStale(CACHE_KEY) + if (stale != null) { + logger.debug("Using stale cached profile as fallback") + emit(stale) + } else { + emit(null) + } + } + }.flowOn(Dispatchers.IO) override fun getVersionName(): String { return BuildKonfig.VERSION_NAME @@ -29,5 +82,6 @@ class ProfileRepositoryImpl( override suspend fun logout() { tokenStore.clear() + cacheManager.invalidate(CACHE_KEY) } -} \ No newline at end of file +} diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt index c43383e08..a3af017c3 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt @@ -37,6 +37,7 @@ class ProfileViewModel( if (!hasLoadedInitialData) { loadCurrentTheme() collectIsUserLoggedIn() + loadUserProfile() loadVersionName() loadProxyConfig() @@ -67,10 +68,23 @@ class ProfileViewModel( profileRepository.isUserLoggedIn .collect { isLoggedIn -> _state.update { it.copy(isUserLoggedIn = isLoggedIn) } + if (isLoggedIn) { + loadUserProfile() + } else { + _state.update { it.copy(userProfile = null) } + } } } } + private fun loadUserProfile() { + viewModelScope.launch { + profileRepository.getUser().collect { profile -> + _state.update { it.copy(userProfile = profile) } + } + } + } + private fun loadCurrentTheme() { viewModelScope.launch { themesRepository.getThemeColor().collect { theme -> @@ -170,7 +184,7 @@ class ProfileViewModel( runCatching { profileRepository.logout() }.onSuccess { - _state.update { it.copy(isLogoutDialogVisible = false) } + _state.update { it.copy(isLogoutDialogVisible = false, userProfile = null) } _events.send(ProfileEvent.OnLogoutSuccessful) }.onFailure { error -> _state.update { it.copy(isLogoutDialogVisible = false) } diff --git a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt index 20ae38e0e..695c8e282 100644 --- a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt +++ b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt @@ -8,6 +8,7 @@ val searchModule = module { single { SearchRepositoryImpl( httpClient = get(), + cacheManager = get() ) } } \ No newline at end of file diff --git a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt index 677ed3c51..ff7525b5b 100644 --- a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt +++ b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt @@ -18,6 +18,8 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withTimeoutOrNull +import zed.rainxch.core.data.cache.CacheManager +import zed.rainxch.core.data.cache.CacheTtl import zed.rainxch.core.data.dto.GithubRepoNetworkModel import zed.rainxch.core.data.dto.GithubRepoSearchResponse import zed.rainxch.core.data.mappers.toSummary @@ -34,6 +36,7 @@ import zed.rainxch.search.data.utils.LruCache class SearchRepositoryImpl( private val httpClient: HttpClient, + private val cacheManager: CacheManager ) : SearchRepository { private val releaseCheckCache = LruCache(maxSize = 500) private val cacheMutex = Mutex() @@ -45,6 +48,17 @@ class SearchRepositoryImpl( private const val MAX_AUTO_SKIP_PAGES = 3 } + private fun searchCacheKey( + query: String, + platform: SearchPlatform, + language: ProgrammingLanguage, + sortBy: SortBy, + page: Int + ): String { + val queryHash = query.trim().lowercase().hashCode().toUInt().toString(16) + return "search:$queryHash:${platform.name}:${language.name}:${sortBy.name}:page$page" + } + override fun searchRepositories( query: String, searchPlatform: SearchPlatform, @@ -52,6 +66,14 @@ class SearchRepositoryImpl( sortBy: SortBy, page: Int ): Flow = channelFlow { + val cacheKey = searchCacheKey(query, searchPlatform, language, sortBy, page) + + val cached = cacheManager.get(cacheKey) + if (cached != null && cached.repos.isNotEmpty()) { + send(cached) + return@channelFlow + } + val searchQuery = buildSearchQuery(query, searchPlatform, language) val (sort, order) = sortBy.toGithubParams() @@ -92,14 +114,14 @@ class SearchRepositoryImpl( val verified = verifyBatch(response.items, searchPlatform) if (verified.isNotEmpty()) { - send( - PaginatedDiscoveryRepositories( - repos = verified, - hasMore = baseHasMore, - nextPageIndex = currentPage + 1, - totalCount = total - ) + val result = PaginatedDiscoveryRepositories( + repos = verified, + hasMore = baseHasMore, + nextPageIndex = currentPage + 1, + totalCount = total ) + cacheManager.put(cacheKey, result, CacheTtl.SEARCH_RESULTS) + send(result) return@channelFlow } @@ -290,4 +312,4 @@ class SearchRepositoryImpl( } return result } -} \ No newline at end of file +} From cd41c1f76ab70a04bd0b83b437acce7aea5688f4 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Thu, 26 Feb 2026 22:39:06 +0500 Subject: [PATCH 5/6] refactor(core): improve caching and profile data mapping This commit refines the caching mechanism and data layer for user profiles and repository details. It consolidates cache time-to-live constants, improves cache invalidation logic, and introduces proper DTO-to-domain mapping for GitHub user profiles. - **refactor(core)**: Moved `CacheTtl` constants into a companion object within `CacheManager` and updated `HOME_REPOS` TTL to 12 hours. - **fix(core)**: Updated `CacheManager` to use explicit key filtering during `invalidateByPrefix` and `cleanupExpired` to avoid potential `ConcurrentModificationException`. - **feat(profile)**: Added `UserProfileMappers.kt` and updated `ProfileViewModel` to properly manage user profile fetch jobs, preventing race conditions. - **feat(details)**: Introduced `GithubUserProfileDto` and associated mapper to separate network and domain models. - **fix(details)**: Enhanced `DetailsRepositoryImpl` with better error handling, falling back to stale cache data on network failures for repository and profile lookups. - **feat(ui)**: Updated `AccountSection` to display real stats (repos, followers, following) and improved name display logic. - **chore**: Removed unnecessary `@Serializable` annotations from domain models. --- .../rainxch/core/data/cache/CacheManager.kt | 28 +++++---- .../core/domain/model/GithubUserProfile.kt | 3 - .../model/PaginatedDiscoveryRepositories.kt | 3 - .../data/mappers/GithubUserProfileMapper.kt | 22 +++++++ .../data/model/GithubUserProfileDto.kt | 20 +++++++ .../data/repository/DetailsRepositoryImpl.kt | 59 ++++++++++++------- .../data/repository/HomeRepositoryImpl.kt | 14 ++--- .../data/mappers/UserProfileMappers.kt | 17 ++++++ .../data/repository/ProfileRepositoryImpl.kt | 3 +- .../profile/presentation/ProfileViewModel.kt | 7 ++- .../components/sections/AccountSection.kt | 14 +++-- .../data/repository/SearchRepositoryImpl.kt | 6 +- 12 files changed, 139 insertions(+), 57 deletions(-) create mode 100644 feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/mappers/GithubUserProfileMapper.kt create mode 100644 feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/model/GithubUserProfileDto.kt create mode 100644 feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/mappers/UserProfileMappers.kt diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt index 2dfacadc4..ce2a8f476 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt @@ -7,16 +7,6 @@ import zed.rainxch.core.data.local.db.entities.CacheEntryEntity import kotlin.time.Clock import kotlin.time.Duration.Companion.hours -object CacheTtl { - val HOME_REPOS = 3.hours.inWholeMilliseconds - val REPO_DETAILS = 6.hours.inWholeMilliseconds - val RELEASES = 6.hours.inWholeMilliseconds - val README = 12.hours.inWholeMilliseconds - val USER_PROFILE = 6.hours.inWholeMilliseconds - val SEARCH_RESULTS = 1.hours.inWholeMilliseconds - val REPO_STATS = 6.hours.inWholeMilliseconds -} - class CacheManager( val cacheDao: CacheDao ) { @@ -90,13 +80,27 @@ class CacheManager( } suspend fun invalidateByPrefix(prefix: String) { - memoryCache.keys.removeAll { it.startsWith(prefix) } + val keysToRemove = memoryCache.keys.filter { it.startsWith(prefix) } + keysToRemove.forEach { memoryCache.remove(it) } cacheDao.deleteByPrefix(prefix) } suspend fun cleanupExpired() { val currentTime = now() - memoryCache.entries.removeAll { it.value.first <= currentTime } + val expiredKeys = memoryCache.entries + .filter { it.value.first <= currentTime } + .map { it.key } + expiredKeys.forEach { memoryCache.remove(it) } cacheDao.deleteExpired(currentTime) } + + companion object CacheTtl { + val HOME_REPOS = 12.hours.inWholeMilliseconds + val REPO_DETAILS = 6.hours.inWholeMilliseconds + val RELEASES = 6.hours.inWholeMilliseconds + val README = 12.hours.inWholeMilliseconds + val USER_PROFILE = 6.hours.inWholeMilliseconds + val SEARCH_RESULTS = 1.hours.inWholeMilliseconds + val REPO_STATS = 6.hours.inWholeMilliseconds + } } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubUserProfile.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubUserProfile.kt index da46a58f0..433d02165 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubUserProfile.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubUserProfile.kt @@ -1,8 +1,5 @@ package zed.rainxch.core.domain.model -import kotlinx.serialization.Serializable - -@Serializable data class GithubUserProfile( val id: Long, val login: String, diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt index b4f263a1f..9f4a7bc88 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt @@ -1,8 +1,5 @@ package zed.rainxch.core.domain.model -import kotlinx.serialization.Serializable - -@Serializable data class PaginatedDiscoveryRepositories( val repos: List, val hasMore: Boolean, diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/mappers/GithubUserProfileMapper.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/mappers/GithubUserProfileMapper.kt new file mode 100644 index 000000000..77eaa197b --- /dev/null +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/mappers/GithubUserProfileMapper.kt @@ -0,0 +1,22 @@ +package zed.rainxch.details.data.mappers + +import zed.rainxch.core.domain.model.GithubUserProfile +import zed.rainxch.details.data.model.GithubUserProfileDto + +fun GithubUserProfileDto.toDomain(): GithubUserProfile { + return GithubUserProfile( + id = id, + login = login, + name = name, + bio = bio, + avatarUrl = avatarUrl, + htmlUrl = htmlUrl, + followers = followers, + following = following, + publicRepos = publicRepos, + location = location, + company = company, + blog = blog, + twitterUsername = twitterUsername + ) +} \ No newline at end of file diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/model/GithubUserProfileDto.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/model/GithubUserProfileDto.kt new file mode 100644 index 000000000..b2e7f4346 --- /dev/null +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/model/GithubUserProfileDto.kt @@ -0,0 +1,20 @@ +package zed.rainxch.details.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class GithubUserProfileDto( + val id: Long, + val login: String, + val name: String?, + val bio: String?, + val avatarUrl: String, + val htmlUrl: String, + val followers: Int, + val following: Int, + val publicRepos: Int, + val location: String?, + val company: String?, + val blog: String?, + val twitterUsername: String? +) \ No newline at end of file diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt index f5d1d42db..28bc9a2aa 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt @@ -11,19 +11,25 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.serialization.Serializable import zed.rainxch.core.data.cache.CacheManager -import zed.rainxch.core.data.cache.CacheTtl +import zed.rainxch.core.data.cache.CacheManager.CacheTtl.README +import zed.rainxch.core.data.cache.CacheManager.CacheTtl.RELEASES +import zed.rainxch.core.data.cache.CacheManager.CacheTtl.REPO_DETAILS +import zed.rainxch.core.data.cache.CacheManager.CacheTtl.REPO_STATS +import zed.rainxch.core.data.cache.CacheManager.CacheTtl.USER_PROFILE import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.data.services.LocalizationManager import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.GithubRepoSummary import zed.rainxch.core.domain.model.GithubUser -import zed.rainxch.core.domain.model.GithubUserProfile import zed.rainxch.core.data.dto.ReleaseNetwork import zed.rainxch.core.data.dto.RepoByIdNetwork import zed.rainxch.core.data.dto.RepoInfoNetwork import zed.rainxch.core.data.dto.UserProfileNetwork import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.data.mappers.toDomain +import zed.rainxch.core.domain.model.GithubUserProfile +import zed.rainxch.details.data.mappers.toDomain +import zed.rainxch.details.data.model.GithubUserProfileDto import zed.rainxch.details.data.utils.ReadmeLocalizationHelper import zed.rainxch.details.data.utils.preprocessMarkdown import zed.rainxch.details.domain.model.RepoStats @@ -76,17 +82,28 @@ class DetailsRepositoryImpl( return cached } - val result = httpClient.executeRequest { - get("/repositories/$id") { - header(HttpHeaders.Accept, "application/vnd.github+json") + return try { + val result = httpClient.executeRequest { + get("/repositories/$id") { + header(HttpHeaders.Accept, "application/vnd.github+json") + } + }.getOrThrow().toGithubRepoSummary() + cacheManager.put(cacheKey, result, REPO_DETAILS) + result + } catch (e: Exception) { + cacheManager.getStale(cacheKey)?.let { stale -> + logger.debug("Network error, using stale cache for repo id=$id") + return stale } - }.getOrThrow().toGithubRepoSummary() + throw e + } - cacheManager.put(cacheKey, result, CacheTtl.REPO_DETAILS) - return result } - override suspend fun getRepositoryByOwnerAndName(owner: String, name: String): GithubRepoSummary { + override suspend fun getRepositoryByOwnerAndName( + owner: String, + name: String + ): GithubRepoSummary { val cacheKey = "details:repo:$owner/$name" cacheManager.get(cacheKey)?.let { cached -> @@ -101,7 +118,7 @@ class DetailsRepositoryImpl( } }.getOrThrow().toGithubRepoSummary() - cacheManager.put(cacheKey, result, CacheTtl.REPO_DETAILS) + cacheManager.put(cacheKey, result, REPO_DETAILS) result } catch (e: Exception) { cacheManager.getStale(cacheKey)?.let { stale -> @@ -142,7 +159,7 @@ class DetailsRepositoryImpl( body = processReleaseBody(latest.body, owner, repo, defaultBranch) ).toDomain() - cacheManager.put(cacheKey, result, CacheTtl.RELEASES) + cacheManager.put(cacheKey, result, RELEASES) result } catch (e: Exception) { cacheManager.getStale(cacheKey)?.let { stale -> @@ -185,7 +202,7 @@ class DetailsRepositoryImpl( .sortedByDescending { it.publishedAt } if (result.isNotEmpty()) { - cacheManager.put(cacheKey, result, CacheTtl.RELEASES) + cacheManager.put(cacheKey, result, RELEASES) } result } catch (e: Exception) { @@ -237,7 +254,7 @@ class DetailsRepositoryImpl( languageCode = result.second, path = result.third ) - cacheManager.put(cacheKey, cachedReadme, CacheTtl.README) + cacheManager.put(cacheKey, cachedReadme, README) } return result @@ -388,7 +405,7 @@ class DetailsRepositoryImpl( openIssues = info.openIssues, ) - cacheManager.put(cacheKey, result, CacheTtl.REPO_STATS) + cacheManager.put(cacheKey, result, REPO_STATS) result } catch (e: Exception) { cacheManager.getStale(cacheKey)?.let { stale -> @@ -402,9 +419,9 @@ class DetailsRepositoryImpl( override suspend fun getUserProfile(username: String): GithubUserProfile { val cacheKey = "details:profile:$username" - cacheManager.get(cacheKey)?.let { cached -> + cacheManager.get(cacheKey)?.let { cached -> logger.debug("Cache hit for user profile $username") - return cached + return cached.toDomain() } return try { @@ -414,7 +431,7 @@ class DetailsRepositoryImpl( } }.getOrThrow() - val result = GithubUserProfile( + val result = GithubUserProfileDto( id = user.id, login = user.login, name = user.name, @@ -428,14 +445,14 @@ class DetailsRepositoryImpl( company = user.company, blog = user.blog, twitterUsername = user.twitterUsername - ) + ).toDomain() - cacheManager.put(cacheKey, result, CacheTtl.USER_PROFILE) + cacheManager.put(cacheKey, result, USER_PROFILE) result } catch (e: Exception) { - cacheManager.getStale(cacheKey)?.let { stale -> + cacheManager.getStale(cacheKey)?.let { stale -> logger.debug("Network error, using stale cache for profile $username") - return stale + return stale.toDomain() } throw e } diff --git a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt index 139a97367..a283f7681 100644 --- a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt +++ b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt @@ -24,7 +24,7 @@ import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import zed.rainxch.core.data.cache.CacheManager -import zed.rainxch.core.data.cache.CacheTtl +import zed.rainxch.core.data.cache.CacheManager.CacheTtl.HOME_REPOS import zed.rainxch.core.data.dto.GithubRepoNetworkModel import zed.rainxch.core.data.dto.GithubRepoSearchResponse import zed.rainxch.core.data.mappers.toSummary @@ -69,7 +69,7 @@ class HomeRepositoryImpl( hasMore = false, nextPageIndex = 2 ) - cacheManager.put(cacheKey("trending", page), result, CacheTtl.HOME_REPOS) + cacheManager.put(cacheKey("trending", page), result, CacheManager.CacheTtl.HOME_REPOS) emit(result) return@flow } else { @@ -117,7 +117,7 @@ class HomeRepositoryImpl( hasMore = false, nextPageIndex = 2 ) - cacheManager.put(cacheKey("hot_release", page), result, CacheTtl.HOME_REPOS) + cacheManager.put(cacheKey("hot_release", page), result, CacheManager.HOME_REPOS) emit(result) return@flow } else { @@ -165,7 +165,7 @@ class HomeRepositoryImpl( hasMore = false, nextPageIndex = 2 ) - cacheManager.put(cacheKey("most_popular", page), result, CacheTtl.HOME_REPOS) + cacheManager.put(cacheKey("most_popular", page), result, HOME_REPOS) emit(result) return@flow } else { @@ -302,7 +302,7 @@ class HomeRepositoryImpl( currentApiPage++ pagesFetchedCount++ - } catch (e: RateLimitException) { + } catch (_: RateLimitException) { logger.error("Rate limited during search") break } catch (e: CancellationException) { @@ -341,7 +341,7 @@ class HomeRepositoryImpl( hasMore = pagesFetchedCount < maxPagesToFetch && results.size >= desiredCount, nextPageIndex = currentApiPage + 1 ) - cacheManager.put(cacheKey(category, startPage), allResults, CacheTtl.HOME_REPOS) + cacheManager.put(cacheKey(category, startPage), allResults, HOME_REPOS) logger.debug("Cached ${results.size} repos for $category page $startPage") } }.flowOn(Dispatchers.IO) @@ -424,7 +424,7 @@ class HomeRepositoryImpl( } else { null } - } catch (e: Exception) { + } catch (_: Exception) { null } } diff --git a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/mappers/UserProfileMappers.kt b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/mappers/UserProfileMappers.kt new file mode 100644 index 000000000..3ecbe0d97 --- /dev/null +++ b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/mappers/UserProfileMappers.kt @@ -0,0 +1,17 @@ +package zed.rainxch.profile.data.mappers + +import zed.rainxch.core.data.dto.UserProfileNetwork +import zed.rainxch.profile.domain.model.UserProfile + +fun UserProfileNetwork.toUserProfile(): UserProfile { + return UserProfile( + id = id.toInt(), + imageUrl = avatarUrl, + name = name ?: login, + username = login, + bio = bio, + repositoryCount = publicRepos, + followers = followers, + following = following + ) +} diff --git a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt index 91734bc4e..54d5b8a2e 100644 --- a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt +++ b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import zed.rainxch.core.data.cache.CacheManager +import zed.rainxch.core.data.cache.CacheManager.CacheTtl.USER_PROFILE import zed.rainxch.core.data.cache.CacheTtl import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.dto.UserProfileNetwork @@ -60,7 +61,7 @@ class ProfileRepositoryImpl( }.getOrThrow() val userProfile = networkProfile.toUserProfile() - cacheManager.put(CACHE_KEY, userProfile, CacheTtl.USER_PROFILE) + cacheManager.put(CACHE_KEY, userProfile, USER_PROFILE) logger.debug("Fetched and cached user profile: ${userProfile.username}") emit(userProfile) } catch (e: Exception) { diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt index a3af017c3..b1919d14d 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt @@ -2,6 +2,7 @@ package zed.rainxch.profile.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -29,6 +30,8 @@ class ProfileViewModel( private val proxyRepository: ProxyRepository ) : ViewModel() { + private var userProfileJob: Job? = null + private var hasLoadedInitialData = false private val _state = MutableStateFlow(ProfileState()) @@ -78,7 +81,9 @@ class ProfileViewModel( } private fun loadUserProfile() { - viewModelScope.launch { + userProfileJob?.cancel() + + userProfileJob = viewModelScope.launch { profileRepository.getUser().collect { profile -> _state.update { it.copy(userProfile = profile) } } diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt index 9e6dbeba4..f22840359 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt @@ -76,9 +76,11 @@ fun LazyListScope.accountSection( Spacer(Modifier.height(8.dp)) } - if (state.userProfile?.name != null) { + if (state.userProfile != null) { + val displayName = state.userProfile.name.takeIf { it.isNotBlank() } + ?: state.userProfile.username Text( - text = state.userProfile.name, + text = displayName, style = MaterialTheme.typography.titleLargeEmphasized, color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center @@ -129,19 +131,19 @@ fun LazyListScope.accountSection( ) { StatCard( label = "Repos", - value = "24", + value = state.userProfile.repositoryCount.toString(), modifier = Modifier.weight(1f) ) StatCard( label = "Followers", - value = "1.2K", + value = state.userProfile.followers.toString(), modifier = Modifier.weight(1f) ) StatCard( label = "Following", - value = "56", + value = state.userProfile.following.toString(), modifier = Modifier.weight(1f) ) } @@ -156,7 +158,7 @@ fun LazyListScope.accountSection( onAction(ProfileAction.OnLoginClick) }, modifier = Modifier - .width(480.dp) + .fillMaxWidth() .padding(horizontal = 8.dp) ) } diff --git a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt index ff7525b5b..047660029 100644 --- a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt +++ b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt @@ -19,7 +19,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withTimeoutOrNull import zed.rainxch.core.data.cache.CacheManager -import zed.rainxch.core.data.cache.CacheTtl +import zed.rainxch.core.data.cache.CacheManager.CacheTtl.SEARCH_RESULTS import zed.rainxch.core.data.dto.GithubRepoNetworkModel import zed.rainxch.core.data.dto.GithubRepoSearchResponse import zed.rainxch.core.data.mappers.toSummary @@ -69,7 +69,7 @@ class SearchRepositoryImpl( val cacheKey = searchCacheKey(query, searchPlatform, language, sortBy, page) val cached = cacheManager.get(cacheKey) - if (cached != null && cached.repos.isNotEmpty()) { + if (cached != null) { send(cached) return@channelFlow } @@ -120,7 +120,7 @@ class SearchRepositoryImpl( nextPageIndex = currentPage + 1, totalCount = total ) - cacheManager.put(cacheKey, result, CacheTtl.SEARCH_RESULTS) + cacheManager.put(cacheKey, result, SEARCH_RESULTS) send(result) return@channelFlow } From a6e7c526636f65f50f64ac6ca28f5912726bd245 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Thu, 26 Feb 2026 22:44:54 +0500 Subject: [PATCH 6/6] refactor(details): use domain model for profile caching and cleanup DTOs This commit simplifies the user profile data handling by using the `GithubUserProfile` domain model directly for caching and removing redundant DTOs and mappers. - **refactor(details)**: Removed `GithubUserProfileDto` and its associated mapper. - **refactor(details)**: Updated `DetailsRepositoryImpl` to cache and retrieve `GithubUserProfile` directly instead of using a DTO. - **feat(core)**: Added `@Serializable` to `GithubUserProfile` and `PaginatedDiscoveryRepositories` to support serialization/caching. - **chore**: Cleaned up unused imports in `ProfileRepositoryImpl` and `DetailsRepositoryImpl`. --- .../core/domain/model/GithubUserProfile.kt | 3 +++ .../model/PaginatedDiscoveryRepositories.kt | 3 +++ .../data/mappers/GithubUserProfileMapper.kt | 22 ------------------- .../data/model/GithubUserProfileDto.kt | 20 ----------------- .../data/repository/DetailsRepositoryImpl.kt | 14 +++++------- .../data/repository/ProfileRepositoryImpl.kt | 1 - 6 files changed, 12 insertions(+), 51 deletions(-) delete mode 100644 feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/mappers/GithubUserProfileMapper.kt delete mode 100644 feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/model/GithubUserProfileDto.kt diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubUserProfile.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubUserProfile.kt index 433d02165..da46a58f0 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubUserProfile.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubUserProfile.kt @@ -1,5 +1,8 @@ package zed.rainxch.core.domain.model +import kotlinx.serialization.Serializable + +@Serializable data class GithubUserProfile( val id: Long, val login: String, diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt index 9f4a7bc88..b4f263a1f 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/PaginatedDiscoveryRepositories.kt @@ -1,5 +1,8 @@ package zed.rainxch.core.domain.model +import kotlinx.serialization.Serializable + +@Serializable data class PaginatedDiscoveryRepositories( val repos: List, val hasMore: Boolean, diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/mappers/GithubUserProfileMapper.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/mappers/GithubUserProfileMapper.kt deleted file mode 100644 index 77eaa197b..000000000 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/mappers/GithubUserProfileMapper.kt +++ /dev/null @@ -1,22 +0,0 @@ -package zed.rainxch.details.data.mappers - -import zed.rainxch.core.domain.model.GithubUserProfile -import zed.rainxch.details.data.model.GithubUserProfileDto - -fun GithubUserProfileDto.toDomain(): GithubUserProfile { - return GithubUserProfile( - id = id, - login = login, - name = name, - bio = bio, - avatarUrl = avatarUrl, - htmlUrl = htmlUrl, - followers = followers, - following = following, - publicRepos = publicRepos, - location = location, - company = company, - blog = blog, - twitterUsername = twitterUsername - ) -} \ No newline at end of file diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/model/GithubUserProfileDto.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/model/GithubUserProfileDto.kt deleted file mode 100644 index b2e7f4346..000000000 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/model/GithubUserProfileDto.kt +++ /dev/null @@ -1,20 +0,0 @@ -package zed.rainxch.details.data.model - -import kotlinx.serialization.Serializable - -@Serializable -data class GithubUserProfileDto( - val id: Long, - val login: String, - val name: String?, - val bio: String?, - val avatarUrl: String, - val htmlUrl: String, - val followers: Int, - val following: Int, - val publicRepos: Int, - val location: String?, - val company: String?, - val blog: String?, - val twitterUsername: String? -) \ No newline at end of file diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt index 28bc9a2aa..1f3735998 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt @@ -28,8 +28,6 @@ import zed.rainxch.core.data.dto.UserProfileNetwork import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.data.mappers.toDomain import zed.rainxch.core.domain.model.GithubUserProfile -import zed.rainxch.details.data.mappers.toDomain -import zed.rainxch.details.data.model.GithubUserProfileDto import zed.rainxch.details.data.utils.ReadmeLocalizationHelper import zed.rainxch.details.data.utils.preprocessMarkdown import zed.rainxch.details.domain.model.RepoStats @@ -419,9 +417,9 @@ class DetailsRepositoryImpl( override suspend fun getUserProfile(username: String): GithubUserProfile { val cacheKey = "details:profile:$username" - cacheManager.get(cacheKey)?.let { cached -> + cacheManager.get(cacheKey)?.let { cached -> logger.debug("Cache hit for user profile $username") - return cached.toDomain() + return cached } return try { @@ -431,7 +429,7 @@ class DetailsRepositoryImpl( } }.getOrThrow() - val result = GithubUserProfileDto( + val result = GithubUserProfile( id = user.id, login = user.login, name = user.name, @@ -445,14 +443,14 @@ class DetailsRepositoryImpl( company = user.company, blog = user.blog, twitterUsername = user.twitterUsername - ).toDomain() + ) cacheManager.put(cacheKey, result, USER_PROFILE) result } catch (e: Exception) { - cacheManager.getStale(cacheKey)?.let { stale -> + cacheManager.getStale(cacheKey)?.let { stale -> logger.debug("Network error, using stale cache for profile $username") - return stale.toDomain() + return stale } throw e } diff --git a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt index 54d5b8a2e..9490243f0 100644 --- a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt +++ b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import zed.rainxch.core.data.cache.CacheManager import zed.rainxch.core.data.cache.CacheManager.CacheTtl.USER_PROFILE -import zed.rainxch.core.data.cache.CacheTtl import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.dto.UserProfileNetwork import zed.rainxch.core.data.network.executeRequest