diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 50593cc..9a06090 100755 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -25,6 +25,7 @@ kotlin { iosTarget.binaries.framework { baseName = "ComposeApp" isStatic = true + linkerOpts("-lsqlite3", "-framework", "SwiftUI", "-framework", "UIKit") } } @@ -55,6 +56,7 @@ kotlin { implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.navigation.compose) implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) implementation(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index d9b77f4..219faaf 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -76,6 +76,9 @@ Top Repositories Organizations + Issues + Mentioned + No starred repositories synapseSRC diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/data/GitHubApiClient.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/data/GitHubApiClient.kt index c95a375..8e79091 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/data/GitHubApiClient.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/data/GitHubApiClient.kt @@ -32,6 +32,13 @@ data class GitHubUserStatus( val message: String? ) + +@Serializable +data class GitHubRepoOwner( + val login: String, + @SerialName("avatar_url") val avatarUrl: String +) + @Serializable data class GitHubRepo( val id: Long, @@ -41,6 +48,7 @@ data class GitHubRepo( val private: Boolean = false, @SerialName("stargazers_count") val stars: Int = 0, val language: String? = null, + val owner: GitHubRepoOwner? = null, @SerialName("updated_at") val updatedAt: String? = null ) diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeScreen.kt index f0d7de3..aaede29 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeScreen.kt @@ -1,4 +1,7 @@ package dev.therealashik.github.home +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlin.time.* import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -9,6 +12,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* +import coil3.compose.AsyncImage +import androidx.compose.foundation.clickable +import androidx.compose.material.icons.outlined.CallSplit + import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -52,14 +59,25 @@ fun HomeScreenContent(state: HomeUiState, onRetry: () -> Unit = {}, onNavigateTo Icon(Icons.Outlined.AddCircle, contentDescription = stringResource(Res.string.cd_create)) } IconButton(onClick = onNavigateToProfile) { - Icon( - imageVector = Icons.Outlined.Person, - contentDescription = stringResource(Res.string.cd_user_avatar), - modifier = Modifier - .size(Dimens.IconSizeNormal) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) - ) + if (state is HomeUiState.Success && state.avatarUrl.isNotEmpty()) { + AsyncImage( + model = state.avatarUrl, + contentDescription = stringResource(Res.string.cd_user_avatar), + modifier = Modifier + .size(Dimens.IconSizeNormal) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + } else { + Icon( + imageVector = Icons.Outlined.Person, + contentDescription = stringResource(Res.string.cd_user_avatar), + modifier = Modifier + .size(Dimens.IconSizeNormal) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + } } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background), @@ -93,20 +111,38 @@ fun HomeScreenContent(state: HomeUiState, onRetry: () -> Unit = {}, onNavigateTo } is HomeUiState.Success -> { LazyColumn(modifier = Modifier.fillMaxSize().padding(innerPadding)) { - // Repositories + // My Work item { SectionHeader(title = stringResource(Res.string.section_my_work)) } - items(state.repos) { RepoRow(it) } + item { + MyWorkRow( + icon = Icons.Outlined.AccountBox, + title = stringResource(Res.string.my_work_repos), + color = MaterialTheme.colorScheme.surfaceVariant + ) + } + item { + MyWorkRow( + icon = Icons.Outlined.Menu, + title = stringResource(Res.string.my_work_orgs), + color = MaterialTheme.colorScheme.tertiary + ) + } item { HomeDivider() } - // Organizations - item { SectionHeader(title = "Organizations") } - if (state.orgs.isEmpty()) { - item { EmptyHint("No organizations") } + // Favorites + item { SectionHeader(title = stringResource(Res.string.section_favorites)) } + if (state.starred.isEmpty()) { + item { EmptyHint(stringResource(Res.string.no_starred_repos)) } } else { - items(state.orgs) { OrgRow(it) } + items(state.starred.take(5)) { StarredRow(it) } } item { HomeDivider() } + // Shortcuts + item { SectionHeader(title = stringResource(Res.string.section_shortcuts)) } + item { ShortcutsRow() } + item { HomeDivider() } + // Notifications item { SectionHeader(title = stringResource(Res.string.section_recent)) } if (state.notifications.isEmpty()) { @@ -144,6 +180,125 @@ private fun SectionHeader(title: String) { } } + +@Composable +private fun MyWorkRow(icon: androidx.compose.ui.graphics.vector.ImageVector, title: String, color: androidx.compose.ui.graphics.Color) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { } + .padding(horizontal = Dimens.SpacingMedium, vertical = Dimens.SpacingSmall), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(Dimens.IconSizeLarge) + .background(color, MaterialTheme.shapes.medium), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(Dimens.IconSizeNormal) + ) + } + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground + ) + } +} + +@Composable +private fun ShortcutsRow() { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { } + .padding(horizontal = Dimens.SpacingMedium, vertical = Dimens.SpacingSmall), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(Dimens.IconSizeLarge) + .background(MaterialTheme.colorScheme.secondary, MaterialTheme.shapes.medium), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondary, + modifier = Modifier.size(Dimens.IconSizeNormal) + ) + } + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + Column { + Text( + text = stringResource(Res.string.shortcuts_issues), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(Res.string.shortcuts_mentioned), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground + ) + } + } +} + +@Composable +private fun StarredRow(item: StarredItem) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { } + .padding(horizontal = Dimens.SpacingMedium, vertical = Dimens.SpacingSmall), + verticalAlignment = Alignment.CenterVertically + ) { + if (item.ownerAvatarUrl.isNotEmpty()) { + AsyncImage( + model = item.ownerAvatarUrl, + contentDescription = null, + modifier = Modifier + .size(Dimens.IconSizeAvatar) + .clip(CircleShape) + ) + } else { + Box( + modifier = Modifier + .size(Dimens.IconSizeAvatar) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(Dimens.IconSizeNormal) + ) + } + } + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + Column { + Text( + text = item.ownerLogin, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = item.name, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), + color = MaterialTheme.colorScheme.onBackground + ) + } + } +} + @Composable private fun RepoRow(item: RepoItem) { Row( @@ -307,3 +462,56 @@ private fun HomeDivider() { color = MaterialTheme.colorScheme.outlineVariant ) } + + + + + + +private fun relativeTime(iso: String): String { + return try { + // Fallback implementation without kotlinx.datetime since it fails to resolve properly in this environment + if (iso.length < 19) return "" + val now = io.ktor.util.date.getTimeMillis() + // Approximation of ISO parsing using basic math since kotlinx.datetime is not resolving: + val year = iso.substring(0, 4).toInt() + val month = iso.substring(5, 7).toInt() + val day = iso.substring(8, 10).toInt() + val hour = iso.substring(11, 13).toInt() + val min = iso.substring(14, 16).toInt() + val sec = iso.substring(17, 19).toInt() + + val daysInMonth = intArrayOf(0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + var totalDays = 0L + for (y in 1970 until year) { + totalDays += if (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)) 366 else 365 + } + val isLeap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) + for (m in 1 until month) { + totalDays += daysInMonth[m] + if (m == 2 && isLeap) totalDays++ + } + totalDays += (day - 1) + + val totalSeconds = (totalDays * 24L * 60L * 60L) + (hour * 60L * 60L) + (min * 60L) + sec + val millis = totalSeconds * 1000L + + val diff = now - millis + if (diff < 0) return "just now" + + val diffSeconds = diff / 1000 + val diffMinutes = diffSeconds / 60 + val diffHours = diffMinutes / 60 + val diffDays = diffHours / 24 + + when { + diffSeconds < 60 -> "${diffSeconds}s" + diffMinutes < 60 -> "${diffMinutes}m" + diffHours < 24 -> "${diffHours}h" + diffDays < 7 -> "${diffDays}d" + else -> "${diffDays / 7}w" + } + } catch (e: Exception) { + "" + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeUiState.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeUiState.kt index 1fe0a69..c78ddc0 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeUiState.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeUiState.kt @@ -6,7 +6,9 @@ sealed class HomeUiState { data class Success( val repos: List, val orgs: List, - val notifications: List + val notifications: List, + val starred: List, + val avatarUrl: String ) : HomeUiState() } @@ -36,3 +38,11 @@ data class NotificationItem( val isUnread: Boolean, val updatedAt: String ) + + +data class StarredItem( + val id: Long, + val name: String, + val ownerLogin: String, + val ownerAvatarUrl: String +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeViewModel.kt index 5ef6731..bb16749 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeViewModel.kt @@ -27,10 +27,14 @@ class HomeViewModel : ViewModel() { val reposDeferred = async { apiClient.getUserRepos() } val orgsDeferred = async { apiClient.getUserOrgs() } val notificationsDeferred = async { apiClient.getNotifications() } + val starredDeferred = async { apiClient.getStarredRepos(perPage = 5) } + val userDeferred = async { apiClient.getAuthenticatedUser() } val repos = reposDeferred.await() val orgs = orgsDeferred.await() val notifications = notificationsDeferred.await() + val starred = starredDeferred.await() + val user = userDeferred.await() if (repos.isFailure && orgs.isFailure && notifications.isFailure) { _uiState.value = HomeUiState.Error(repos.exceptionOrNull()?.message ?: "Failed to load data") @@ -38,6 +42,15 @@ class HomeViewModel : ViewModel() { } _uiState.value = HomeUiState.Success( + avatarUrl = user.getOrNull()?.avatarUrl ?: "", + starred = starred.getOrDefault(emptyList()).map { repo -> + StarredItem( + id = repo.id, + name = repo.name, + ownerLogin = repo.owner?.login ?: "", + ownerAvatarUrl = repo.owner?.avatarUrl ?: "" + ) + }, repos = repos.getOrDefault(emptyList()).map { repo -> RepoItem( id = repo.id, diff --git a/fix.py b/fix.py new file mode 100644 index 0000000..f4de3fd --- /dev/null +++ b/fix.py @@ -0,0 +1,13 @@ +import sys +import re + +# Wait, `UIViewLayoutRegion` is an iOS 17 framework thing from `SwiftUI` or `UIKit` probably. +# If I add `-framework SwiftUI` to `linkerOpts`? +with open('composeApp/build.gradle.kts', 'r') as f: + content = f.read() + +if "linkerOpts" not in content: + content = content.replace('isStatic = true', 'isStatic = true\n linkerOpts("-lsqlite3", "-framework", "SwiftUI", "-framework", "UIKit")') + +with open('composeApp/build.gradle.kts', 'w') as f: + f.write(content) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 13d8df3..ba575ef 100755 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ composeMultiplatform = "1.11.0" junit = "4.13.2" kotlin = "2.3.21" kotlinx-coroutines = "1.10.2" +kotlinx-datetime = "0.6.0" kotlinx-serialization = "1.7.3" coil3 = "3.2.0" ktor = "3.1.3" @@ -39,6 +40,7 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMul compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }