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..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 @@ -213,6 +213,15 @@ fun AppNavigation( ProfileRoot( onNavigateBack = { navController.navigateUp() + }, + onNavigateToAuthentication = { + navController.navigate(GithubStoreGraph.AuthenticationScreen) + }, + onNavigateToStarredRepos = { + navController.navigate(GithubStoreGraph.StarredReposScreen) + }, + onNavigateToFavouriteRepos = { + navController.navigate(GithubStoreGraph.FavouritesScreen) } ) } 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..ce2a8f476 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt @@ -0,0 +1,106 @@ +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 + +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) { + val keysToRemove = memoryCache.keys.filter { it.startsWith(prefix) } + keysToRemove.forEach { memoryCache.remove(it) } + cacheDao.deleteByPrefix(prefix) + } + + suspend fun cleanupExpired() { + val currentTime = now() + 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/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/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..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 @@ -9,18 +9,25 @@ 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.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.utils.ReadmeLocalizationHelper import zed.rainxch.details.data.utils.preprocessMarkdown import zed.rainxch.details.domain.model.RepoStats @@ -29,9 +36,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 +73,58 @@ class DetailsRepositoryImpl( } override suspend fun getRepositoryById(id: Long): GithubRepoSummary { - return httpClient.executeRequest { - get("/repositories/$id") { - header(HttpHeaders.Accept, "application/vnd.github+json") + val cacheKey = "details:repo_id:$id" + + cacheManager.get(cacheKey)?.let { cached -> + logger.debug("Cache hit for repo id=$id") + return cached + } + + 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 + } + } - override suspend fun getRepositoryByOwnerAndName(owner: String, name: String): GithubRepoSummary { - return httpClient.executeRequest { - get("/repos/$owner/$name") { - header(HttpHeaders.Accept, "application/vnd.github+json") + override suspend fun getRepositoryByOwnerAndName( + owner: String, + name: String + ): GithubRepoSummary { + 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, 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 +132,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, 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 +173,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 + } + } + + 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, RELEASES) } - }.getOrNull() ?: return emptyList() - - return releases - .filter { it.draft != true } - .map { release -> - release.copy( - body = processReleaseBody(release.body, owner, repo, defaultBranch) - ).toDomain() + 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 +236,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, 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 +383,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, 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, 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/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..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 @@ -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.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 @@ -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, CacheManager.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, CacheManager.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, 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 } @@ -275,7 +302,7 @@ class HomeRepositoryImpl( currentApiPage++ pagesFetchedCount++ - } catch (e: RateLimitException) { + } catch (_: RateLimitException) { logger.error("Rate limited during search") break } catch (e: CancellationException) { @@ -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, HOME_REPOS) + logger.debug("Cached ${results.size} repos for $category page $startPage") + } }.flowOn(Dispatchers.IO) private fun buildSimplifiedQuery(baseQuery: String): String { @@ -388,7 +424,7 @@ class HomeRepositoryImpl( } else { null } - } catch (e: Exception) { + } catch (_: Exception) { null } } 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/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 e37e89339..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 @@ -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.CacheManager.CacheTtl.USER_PROFILE 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, 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/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..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,11 +1,14 @@ package zed.rainxch.profile.domain.model +import kotlinx.serialization.Serializable + +@Serializable data class UserProfile( val id: Int, 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..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,8 +10,11 @@ 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 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..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 @@ -49,6 +49,9 @@ import zed.rainxch.profile.presentation.components.sections.settings @Composable fun ProfileRoot( onNavigateBack: () -> Unit, + onNavigateToAuthentication: () -> Unit, + onNavigateToStarredRepos: () -> Unit, + onNavigateToFavouriteRepos: () -> Unit, viewModel: ProfileViewModel = koinViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -93,6 +96,18 @@ fun ProfileRoot( onNavigateBack() } + ProfileAction.OnLoginClick -> { + onNavigateToAuthentication() + } + + ProfileAction.OnFavouriteReposClick -> { + onNavigateToFavouriteRepos() + } + + ProfileAction.OnStarredReposClick -> { + onNavigateToStarredRepos() + } + else -> { viewModel.onAction(action) } @@ -147,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 a0b06064a..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()) @@ -37,6 +40,7 @@ class ProfileViewModel( if (!hasLoadedInitialData) { loadCurrentTheme() collectIsUserLoggedIn() + loadUserProfile() loadVersionName() loadProxyConfig() @@ -67,10 +71,25 @@ 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() { + userProfileJob?.cancel() + + userProfileJob = viewModelScope.launch { + profileRepository.getUser().collect { profile -> + _state.update { it.copy(userProfile = profile) } + } + } + } + private fun loadCurrentTheme() { viewModelScope.launch { themesRepository.getThemeColor().collect { theme -> @@ -170,7 +189,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) } @@ -193,6 +212,18 @@ class ProfileViewModel( /* Handed in composable */ } + ProfileAction.OnLoginClick -> { + /* 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/AccountSection.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt index a1b689a59..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 @@ -1,57 +1,216 @@ 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 != null) { + val displayName = state.userProfile.name.takeIf { it.isNotBlank() } + ?: state.userProfile.username + Text( + text = displayName, + 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 = state.userProfile.repositoryCount.toString(), + modifier = Modifier.weight(1f) + ) + StatCard( + label = "Followers", + value = state.userProfile.followers.toString(), + modifier = Modifier.weight(1f) + ) + + StatCard( + label = "Following", + value = state.userProfile.following.toString(), + modifier = Modifier.weight(1f) + ) + } + } + + if (state.userProfile == null) { + Spacer(Modifier.height(8.dp)) + + GithubStoreButton( + text = "Login", + onClick = { + onAction(ProfileAction.OnLoginClick) + }, + modifier = Modifier + .fillMaxWidth() + .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 +222,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 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 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..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 @@ -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.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 @@ -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) { + 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, SEARCH_RESULTS) + send(result) return@channelFlow } @@ -290,4 +312,4 @@ class SearchRepositoryImpl( } return result } -} \ No newline at end of file +}