Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ kotlin {
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
linkerOpts("-lsqlite3", "-framework", "SwiftUI", "-framework", "UIKit")
}
}

Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions composeApp/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@
<!-- My Work items -->
<string name="my_work_repos">Top Repositories</string>
<string name="my_work_orgs">Organizations</string>
<string name="shortcuts_issues">Issues</string>
<string name="shortcuts_mentioned">Mentioned</string>
Comment on lines +79 to +80
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These string resources (shortcuts_issues and shortcuts_mentioned) appear to be duplicates of shortcut_issues and shortcut_mentioned defined later in the file (lines 90-91). Please remove the duplicates and use a consistent naming convention throughout the project.

<string name="no_starred_repos">No starred repositories</string>

<!-- Mock data - Favorites -->
<string name="fav_synapsesrc">synapseSRC</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
)

Expand Down
Original file line number Diff line number Diff line change
@@ -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.*
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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
)
}
}
}
Comment on lines +216 to +251
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The ShortcutsRow component hardcodes two distinct labels ("Issues" and "Mentioned") into a single row. This is inconsistent with the MyWorkRow pattern where each item is separate. If these are meant to be independent shortcuts, they should be implemented as individual rows or passed as data to a reusable component. Also, the clickable modifier has an empty lambda, which should at least have a TODO or be wired to an action.


@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(
Expand Down Expand Up @@ -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) {
""
}
}
Comment on lines +465 to +517
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The relativeTime function is currently unused in the codebase. Additionally, the manual implementation of date parsing and difference calculation is complex and error-prone (e.g., it doesn't account for timezones or varying month lengths correctly). Since kotlinx.datetime is already a project dependency and imported in this file, you should use it to simplify the logic. If this function is intended for future use, please ensure it is integrated; otherwise, it should be removed.

private fun relativeTime(iso: String): String {
    return try {
        val instant = Instant.parse(iso)
        val now = Clock.System.now()
        val diff = now - instant

        val seconds = diff.inWholeSeconds
        if (seconds < 0) return "just now"

        val minutes = diff.inWholeMinutes
        val hours = diff.inWholeHours
        val days = diff.inWholeDays

        when {
            seconds < 60 -> "${seconds}s"
            minutes < 60 -> "${minutes}m"
            hours < 24 -> "${hours}h"
            days < 7 -> "${days}d"
            else -> "${days / 7}w"
        }
    } catch (e: Exception) {
        ""
    }
}

Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ sealed class HomeUiState {
data class Success(
val repos: List<RepoItem>,
val orgs: List<OrgItem>,
val notifications: List<NotificationItem>
val notifications: List<NotificationItem>,
val starred: List<StarredItem>,
val avatarUrl: String
) : HomeUiState()
}

Expand Down Expand Up @@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,30 @@ 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")
return@launch
}

_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,
Expand Down
13 changes: 13 additions & 0 deletions fix.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading