diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 23497cf..f689e56 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,9 @@ on: push: branches: - master + - develop pull_request: + workflow_dispatch: jobs: android: diff --git a/AGENTS.md b/AGENTS.md index be63fa5..4cddbf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ composeApp/src/ ### UI Design - Follow **Material 3 Expressive** design guidelines — use expressive color roles, dynamic shapes, motion, and typography tokens from the M3 spec. - Use `MaterialTheme` tokens (`colorScheme`, `typography`, `shapes`) exclusively; never hardcode colors or dimensions. +- Theme must support **both light and dark** modes using `lightColorScheme()` and `darkColorScheme()`. Switch automatically with `isSystemInDarkTheme()`. Never force a single theme or override colors with hardcoded values (e.g. `Color.Black`). - Optimize layouts for all target platforms: - **Mobile (Android/iOS):** single-column, touch-friendly tap targets (≥48dp), bottom navigation. - **Desktop (JVM):** wider layouts with side navigation or rail, keyboard/mouse interactions, resizable windows. @@ -59,9 +60,19 @@ composeApp/src/ - Never use Android or iOS APIs directly in `commonMain`. Use `expect/actual`. - `androidMain` and `iosMain` contain only thin platform bridges. +## No Hardcoding Policy + +Strict rules apply to all UI implementations. **NO EXCEPTIONS**: +- All user-visible strings → `strings.xml` + `stringResource()` +- All colors → `MaterialTheme.colorScheme.*` only +- All dimensions/spacing → `Dimens` object constants (PascalCase) or `MaterialTheme` tokens; never inline magic numbers +- All typography → `MaterialTheme.typography.*` only +- All shapes → `MaterialTheme.shapes.*` only +- Follow M3 Expressive: use expressive color roles, shape morphing, and motion tokens where applicable + ## What Agents Should NOT Do - Do not modify `local.properties`. -- Do not push directly to `main` — open a PR. +- Do not push directly to `main` or `develop` — open a PR targeting `develop`. - Do not add new Gradle modules without discussion. - Do not introduce new networking libraries; use the existing HTTP client. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index d5131c7..6993a6a 100755 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.composeHotReload) + alias(libs.plugins.kotlinSerialization) } kotlin { @@ -52,6 +53,12 @@ kotlin { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) + implementation(libs.navigation.compose) + implementation(libs.kotlinx.serialization.json) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.auth) } } val commonTest by getting { @@ -63,6 +70,7 @@ kotlin { dependencies { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.activity.compose) + implementation(libs.ktor.client.android) } } val jvmMain by getting { @@ -73,6 +81,9 @@ kotlin { } val webMain by creating { dependsOn(commonMain) + dependencies { + implementation(libs.ktor.client.js) + } } val jsMain by getting { dependsOn(webMain) @@ -82,6 +93,9 @@ kotlin { } val iosMain by creating { dependsOn(commonMain) + dependencies { + implementation(libs.ktor.client.darwin) + } } val iosArm64Main by getting { dependsOn(iosMain) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 26403a7..de6216c 100755 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,6 +1,8 @@ + + + + GitHub + + + Back + Create + Share + Settings + Add + More options + ADD TO LIST + Star + Fork + Watch + Issues + Pull Requests + Discussions + Actions + Releases + Contributors + Watchers + Latest + Current branch + CHANGE + Code + Commits + README.md + EDIT + Open ▾ + Draft + Label ▾ + Author ▾ + Assignee ▾ + Search + Compare changes + Close + Choose branches + base: + main ▾ + compare: + SELECT BRANCH + Choose a branch to compare changes + ☆ %1$d stars + ⑂ %1$d fork + #%1$d + ✓ %1$s + 💬 %1$d + Coming soon + + + Home + Inbox + Explore + Copilot + Home + + + Search + Refresh + Create + User Avatar + More options + Repository + Organization + Issue + Pull Request + Favorite + + + My Work + Favorites + Shortcuts + Recent + + + Top Repositories + Organizations + + + synapseSRC + synapseApp + TheRealAshik + Jules + + + Issues + Mentioned + + + TheRealAshik/Jules + 2h + 1d + Fix bottom navigation rendering issue + You received a review + Add Compose Multiplatform to project + You opened this pull request + 3 + 12 + + + Inbox + Inbox ▾ + Focused + Unread + Repository ▾ + Mark as done + Unsubscribe + + + TheRealAshik / Jules #49 + 🦑 Jules, reporting for duty! + 👋 Jules, reporting for duty! I\'m here to help. + 2h + + TheRealAshik / ComposeApp #12 + Update Compose Multiplatform + Bumping to version 1.6.0 + 5h + + + Explore + Discover + Trending Repositories + Awesome Lists + Activity + Read more › + Filter activity + + + ashik + contributed to TheRealAshik/Jules + 2h + TheRealAshik/Jules + ✨ Feature: Add Inbox and Explore screens + Merged + feature/ui-screens + This PR adds the missing Inbox and Explore screens using Jetpack Compose. + + github-actions[bot] + published a release + 1d + v1.0.0-beta01 + + + Ashik Ahmed + TheRealAshik + he/him + Full-time student, part time android developer + 🎓 + Passionate software engineer building native and cross-platform apps. + Google Labs + Earth + 20 followers + 3 following + + + Ashik Ahmed + ashik.ahmed + @TheRealAshik + + + Popular + TheRealAshik + + + Repositories + Organizations + Starred + Projects + + + Repositories + TheRealAshik + + + All + Language + Sort: Recently pushed + + + Jules + A client of Jules (end-to-end coding agent by Google Labs) based on KMP + Kotlin + + awsome-pc + Awsome personal computer configuration combination for best value-for-money purchase. + + Projectivy-Launcher + Fork of Projectivy Launcher in Material 3 Design + Forked from spocky/miproja1 + + gh-actions + Shell + + TizenTubeCobalt + Experience TizenTube on other devices that are not Tizen. + Forked from reisxd/TizenTubeCobalt + + + Settings + Notifications + Notification Options + General + Theme + Follow system + Code Options + Language + Accounts + App Lock + Subscriptions + Copilot + Copilot Free + More Options + Share Feedback + Get Help + Terms of Service + Privacy Policy & Analytics + Open Source Libraries + Sign Out + GitHub Mobile v1.258.0-beta (10323) + + + Notifications + General + System Options + Configure notifications for this device + Working hours + Allow push notifications every day from 9:00 am to 5:00 pm + Push Notifications Types + Direct Mentions + Review Requested + Assigned + Deployment Review + Pull Request Review + Workflow Runs + Failed Workflows Only + Live notifications + Live Agent Updates + Get live updates for your background tasks + Swipe Options + Left swipe + Mark as done + Right swipe + Unsubscribe + CHANGE + + + Code Options + Scrollable File Path + Show line numbers + Always use dark theme + Override system font size + Wrap lines + Preview + + + Accounts + EDIT + + ADD ACCOUNT + + + Add Account + Enter a GitHub Personal Access Token (PAT) with the required scopes to authenticate. + Personal Access Token + ghp_xxxxxxxxxxxxxxxxxxxx + Save Token + + + main.kt + fun main() {\n println("Hello, World!")\n} + + + Back + Close + Collapse + Copy + More + System Settings + Swipe Preview Icon + Active account + + fun + main() {\n + println( + "Hello, World!" + )\n} + Retry + %1$d followers · %2$d following + GitHub Logo + GitHub + diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/App.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/App.kt index ab83fe5..feab061 100755 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/App.kt @@ -1,29 +1,78 @@ package dev.therealashik.github -import androidx.compose.animation.Crossfade +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.graphics.Color +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import dev.therealashik.github.profile.ProfileScreen +import dev.therealashik.github.profile.ProfileViewModel +import dev.therealashik.github.repository.RepositoryListScreen +import dev.therealashik.github.repository.RepositoryListViewModel +import dev.therealashik.github.settings.AddPatScreen +import dev.therealashik.github.settings.CodeOptionsScreen +import dev.therealashik.github.settings.NotificationOptionsScreen +import dev.therealashik.github.settings.SettingsScreen + +private val LightColorScheme = lightColorScheme() +private val DarkColorScheme = darkColorScheme() @Composable fun App() { - MaterialTheme( - colorScheme = darkColorScheme( - surface = Color.Black, - background = Color.Black - ) - ) { - var currentScreen by remember { mutableStateOf("splash") } + val colorScheme = if (isSystemInDarkTheme()) DarkColorScheme else LightColorScheme + + MaterialTheme(colorScheme = colorScheme) { + val navController = rememberNavController() - Crossfade(targetState = currentScreen) { screen -> - when (screen) { - "splash" -> SplashScreen(onSplashFinished = { currentScreen = "main" }) - "main" -> MainScreen() + NavHost(navController = navController, startDestination = Route.Splash) { + composable { + SplashScreen(onSplashFinished = { + navController.navigate(Route.Main) { + popUpTo(Route.Splash) { inclusive = true } + } + }) + } + composable { + MainScreen( + onNavigateToProfile = { navController.navigate(Route.Profile) } + ) + } + composable { + ProfileScreen( + viewModel = ProfileViewModel(), + onBack = { navController.popBackStack() }, + onNavigateToRepositories = { navController.navigate(Route.Repositories) }, + onNavigateToSettings = { navController.navigate(Route.Settings) } + ) + } + composable { + RepositoryListScreen( + viewModel = RepositoryListViewModel(), + onBack = { navController.popBackStack() } + ) + } + composable { + SettingsScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToNotificationOptions = { navController.navigate(Route.NotificationOptions) }, + onNavigateToCodeOptions = { navController.navigate(Route.CodeOptions) }, + onNavigateToAddPat = { navController.navigate(Route.AddPat) } + ) + } + composable { + NotificationOptionsScreen(onNavigateBack = { navController.popBackStack() }) + } + composable { + CodeOptionsScreen(onNavigateBack = { navController.popBackStack() }) + } + composable { + AddPatScreen( + onNavigateBack = { navController.popBackStack() }, + onSuccess = { navController.popBackStack() } + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/Dimens.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/Dimens.kt new file mode 100644 index 0000000..a6017b1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/Dimens.kt @@ -0,0 +1,39 @@ +package dev.therealashik.github + +import androidx.compose.ui.unit.dp + +object Dimens { + val SpacingNano = 2.dp + val SpacingMicro = 4.dp + val SpacingExtraSmall = 4.dp + val SpacingSmall = 8.dp + val SpacingMediumSmall = 12.dp + val SpacingMedium = 16.dp + val SpacingMediumLarge = 12.dp + val SpacingLarge = 24.dp + val SpacingExtraLarge = 24.dp + val SpacingExtraExtraLarge = 32.dp + val SpacingHuge = 48.dp + val SpacingMassive = 56.dp + + val IconSizeExtraSmall = 10.dp + val IconSizeSmall = 16.dp + val IconSizeNormal = 20.dp + val IconSizeMedium = 24.dp + val IconSizeLarge = 32.dp + val IconSizeExtraLarge = 40.dp + val IconSizeHuge = 48.dp + + val HandleWidth = 32.dp + val HandleHeight = 4.dp + + val AvatarSize = 72.dp + val AvatarSmall = 20.dp + + val IndicatorSize = 10.dp + val CardWidth = 280.dp + val BorderWidth = 1.dp + val BorderWidthThin = 0.5.dp + val BadgeHeight = 18.dp + val BadgePaddingHorizontal = 6.dp +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/MainScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/MainScreen.kt index 91d0d53..b642d4a 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/MainScreen.kt @@ -4,6 +4,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.Email import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Person @@ -20,12 +24,33 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import dev.therealashik.github.explore.ExploreScreen +import dev.therealashik.github.home.HomeScreen +import dev.therealashik.github.inbox.InboxScreen +import github.composeapp.generated.resources.Res +import github.composeapp.generated.resources.coming_soon +import github.composeapp.generated.resources.tab_copilot +import github.composeapp.generated.resources.tab_explore +import github.composeapp.generated.resources.tab_home +import github.composeapp.generated.resources.tab_inbox +import org.jetbrains.compose.resources.stringResource @Composable -fun MainScreen() { +fun MainScreen(onNavigateToProfile: () -> Unit = {}) { var selectedTab by remember { mutableStateOf(0) } - val tabs = listOf("Home", "Inbox", "Explore", "Copilot") - val icons = listOf( + val tabs = listOf( + Res.string.tab_home, + Res.string.tab_inbox, + Res.string.tab_explore, + Res.string.tab_copilot + ) + val selectedIcons = listOf( + Icons.Filled.Home, + Icons.Filled.Email, + Icons.Filled.Search, + Icons.Filled.Person + ) + val unselectedIcons = listOf( Icons.Outlined.Home, Icons.Outlined.Email, Icons.Outlined.Search, @@ -35,12 +60,20 @@ fun MainScreen() { Scaffold( bottomBar = { NavigationBar { - tabs.forEachIndexed { index, title -> + tabs.forEachIndexed { index, titleRes -> NavigationBarItem( selected = selectedTab == index, - onClick = { selectedTab = index }, - icon = { Icon(icons[index], contentDescription = title) }, - label = { Text(title) } + onClick = { + if (index == 3) onNavigateToProfile() + else selectedTab = index + }, + icon = { + Icon( + imageVector = if (selectedTab == index) selectedIcons[index] else unselectedIcons[index], + contentDescription = stringResource(titleRes) + ) + }, + label = { Text(stringResource(titleRes)) } ) } } @@ -49,10 +82,19 @@ fun MainScreen() { Box( modifier = Modifier .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center + .padding(innerPadding) ) { - Text("Coming soon") + when (selectedTab) { + 0 -> HomeScreen(onNavigateToProfile = onNavigateToProfile) + 1 -> InboxScreen() + 2 -> ExploreScreen() + else -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(stringResource(Res.string.coming_soon)) + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/NavRoutes.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/NavRoutes.kt new file mode 100644 index 0000000..caa8988 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/NavRoutes.kt @@ -0,0 +1,14 @@ +package dev.therealashik.github + +import kotlinx.serialization.Serializable + +sealed interface Route { + @Serializable data object Splash : Route + @Serializable data object Main : Route + @Serializable data object Profile : Route + @Serializable data object Repositories : Route + @Serializable data object Settings : Route + @Serializable data object NotificationOptions : Route + @Serializable data object CodeOptions : Route + @Serializable data object AddPat : Route +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/SplashScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/SplashScreen.kt index d0fd392..e4656e6 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/SplashScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/SplashScreen.kt @@ -25,8 +25,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import github.composeapp.generated.resources.Res import github.composeapp.generated.resources.compose_multiplatform +import github.composeapp.generated.resources.* import kotlinx.coroutines.delay import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource @Composable fun SplashScreen(onSplashFinished: () -> Unit) { @@ -55,12 +57,12 @@ fun SplashScreen(onSplashFinished: () -> Unit) { ) { Image( painter = painterResource(Res.drawable.compose_multiplatform), - contentDescription = "GitHub Logo", + contentDescription = stringResource(Res.string.cd_github_logo), modifier = Modifier.size(120.dp) ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = "GitHub", + text = stringResource(Res.string.app_name_display), style = MaterialTheme.typography.displayMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/data/GitHubApiClient.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/data/GitHubApiClient.kt new file mode 100644 index 0000000..daa2e6a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/data/GitHubApiClient.kt @@ -0,0 +1,111 @@ +package dev.therealashik.github.data + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class GitHubUser( + val login: String, + val name: String? = null, + @SerialName("avatar_url") val avatarUrl: String, + val bio: String? = null, + val followers: Int = 0, + val following: Int = 0, + @SerialName("public_repos") val publicRepos: Int = 0 +) + +@Serializable +data class GitHubRepo( + val id: Long, + val name: String, + @SerialName("full_name") val fullName: String, + val description: String? = null, + val private: Boolean = false, + @SerialName("stargazers_count") val stars: Int = 0, + val language: String? = null, + @SerialName("updated_at") val updatedAt: String? = null +) + +@Serializable +data class GitHubOrg( + val id: Long, + val login: String, + @SerialName("avatar_url") val avatarUrl: String, + val description: String? = null +) + +@Serializable +data class GitHubNotification( + val id: String, + val unread: Boolean, + val reason: String, + @SerialName("updated_at") val updatedAt: String, + val subject: NotificationSubject, + val repository: NotificationRepo +) + +@Serializable +data class NotificationSubject( + val title: String, + val type: String, + val url: String? = null +) + +@Serializable +data class NotificationRepo( + @SerialName("full_name") val fullName: String +) + +class GitHubApiClient(private val tokenStorage: TokenStorage) { + + private val client = HttpClient { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + } + + private fun HttpRequestBuilder.withAuth() { + tokenStorage.getToken()?.let { header(HttpHeaders.Authorization, "Bearer $it") } + header(HttpHeaders.Accept, "application/vnd.github+json") + header("X-GitHub-Api-Version", "2022-11-28") + } + + suspend fun getAuthenticatedUser(): Result = runCatching { + client.get("https://api.github.com/user") { withAuth() }.body() + } + + suspend fun getUserRepos(perPage: Int = 10): Result> = runCatching { + client.get("https://api.github.com/user/repos") { + withAuth() + parameter("sort", "updated") + parameter("per_page", perPage) + }.body() + } + + suspend fun getUserOrgs(): Result> = runCatching { + client.get("https://api.github.com/user/orgs") { withAuth() }.body() + } + + suspend fun getNotifications(perPage: Int = 20): Result> = runCatching { + client.get("https://api.github.com/notifications") { + withAuth() + parameter("per_page", perPage) + }.body() + } + + suspend fun getStarredRepos(perPage: Int = 5): Result> = runCatching { + client.get("https://api.github.com/user/starred") { + withAuth() + parameter("per_page", perPage) + }.body() + } + + fun close() = client.close() +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/data/TokenStorage.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/data/TokenStorage.kt new file mode 100644 index 0000000..fd97334 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/data/TokenStorage.kt @@ -0,0 +1,9 @@ +package dev.therealashik.github.data + +interface TokenStorage { + fun saveToken(token: String) + fun getToken(): String? + fun clearToken() +} + +expect fun createTokenStorage(): TokenStorage diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreScreen.kt new file mode 100644 index 0000000..2a3aec8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreScreen.kt @@ -0,0 +1,439 @@ +package dev.therealashik.github.explore + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.items +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material.icons.outlined.LocalFireDepartment +import androidx.compose.material.icons.outlined.ThumbUp +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.lifecycle.viewmodel.compose.viewModel +import dev.therealashik.github.theme.Dimensions +import github.composeapp.generated.resources.Res +import github.composeapp.generated.resources.activity_section +import github.composeapp.generated.resources.awesome_lists +import github.composeapp.generated.resources.discover_section +import github.composeapp.generated.resources.explore_title +import github.composeapp.generated.resources.filter_activity +import github.composeapp.generated.resources.read_more +import github.composeapp.generated.resources.trending_repos +import org.jetbrains.compose.resources.stringResource + +@Composable +fun ExploreScreen(viewModel: ExploreViewModel = viewModel { ExploreViewModel() }) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + topBar = { ExploreTopBar() } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .background(MaterialTheme.colorScheme.background) + ) { + item { + DiscoverSection() + } + item { + HorizontalDivider( + modifier = Modifier.padding(vertical = Dimensions.PaddingSmall), + color = MaterialTheme.colorScheme.surfaceVariant + ) + } + item { + ActivitySectionHeader() + } + items( + items = uiState.activityFeed, + key = { it.id } + ) { item -> + when (item) { + is ContributionItem -> ContributionCard(item) + is ReleaseItem -> ReleaseCard(item) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ExploreTopBar() { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.explore_title), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) +} + +@Composable +private fun DiscoverSection() { + Column(modifier = Modifier.padding(Dimensions.PaddingMedium)) { + Text( + text = stringResource(Res.string.discover_section), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = Dimensions.PaddingMedium) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Dimensions.PaddingSmall), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(Dimensions.IconSizeLarge) + .clip(RoundedCornerShape(Dimensions.PaddingSmall)) + .background(MaterialTheme.colorScheme.error), // Flame + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.LocalFireDepartment, + contentDescription = null, + tint = MaterialTheme.colorScheme.onError + ) + } + Spacer(modifier = Modifier.width(Dimensions.SpacingMedium)) + Text( + text = stringResource(Res.string.trending_repos), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Dimensions.PaddingSmall), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(Dimensions.IconSizeLarge) + .clip(RoundedCornerShape(Dimensions.PaddingSmall)) + .background(MaterialTheme.colorScheme.tertiary), // Purple-ish + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.List, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiary + ) + } + Spacer(modifier = Modifier.width(Dimensions.SpacingMedium)) + Text( + text = stringResource(Res.string.awesome_lists), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} + +@Composable +private fun ActivitySectionHeader() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.PaddingMedium, vertical = Dimensions.PaddingSmall), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(Res.string.activity_section), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.Outlined.FilterList, + contentDescription = stringResource(Res.string.filter_activity), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun ContributionCard(item: ContributionItem) { + var expanded by remember { mutableStateOf(false) } + + Column(modifier = Modifier.padding(Dimensions.PaddingMedium)) { + // Header + Row( + modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(Dimensions.AvatarSizeMedium) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(Dimensions.IconSizeMedium) + ) + } + Spacer(modifier = Modifier.width(Dimensions.SpacingSmall)) + Text( + text = stringResource(item.username), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(Dimensions.SpacingExtraSmall)) + Text( + text = stringResource(item.actionText), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(item.timestamp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(Dimensions.SpacingSmall)) + + AnimatedVisibility(visible = expanded) { + // Card Content + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimensions.PaddingMedium)) + .border(Dimensions.ThinBorderWidth, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(Dimensions.PaddingMedium)) + .background(MaterialTheme.colorScheme.surface) + .padding(Dimensions.PaddingMedium) + ) { + Column { + Text( + text = stringResource(item.repoPath), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(Dimensions.SpacingExtraSmall)) + Text( + text = stringResource(item.prTitle), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(Dimensions.SpacingSmall)) + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.SpacingSmall), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(Dimensions.PaddingSmall)) + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(horizontal = Dimensions.PaddingSmall, vertical = Dimensions.PaddingExtraSmall) + ) { + Text( + text = stringResource(item.statusText), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + Box( + modifier = Modifier + .clip(RoundedCornerShape(Dimensions.PaddingSmall)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = Dimensions.PaddingSmall, vertical = Dimensions.PaddingExtraSmall) + ) { + Text( + text = stringResource(item.branchName), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Spacer(modifier = Modifier.height(Dimensions.SpacingSmall)) + Text( + text = stringResource(item.bodyPreview), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(Dimensions.SpacingSmall)) + TextButton( + onClick = { /* TODO */ }, + modifier = Modifier.padding(Dimensions.Zero) + ) { + Text( + text = stringResource(Res.string.read_more), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } + Spacer(modifier = Modifier.height(Dimensions.SpacingSmall)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimensions.SpacingMedium), + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Outlined.ThumbUp, + contentDescription = null, + modifier = Modifier.size(Dimensions.IconSizeSmall), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(Dimensions.PaddingExtraSmall)) + Text( + text = "1", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Outlined.ChatBubbleOutline, + contentDescription = null, + modifier = Modifier.size(Dimensions.IconSizeSmall), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(Dimensions.PaddingExtraSmall)) + Text( + text = "2", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } +} + +@Composable +private fun ReleaseCard(item: ReleaseItem) { + Column(modifier = Modifier.padding(Dimensions.PaddingMedium)) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(Dimensions.AvatarSizeMedium) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.SmartToy, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(Dimensions.IconSizeMedium) + ) + } + Spacer(modifier = Modifier.width(Dimensions.SpacingSmall)) + Text( + text = stringResource(item.botName), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(Dimensions.SpacingExtraSmall)) + Text( + text = stringResource(item.actionText), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(item.timestamp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(Dimensions.SpacingSmall)) + + // Card Content + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimensions.PaddingMedium)) + .border(Dimensions.ThinBorderWidth, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(Dimensions.PaddingMedium)) + .background(MaterialTheme.colorScheme.surface) + ) { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .height(Dimensions.ReleaseBannerHeight) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(item.releaseTitle), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreUiState.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreUiState.kt new file mode 100644 index 0000000..488bb8b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreUiState.kt @@ -0,0 +1,31 @@ +package dev.therealashik.github.explore + +import org.jetbrains.compose.resources.StringResource + +sealed class ActivityItem { + abstract val id: String +} + +data class ContributionItem( + override val id: String, + val username: StringResource, + val actionText: StringResource, + val timestamp: StringResource, + val repoPath: StringResource, + val prTitle: StringResource, + val statusText: StringResource, + val branchName: StringResource, + val bodyPreview: StringResource +) : ActivityItem() + +data class ReleaseItem( + override val id: String, + val botName: StringResource, + val actionText: StringResource, + val timestamp: StringResource, + val releaseTitle: StringResource +) : ActivityItem() + +data class ExploreUiState( + val activityFeed: List = emptyList() +) diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreViewModel.kt new file mode 100644 index 0000000..c8dedf9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreViewModel.kt @@ -0,0 +1,53 @@ +package dev.therealashik.github.explore + +import androidx.lifecycle.ViewModel +import github.composeapp.generated.resources.Res +import github.composeapp.generated.resources.explore_mock_user_1 +import github.composeapp.generated.resources.explore_mock_action_1 +import github.composeapp.generated.resources.explore_mock_time_1 +import github.composeapp.generated.resources.explore_mock_repo_1 +import github.composeapp.generated.resources.explore_mock_pr_title_1 +import github.composeapp.generated.resources.explore_mock_status_merged +import github.composeapp.generated.resources.explore_mock_branch_1 +import github.composeapp.generated.resources.explore_mock_pr_body_1 +import github.composeapp.generated.resources.explore_mock_bot +import github.composeapp.generated.resources.explore_mock_action_release +import github.composeapp.generated.resources.explore_mock_time_release +import github.composeapp.generated.resources.explore_mock_release_title +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class ExploreViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(ExploreUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadMocks() + } + + private fun loadMocks() { + val mocks = listOf( + ContributionItem( + id = "1", + username = Res.string.explore_mock_user_1, + actionText = Res.string.explore_mock_action_1, + timestamp = Res.string.explore_mock_time_1, + repoPath = Res.string.explore_mock_repo_1, + prTitle = Res.string.explore_mock_pr_title_1, + statusText = Res.string.explore_mock_status_merged, + branchName = Res.string.explore_mock_branch_1, + bodyPreview = Res.string.explore_mock_pr_body_1 + ), + ReleaseItem( + id = "2", + botName = Res.string.explore_mock_bot, + actionText = Res.string.explore_mock_action_release, + timestamp = Res.string.explore_mock_time_release, + releaseTitle = Res.string.explore_mock_release_title + ) + ) + _uiState.value = ExploreUiState(activityFeed = mocks) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/Dimens.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/Dimens.kt new file mode 100644 index 0000000..6ce7a12 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/Dimens.kt @@ -0,0 +1,26 @@ +package dev.therealashik.github.home + +import androidx.compose.ui.unit.dp + +object Dimens { + val SpacingNano = 4.dp + val SpacingMicro = 8.dp + val SpacingSmall = 12.dp + val SpacingMedium = 16.dp + val SpacingLarge = 24.dp + val SpacingExtraLarge = 32.dp + + val IconSizeSmall = 16.dp + val IconSizeNormal = 24.dp + val IconSizeLarge = 32.dp + val IconSizeAvatar = 40.dp + val IconSizeHeader = 48.dp + + val BorderWidthThin = 1.dp + val CornerRadiusSmall = 4.dp + val CornerRadiusMedium = 8.dp + val CornerRadiusLarge = 12.dp + + val BadgeHeight = 20.dp + val BadgePaddingHorizontal = 6.dp +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeScreen.kt new file mode 100644 index 0000000..f0d7de3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeScreen.kt @@ -0,0 +1,309 @@ +package dev.therealashik.github.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import org.jetbrains.compose.resources.stringResource +import github.composeapp.generated.resources.Res +import github.composeapp.generated.resources.* + +@Composable +fun HomeScreen( + viewModel: HomeViewModel = androidx.lifecycle.viewmodel.compose.viewModel { HomeViewModel() }, + onNavigateToProfile: () -> Unit = {} +) { + val state by viewModel.uiState.collectAsState() + HomeScreenContent(state = state, onRetry = viewModel::loadData, onNavigateToProfile = onNavigateToProfile) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreenContent(state: HomeUiState, onRetry: () -> Unit = {}, onNavigateToProfile: () -> Unit = {}) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.title_home), + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold) + ) + }, + actions = { + IconButton(onClick = { }) { + Icon(Icons.Outlined.Search, contentDescription = stringResource(Res.string.cd_search)) + } + IconButton(onClick = onRetry) { + Icon(Icons.Outlined.Refresh, contentDescription = stringResource(Res.string.cd_refresh)) + } + IconButton(onClick = { }) { + 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) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background), + windowInsets = WindowInsets(0) + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { innerPadding -> + when (state) { + is HomeUiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center + ) { CircularProgressIndicator() } + } + is HomeUiState.Error -> { + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = state.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(Dimens.SpacingMedium)) + Button(onClick = onRetry) { Text(stringResource(Res.string.retry)) } + } + } + } + is HomeUiState.Success -> { + LazyColumn(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + // Repositories + item { SectionHeader(title = stringResource(Res.string.section_my_work)) } + items(state.repos) { RepoRow(it) } + item { HomeDivider() } + + // Organizations + item { SectionHeader(title = "Organizations") } + if (state.orgs.isEmpty()) { + item { EmptyHint("No organizations") } + } else { + items(state.orgs) { OrgRow(it) } + } + item { HomeDivider() } + + // Notifications + item { SectionHeader(title = stringResource(Res.string.section_recent)) } + if (state.notifications.isEmpty()) { + item { EmptyHint("No notifications") } + } else { + items(state.notifications) { NotificationRow(it) } + } + } + } + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimens.SpacingMedium, vertical = Dimens.SpacingSmall), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground + ) + IconButton(onClick = { }, modifier = Modifier.size(Dimens.IconSizeNormal)) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(Res.string.cd_overflow), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun RepoRow(item: RepoItem) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimens.SpacingMedium, vertical = Dimens.SpacingSmall), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(Dimens.IconSizeLarge) + .background( + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.shapes.small + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (item.isPrivate) Icons.Outlined.Lock else Icons.Outlined.AccountBox, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(Dimens.IconSizeSmall) + ) + } + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.fullName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground + ) + item.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + } + if (item.stars > 0) { + Text( + text = "★ ${item.stars}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun OrgRow(item: OrgItem) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimens.SpacingMedium, vertical = Dimens.SpacingSmall), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(Dimens.IconSizeLarge) + .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.login, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground + ) + item.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + } + } +} + +@Composable +private fun NotificationRow(item: NotificationItem) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimens.SpacingMedium, vertical = Dimens.SpacingMedium), + verticalAlignment = Alignment.Top + ) { + Box(modifier = Modifier.size(Dimens.IconSizeNormal)) { + Icon( + imageVector = when (item.type) { + "PullRequest" -> Icons.Outlined.AccountBox + "Issue" -> Icons.Outlined.Warning + else -> Icons.Outlined.Notifications + }, + contentDescription = null, + tint = if (item.isUnread) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + if (item.isUnread) { + Box( + modifier = Modifier + .size(Dimens.SpacingMicro) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .align(Alignment.TopEnd) + ) + } + } + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.repoFullName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(Dimens.SpacingNano)) + Text( + text = item.title, + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = if (item.isUnread) FontWeight.Bold else FontWeight.Normal + ), + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(Dimens.SpacingNano)) + Text( + text = item.type, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun EmptyHint(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Dimens.SpacingMedium, vertical = Dimens.SpacingSmall) + ) +} + +@Composable +private fun HomeDivider() { + HorizontalDivider( + modifier = Modifier.padding(start = Dimens.SpacingExtraLarge + Dimens.SpacingMedium), + thickness = Dimens.BorderWidthThin, + color = MaterialTheme.colorScheme.outlineVariant + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeUiState.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeUiState.kt new file mode 100644 index 0000000..1fe0a69 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeUiState.kt @@ -0,0 +1,38 @@ +package dev.therealashik.github.home + +sealed class HomeUiState { + data object Loading : HomeUiState() + data class Error(val message: String) : HomeUiState() + data class Success( + val repos: List, + val orgs: List, + val notifications: List + ) : HomeUiState() +} + +data class RepoItem( + val id: Long, + val name: String, + val fullName: String, + val description: String?, + val language: String?, + val stars: Int, + val isPrivate: Boolean, + val updatedAt: String? +) + +data class OrgItem( + val id: Long, + val login: String, + val avatarUrl: String, + val description: String? +) + +data class NotificationItem( + val id: String, + val repoFullName: String, + val title: String, + val type: String, + val isUnread: Boolean, + val updatedAt: String +) diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeViewModel.kt new file mode 100644 index 0000000..5ef6731 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/home/HomeViewModel.kt @@ -0,0 +1,79 @@ +package dev.therealashik.github.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.therealashik.github.data.GitHubApiClient +import dev.therealashik.github.data.createTokenStorage +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class HomeViewModel : ViewModel() { + + private val apiClient = GitHubApiClient(createTokenStorage()) + + private val _uiState = MutableStateFlow(HomeUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadData() + } + + fun loadData() { + viewModelScope.launch { + _uiState.value = HomeUiState.Loading + val reposDeferred = async { apiClient.getUserRepos() } + val orgsDeferred = async { apiClient.getUserOrgs() } + val notificationsDeferred = async { apiClient.getNotifications() } + + val repos = reposDeferred.await() + val orgs = orgsDeferred.await() + val notifications = notificationsDeferred.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( + repos = repos.getOrDefault(emptyList()).map { repo -> + RepoItem( + id = repo.id, + name = repo.name, + fullName = repo.fullName, + description = repo.description, + language = repo.language, + stars = repo.stars, + isPrivate = repo.private, + updatedAt = repo.updatedAt + ) + }, + orgs = orgs.getOrDefault(emptyList()).map { org -> + OrgItem( + id = org.id, + login = org.login, + avatarUrl = org.avatarUrl, + description = org.description + ) + }, + notifications = notifications.getOrDefault(emptyList()).map { n -> + NotificationItem( + id = n.id, + repoFullName = n.repository.fullName, + title = n.subject.title, + type = n.subject.type, + isUnread = n.unread, + updatedAt = n.updatedAt + ) + } + ) + } + } + + override fun onCleared() { + super.onCleared() + apiClient.close() + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/inbox/InboxScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/inbox/InboxScreen.kt new file mode 100644 index 0000000..bca6d69 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/inbox/InboxScreen.kt @@ -0,0 +1,313 @@ +package dev.therealashik.github.inbox + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.AltRoute +import androidx.compose.material.icons.outlined.LocalOffer +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.lifecycle.viewmodel.compose.viewModel +import dev.therealashik.github.theme.Dimensions +import github.composeapp.generated.resources.Res +import github.composeapp.generated.resources.filter_focused +import github.composeapp.generated.resources.filter_inbox +import github.composeapp.generated.resources.filter_repository +import github.composeapp.generated.resources.filter_unread +import github.composeapp.generated.resources.inbox_title +import github.composeapp.generated.resources.more_options +import github.composeapp.generated.resources.swipe_mark_done +import github.composeapp.generated.resources.swipe_unsubscribe +import org.jetbrains.compose.resources.stringResource + +@Composable +fun InboxScreen(viewModel: InboxViewModel = viewModel { InboxViewModel() }) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + topBar = { InboxTopBar() } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + FilterChipsRow() + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items( + items = uiState.notifications, + key = { it.id } + ) { notification -> + NotificationSwipeItem( + item = notification, + onMarkDone = { viewModel.markAsDone(notification.id) }, + onUnsubscribe = { viewModel.unsubscribe(notification.id) } + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InboxTopBar() { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.inbox_title), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold + ) + }, + actions = { + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(Res.string.more_options) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) +} + +@Composable +private fun FilterChipsRow() { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.PaddingMedium, vertical = Dimensions.PaddingSmall), + horizontalArrangement = Arrangement.spacedBy(Dimensions.SpacingSmall) + ) { + item { + FilterChip( + selected = true, + onClick = { /* TODO */ }, + label = { Text(stringResource(Res.string.filter_inbox)) } + ) + } + item { + FilterChip( + selected = false, + onClick = { /* TODO */ }, + label = { Text(stringResource(Res.string.filter_focused)) } + ) + } + item { + FilterChip( + selected = false, + onClick = { /* TODO */ }, + label = { Text(stringResource(Res.string.filter_unread)) } + ) + } + item { + FilterChip( + selected = false, + onClick = { /* TODO */ }, + label = { Text(stringResource(Res.string.filter_repository)) } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NotificationSwipeItem( + item: NotificationItem, + onMarkDone: () -> Unit, + onUnsubscribe: () -> Unit +) { + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { dismissValue -> + when (dismissValue) { + SwipeToDismissBoxValue.StartToEnd -> { + onUnsubscribe() + true + } + SwipeToDismissBoxValue.EndToStart -> { + onMarkDone() + true + } + SwipeToDismissBoxValue.Settled -> false + } + } + ) + + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + val color = when (dismissState.dismissDirection) { + SwipeToDismissBoxValue.StartToEnd -> MaterialTheme.colorScheme.errorContainer + SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.primaryContainer + SwipeToDismissBoxValue.Settled -> MaterialTheme.colorScheme.background + } + val alignment = when (dismissState.dismissDirection) { + SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart + SwipeToDismissBoxValue.EndToStart -> Alignment.CenterEnd + SwipeToDismissBoxValue.Settled -> Alignment.Center + } + val icon = when (dismissState.dismissDirection) { + SwipeToDismissBoxValue.StartToEnd -> Icons.Outlined.NotificationsOff + SwipeToDismissBoxValue.EndToStart -> Icons.Default.Check + SwipeToDismissBoxValue.Settled -> Icons.Default.Check + } + val textRes = when (dismissState.dismissDirection) { + SwipeToDismissBoxValue.StartToEnd -> Res.string.swipe_unsubscribe + SwipeToDismissBoxValue.EndToStart -> Res.string.swipe_mark_done + SwipeToDismissBoxValue.Settled -> Res.string.swipe_mark_done + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(color) + .padding(horizontal = Dimensions.PaddingLarge), + contentAlignment = alignment + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd) { + Icon(icon, contentDescription = null) + Spacer(Modifier.width(Dimensions.SpacingSmall)) + Text(stringResource(textRes)) + } else if (dismissState.dismissDirection == SwipeToDismissBoxValue.EndToStart) { + Text(stringResource(textRes)) + Spacer(Modifier.width(Dimensions.SpacingSmall)) + Icon(icon, contentDescription = null) + } + } + } + }, + content = { + NotificationRow(item) + } + ) +} + +@Composable +private fun NotificationRow(item: NotificationItem) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(Dimensions.PaddingMedium), + verticalAlignment = Alignment.Top + ) { + // Left: Icon + Unread Dot + Box(modifier = Modifier.padding(end = Dimensions.PaddingMedium)) { + val icon = if (item.type == NotificationType.PULL_REQUEST) { + Icons.Outlined.AltRoute + } else { + Icons.Outlined.LocalOffer + } + val tint = if (item.type == NotificationType.PULL_REQUEST) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.secondary + } + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(Dimensions.IconSizeMedium) + ) + if (item.isUnread) { + Box( + modifier = Modifier + .size(Dimensions.IndicatorDotSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .align(Alignment.TopEnd) + ) + } + } + + // Center: Content + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(item.repoPath), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(item.timestamp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = stringResource(item.title), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + item.subtitle?.let { + Text( + text = stringResource(it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Right: Comment Count Badge + if (item.commentCount != null) { + Box( + modifier = Modifier + .padding(start = Dimensions.PaddingSmall) + .clip(RoundedCornerShape(Dimensions.PaddingSmall)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = Dimensions.PaddingSmall, vertical = Dimensions.PaddingExtraSmall) + ) { + Text( + text = item.commentCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/inbox/InboxUiState.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/inbox/InboxUiState.kt new file mode 100644 index 0000000..599a71d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/inbox/InboxUiState.kt @@ -0,0 +1,23 @@ +package dev.therealashik.github.inbox + +import org.jetbrains.compose.resources.StringResource + +enum class NotificationType { + PULL_REQUEST, + RELEASE +} + +data class NotificationItem( + val id: String, + val type: NotificationType, + val isUnread: Boolean, + val repoPath: StringResource, + val timestamp: StringResource, + val title: StringResource, + val subtitle: StringResource?, + val commentCount: Int? = null +) + +data class InboxUiState( + val notifications: List = emptyList() +) diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/inbox/InboxViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/inbox/InboxViewModel.kt new file mode 100644 index 0000000..f6aad16 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/inbox/InboxViewModel.kt @@ -0,0 +1,63 @@ +package dev.therealashik.github.inbox + +import androidx.lifecycle.ViewModel +import github.composeapp.generated.resources.Res +import github.composeapp.generated.resources.inbox_mock_repo_1 +import github.composeapp.generated.resources.inbox_mock_title_1 +import github.composeapp.generated.resources.inbox_mock_subtitle_1 +import github.composeapp.generated.resources.inbox_mock_time_1 +import github.composeapp.generated.resources.inbox_mock_repo_2 +import github.composeapp.generated.resources.inbox_mock_title_2 +import github.composeapp.generated.resources.inbox_mock_subtitle_2 +import github.composeapp.generated.resources.inbox_mock_time_2 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class InboxViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(InboxUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadMocks() + } + + private fun loadMocks() { + val mocks = listOf( + NotificationItem( + id = "1", + type = NotificationType.PULL_REQUEST, + isUnread = true, + repoPath = Res.string.inbox_mock_repo_1, + timestamp = Res.string.inbox_mock_time_1, + title = Res.string.inbox_mock_title_1, + subtitle = Res.string.inbox_mock_subtitle_1, + commentCount = 2 + ), + NotificationItem( + id = "2", + type = NotificationType.RELEASE, + isUnread = false, + repoPath = Res.string.inbox_mock_repo_2, + timestamp = Res.string.inbox_mock_time_2, + title = Res.string.inbox_mock_title_2, + subtitle = Res.string.inbox_mock_subtitle_2, + commentCount = null + ) + ) + _uiState.value = InboxUiState(notifications = mocks) + } + + fun markAsDone(id: String) { + _uiState.value = _uiState.value.copy( + notifications = _uiState.value.notifications.filter { it.id != id } + ) + } + + fun unsubscribe(id: String) { + _uiState.value = _uiState.value.copy( + notifications = _uiState.value.notifications.filter { it.id != id } + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileScreen.kt new file mode 100644 index 0000000..d57500d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileScreen.kt @@ -0,0 +1,447 @@ +package dev.therealashik.github.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.therealashik.github.Dimens +import github.composeapp.generated.resources.Res +import github.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen( + viewModel: ProfileViewModel, + onBack: () -> Unit, + onNavigateToRepositories: () -> Unit, + onNavigateToSettings: () -> Unit = {} +) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back) + ) + } + }, + actions = { + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.Outlined.Share, + contentDescription = stringResource(Res.string.share) + ) + } + IconButton(onClick = onNavigateToSettings) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(Res.string.settings) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurface + ) + ) + } + ) { innerPadding -> + when (val state = uiState) { + is ProfileUiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center + ) { CircularProgressIndicator() } + } + is ProfileUiState.Error -> { + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(state.message, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(Dimens.SpacingMedium)) + Button(onClick = viewModel::loadData) { Text(stringResource(Res.string.retry)) } + } + } + } + is ProfileUiState.Success -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + ) { + HeaderSection(state) + Spacer(modifier = Modifier.height(Dimens.SpacingLarge)) + PopularReposSection(popularRepos = state.popularRepos) + Spacer(modifier = Modifier.height(Dimens.SpacingMedium)) + HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) + NavigationListSection( + state = state, + onNavigateToRepositories = onNavigateToRepositories + ) + } + } + } + } +} + +@Composable +private fun HeaderSection(state: ProfileUiState.Success) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimens.SpacingMedium) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(Dimens.AvatarSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Person, + contentDescription = null, + modifier = Modifier.size(Dimens.IconSizeExtraLarge), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + + Column { + Text( + text = state.name ?: state.login, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "@${state.login}", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + state.bio?.let { + Spacer(modifier = Modifier.height(Dimens.SpacingMedium)) + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + + if (state.company != null || state.location != null) { + Spacer(modifier = Modifier.height(Dimens.SpacingMedium)) + Row(verticalAlignment = Alignment.CenterVertically) { + state.company?.let { company -> + Icon( + imageVector = Icons.Outlined.Business, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(Dimens.SpacingMedium) + ) + Spacer(modifier = Modifier.width(Dimens.SpacingSmall)) + Text( + text = company, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + } + state.location?.let { location -> + Icon( + imageVector = Icons.Outlined.LocationOn, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(Dimens.SpacingMedium) + ) + Spacer(modifier = Modifier.width(Dimens.SpacingSmall)) + Text( + text = location, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + + Spacer(modifier = Modifier.height(Dimens.SpacingMedium)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Outlined.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(Dimens.SpacingMedium) + ) + Spacer(modifier = Modifier.width(Dimens.SpacingSmall)) + Text( + text = stringResource(Res.string.followers_following_format, state.followers, state.following), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun PopularReposSection(popularRepos: List) { + Column { + Text( + text = stringResource(Res.string.popular_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = Dimens.SpacingMedium, vertical = Dimens.SpacingSmall) + ) + + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = Dimens.SpacingMedium), + horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingMediumSmall) + ) { + popularRepos.forEach { repo -> + OutlinedCard( + modifier = Modifier.width(Dimens.CardWidth), + colors = CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = CardDefaults.outlinedCardBorder().copy(width = Dimens.BorderWidth) + ) { + Column( + modifier = Modifier + .padding(Dimens.SpacingMedium) + .fillMaxWidth() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(Dimens.AvatarSmall) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Person, + contentDescription = null, + modifier = Modifier.size(Dimens.SpacingMediumSmall), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(Dimens.SpacingSmall)) + Text( + text = repo.owner, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(Dimens.SpacingSmall)) + + Text( + text = repo.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + + if (repo.description != null) { + Spacer(modifier = Modifier.height(Dimens.SpacingExtraSmall)) + Text( + text = repo.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.height(Dimens.SpacingMedium)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Star, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(Dimens.SpacingMedium) + ) + Spacer(modifier = Modifier.width(Dimens.SpacingExtraSmall)) + Text( + text = repo.stars.toString(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (repo.language != null) { + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + Box( + modifier = Modifier + .size(Dimens.IndicatorSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + ) + Spacer(modifier = Modifier.width(Dimens.SpacingExtraSmall)) + Text( + text = repo.language, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } +} + +@Composable +private fun NavigationListSection( + state: ProfileUiState.Success, + onNavigateToRepositories: () -> Unit +) { + Column { + NavigationItem( + icon = { + Box( + modifier = Modifier + .size(Dimens.IconSizeLarge) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Book, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + label = stringResource(Res.string.nav_repositories), + count = state.publicRepos, + onClick = onNavigateToRepositories + ) + NavigationItem( + icon = { + Box( + modifier = Modifier + .size(Dimens.IconSizeLarge) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.errorContainer), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Business, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer + ) + } + }, + label = stringResource(Res.string.nav_organizations), + count = state.orgs.size, + onClick = { /* TODO */ } + ) + NavigationItem( + icon = { + Box( + modifier = Modifier + .size(Dimens.IconSizeLarge) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.tertiaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Star, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + }, + label = stringResource(Res.string.nav_starred), + count = state.starredCount, + onClick = { /* TODO */ } + ) + NavigationItem( + icon = { + Box( + modifier = Modifier + .size(Dimens.IconSizeLarge) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.ViewTimeline, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + }, + label = stringResource(Res.string.nav_projects), + count = 0, + onClick = { /* TODO */ } + ) + } +} + +@Composable +private fun NavigationItem( + icon: @Composable () -> Unit, + label: String, + count: Int, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = Dimens.SpacingMedium, vertical = Dimens.SpacingMediumSmall), + verticalAlignment = Alignment.CenterVertically + ) { + icon() + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + Text( + text = count.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileUiState.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileUiState.kt new file mode 100644 index 0000000..540fca2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileUiState.kt @@ -0,0 +1,35 @@ +package dev.therealashik.github.profile + +sealed class ProfileUiState { + data object Loading : ProfileUiState() + data class Error(val message: String) : ProfileUiState() + data class Success( + val login: String, + val name: String?, + val avatarUrl: String, + val bio: String?, + val company: String?, + val location: String?, + val followers: Int, + val following: Int, + val publicRepos: Int, + val popularRepos: List, + val orgs: List, + val starredCount: Int + ) : ProfileUiState() +} + +data class PopularRepo( + val id: Long, + val owner: String, + val name: String, + val description: String?, + val stars: Int, + val language: String? +) + +data class OrgSummary( + val id: Long, + val login: String, + val avatarUrl: String +) diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileViewModel.kt new file mode 100644 index 0000000..b7188c6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileViewModel.kt @@ -0,0 +1,73 @@ +package dev.therealashik.github.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.therealashik.github.data.GitHubApiClient +import dev.therealashik.github.data.createTokenStorage +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class ProfileViewModel : ViewModel() { + + private val apiClient = GitHubApiClient(createTokenStorage()) + + private val _uiState = MutableStateFlow(ProfileUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadData() + } + + fun loadData() { + viewModelScope.launch { + _uiState.value = ProfileUiState.Loading + + val userResult = apiClient.getAuthenticatedUser() + if (userResult.isFailure) { + _uiState.value = ProfileUiState.Error(userResult.exceptionOrNull()?.message ?: "Failed to load profile") + return@launch + } + val user = userResult.getOrThrow() + + val reposDeferred = async { apiClient.getUserRepos(perPage = 6) } + val orgsDeferred = async { apiClient.getUserOrgs() } + val starredDeferred = async { apiClient.getStarredRepos(perPage = 1) } + + val repos = reposDeferred.await().getOrDefault(emptyList()) + val orgs = orgsDeferred.await().getOrDefault(emptyList()) + val starred = starredDeferred.await().getOrDefault(emptyList()) + + _uiState.value = ProfileUiState.Success( + login = user.login, + name = user.name, + avatarUrl = user.avatarUrl, + bio = user.bio, + company = null, + location = null, + followers = user.followers, + following = user.following, + publicRepos = user.publicRepos, + popularRepos = repos.sortedByDescending { it.stars }.take(6).map { repo -> + PopularRepo( + id = repo.id, + owner = user.login, + name = repo.name, + description = repo.description, + stars = repo.stars, + language = repo.language + ) + }, + orgs = orgs.map { OrgSummary(it.id, it.login, it.avatarUrl) }, + starredCount = starred.size + ) + } + } + + override fun onCleared() { + super.onCleared() + apiClient.close() + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/pullrequest/CompareBranchSheet.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/pullrequest/CompareBranchSheet.kt new file mode 100644 index 0000000..b86cd66 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/pullrequest/CompareBranchSheet.kt @@ -0,0 +1,188 @@ +package dev.therealashik.github.pullrequest + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.CallSplit +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import github.composeapp.generated.resources.Res +import github.composeapp.generated.resources.base +import github.composeapp.generated.resources.choose_branch_to_compare +import github.composeapp.generated.resources.choose_branches +import github.composeapp.generated.resources.close +import github.composeapp.generated.resources.compare +import github.composeapp.generated.resources.compare_changes +import github.composeapp.generated.resources.main_branch_dropdown +import github.composeapp.generated.resources.select_branch +import org.jetbrains.compose.resources.stringResource +import dev.therealashik.github.Dimens + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CompareBranchSheet( + sheetState: SheetState, + repoPath: String, + onDismissRequest: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .padding(vertical = Dimens.SpacingMedium) + .size(width = Dimens.SpacingExtraExtraLarge, height = Dimens.SpacingSmall) + .background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape) + ) + } + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimens.SpacingLarge, vertical = Dimens.SpacingMedium) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + IconButton(onClick = onDismissRequest) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(Res.string.close) + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = repoPath, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.size(Dimens.IconSizeLarge)) // To balance the close button + } + + Spacer(modifier = Modifier.height(Dimens.SpacingLarge)) + + Text( + text = stringResource(Res.string.compare_changes), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(Dimens.SpacingExtraLarge)) + + Text( + text = stringResource(Res.string.choose_branches), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(Dimens.SpacingLarge)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(Res.string.base), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + FilterChip( + selected = true, + onClick = { /* TODO */ }, + label = { Text(stringResource(Res.string.main_branch_dropdown)) } + ) + } + + Spacer(modifier = Modifier.height(Dimens.SpacingMedium)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(Res.string.compare), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + TextButton(onClick = { /* TODO */ }) { + Text( + text = stringResource(Res.string.select_branch), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge + ) + } + } + + Spacer(modifier = Modifier.height(Dimens.SpacingExtraLarge)) + HorizontalDivider() + Spacer(modifier = Modifier.height(Dimens.SpacingExtraLarge)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Dimens.SpacingExtraExtraLarge), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(Dimens.IconSizeLarge) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.CallSplit, // Using CallSplit as placeholder + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(Dimens.IconSizeMedium) + ) + } + Spacer(modifier = Modifier.height(Dimens.SpacingLarge)) + Text( + text = stringResource(Res.string.choose_branch_to_compare), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + } + } + + Spacer(modifier = Modifier.height(Dimens.SpacingExtraExtraLarge)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/pullrequest/PullRequestListScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/pullrequest/PullRequestListScreen.kt new file mode 100644 index 0000000..9ebb648 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/pullrequest/PullRequestListScreen.kt @@ -0,0 +1,211 @@ +package dev.therealashik.github.pullrequest + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.automirrored.filled.CallSplit +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import github.composeapp.generated.resources.Res +import github.composeapp.generated.resources.assignee_filter +import github.composeapp.generated.resources.author_filter +import github.composeapp.generated.resources.back +import github.composeapp.generated.resources.checks_format +import github.composeapp.generated.resources.comments_count_format +import github.composeapp.generated.resources.create +import github.composeapp.generated.resources.draft_filter +import github.composeapp.generated.resources.label_filter +import github.composeapp.generated.resources.open_filter +import github.composeapp.generated.resources.pr_number_format +import github.composeapp.generated.resources.pull_requests +import github.composeapp.generated.resources.search +import org.jetbrains.compose.resources.stringResource +import dev.therealashik.github.Dimens + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PullRequestListScreen( + uiState: PullRequestListUiState, + onNavigateBack: () -> Unit = {} +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + text = uiState.ownerName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(Res.string.pull_requests), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back) + ) + } + }, + actions = { + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(Res.string.search) + ) + } + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(Res.string.create) + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + FilterChipsRow() + HorizontalDivider() + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(uiState.pullRequests) { pr -> + PullRequestListItem(item = pr) + HorizontalDivider(modifier = Modifier.padding(start = Dimens.SpacingMassive)) + } + } + } + } +} + +@Composable +fun FilterChipsRow() { + LazyRow( + contentPadding = PaddingValues(horizontal = Dimens.SpacingLarge, vertical = Dimens.SpacingMedium), + horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingMedium) + ) { + val filters = listOf( + Res.string.open_filter, + Res.string.draft_filter, + Res.string.label_filter, + Res.string.author_filter, + Res.string.assignee_filter + ) + items(filters) { filterRes -> + SuggestionChip( + onClick = { /* TODO */ }, + label = { Text(stringResource(filterRes)) } + ) + } + } +} + +@Composable +fun PullRequestListItem(item: PullRequestItem) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(Dimens.SpacingLarge) + ) { + Box(modifier = Modifier.padding(top = Dimens.SpacingExtraSmall)) { + Icon( + imageVector = Icons.AutoMirrored.Filled.CallSplit, // Using CallSplit as placeholder for PR branching arrows + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, // green PR icon in custom theme + modifier = Modifier.size(Dimens.IconSizeMedium) + ) + if (item.isUnread) { + Box( + modifier = Modifier + .size(Dimens.IconSizeExtraSmall) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.tertiary) // Blue dot for unread + .align(Alignment.TopEnd) + ) + } + } + Spacer(modifier = Modifier.width(Dimens.SpacingLarge)) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.Top) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + Text( + text = item.timestamp, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(Dimens.SpacingSmall)) + Text( + text = "${item.author} ${stringResource(Res.string.pr_number_format, item.number)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(Dimens.SpacingMedium)) + Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingMedium)) { + if (item.checksSummary.isNotEmpty()) { + SuggestionChip( + onClick = {}, + label = { Text(stringResource(Res.string.checks_format, item.checksSummary)) } + ) + } + if (item.commentsCount > 0) { + SuggestionChip( + onClick = {}, + label = { Text(stringResource(Res.string.comments_count_format, item.commentsCount)) } + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/pullrequest/PullRequestListUiState.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/pullrequest/PullRequestListUiState.kt new file mode 100644 index 0000000..251e08f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/pullrequest/PullRequestListUiState.kt @@ -0,0 +1,19 @@ +package dev.therealashik.github.pullrequest + +data class PullRequestItem( + val id: Long, + val title: String, + val number: Int, + val timestamp: String, + val author: String, + val isUnread: Boolean, + val checksSummary: String, + val commentsCount: Int +) + +data class PullRequestListUiState( + val ownerName: String = "", + val repoName: String = "", + val pullRequests: List = emptyList(), + val activeFilter: String? = null +) diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/pullrequest/PullRequestListViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/pullrequest/PullRequestListViewModel.kt new file mode 100644 index 0000000..55be0ad --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/pullrequest/PullRequestListViewModel.kt @@ -0,0 +1,48 @@ +package dev.therealashik.github.pullrequest + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class PullRequestListViewModel : ViewModel() { + private val _uiState = MutableStateFlow( + PullRequestListUiState( + ownerName = "google", + repoName = "accompanist", + pullRequests = listOf( + PullRequestItem( + id = 1, + title = "Update Compose to 1.6.0", + number = 1532, + timestamp = "2 hours ago", + author = "chrisbanes", + isUnread = true, + checksSummary = "3/3", + commentsCount = 5 + ), + PullRequestItem( + id = 2, + title = "Fix navigation animation glitch", + number = 1530, + timestamp = "yesterday", + author = "johndoe", + isUnread = false, + checksSummary = "2/3", + commentsCount = 2 + ), + PullRequestItem( + id = 3, + title = "Add new indicator style", + number = 1525, + timestamp = "3 days ago", + author = "janedoe", + isUnread = false, + checksSummary = "3/3", + commentsCount = 12 + ) + ) + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryDetailScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryDetailScreen.kt new file mode 100644 index 0000000..6fded78 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryDetailScreen.kt @@ -0,0 +1,449 @@ +package dev.therealashik.github.repository + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.PlayCircleOutline +import androidx.compose.material.icons.outlined.StarOutline +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import github.composeapp.generated.resources.Res +import github.composeapp.generated.resources.actions +import github.composeapp.generated.resources.add_to_list +import github.composeapp.generated.resources.back +import github.composeapp.generated.resources.change +import github.composeapp.generated.resources.code +import github.composeapp.generated.resources.commits +import github.composeapp.generated.resources.contributors +import github.composeapp.generated.resources.create +import github.composeapp.generated.resources.current_branch +import github.composeapp.generated.resources.discussions +import github.composeapp.generated.resources.edit +import github.composeapp.generated.resources.fork +import github.composeapp.generated.resources.forks_count_format +import github.composeapp.generated.resources.issues +import github.composeapp.generated.resources.latest +import github.composeapp.generated.resources.more_options +import github.composeapp.generated.resources.pull_requests +import github.composeapp.generated.resources.readme_md +import github.composeapp.generated.resources.releases +import github.composeapp.generated.resources.star +import github.composeapp.generated.resources.stars_count_format +import github.composeapp.generated.resources.watch +import github.composeapp.generated.resources.watchers +import org.jetbrains.compose.resources.stringResource +import dev.therealashik.github.Dimens + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RepositoryDetailScreen( + uiState: RepositoryDetailUiState, + onNavigateBack: () -> Unit = {}, + onStarClick: () -> Unit = {} +) { + Scaffold( + topBar = { + TopAppBar( + title = { }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back) + ) + } + }, + actions = { + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(Res.string.create) + ) + } + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(Res.string.more_options) + ) + } + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + item { + HeaderSection(uiState = uiState, onStarClick = onStarClick) + } + item { + HorizontalDivider(modifier = Modifier.padding(vertical = Dimens.SpacingMedium)) + } + item { + NavigationListSection(uiState = uiState) + } + item { + HorizontalDivider(modifier = Modifier.padding(vertical = Dimens.SpacingMedium)) + } + item { + BranchSection(uiState = uiState) + } + item { + HorizontalDivider(modifier = Modifier.padding(vertical = Dimens.SpacingMedium)) + } + item { + MoreRowsSection() + } + item { + HorizontalDivider(modifier = Modifier.padding(vertical = Dimens.SpacingMedium)) + } + item { + ReadmeSection(uiState = uiState) + } + } + } +} + +@Composable +fun HeaderSection(uiState: RepositoryDetailUiState, onStarClick: () -> Unit) { + Column(modifier = Modifier.padding(horizontal = Dimens.SpacingLarge)) { + Row(verticalAlignment = Alignment.CenterVertically) { + // Placeholder for Avatar + Box( + modifier = Modifier + .size(Dimens.IconSizeMedium) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + Text( + text = uiState.ownerName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(Dimens.SpacingSmall)) + Text( + text = uiState.repoName, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(Dimens.SpacingMedium)) + Row { + Text( + text = stringResource(Res.string.stars_count_format, uiState.starsCount), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(Dimens.SpacingLarge)) + Text( + text = stringResource(Res.string.forks_count_format, uiState.forksCount), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(Dimens.SpacingLarge)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingMedium) + ) { + val buttonModifier = Modifier.weight(1f) + OutlinedButton(onClick = onStarClick, modifier = buttonModifier) { + Icon( + imageVector = if (uiState.isStarred) Icons.Filled.Star else Icons.Outlined.StarOutline, + contentDescription = stringResource(Res.string.star), + tint = if (uiState.isStarred) MaterialTheme.colorScheme.tertiary else LocalContentColor.current + ) + } + OutlinedButton(onClick = { /* TODO */ }, modifier = buttonModifier) { + Text( + text = stringResource(Res.string.add_to_list), + style = MaterialTheme.typography.labelSmall + ) + } + OutlinedButton(onClick = { /* TODO */ }, modifier = buttonModifier) { + Text( + text = stringResource(Res.string.fork), + style = MaterialTheme.typography.labelSmall + ) + } + OutlinedButton(onClick = { /* TODO */ }, modifier = buttonModifier) { + Icon( + imageVector = Icons.Outlined.Notifications, + contentDescription = stringResource(Res.string.watch) + ) + } + } + } +} + +@Composable +fun NavigationListSection(uiState: RepositoryDetailUiState) { + Column { + NavigationListItem( + icon = Icons.Outlined.Info, + label = stringResource(Res.string.issues), + count = uiState.issuesCount, + iconTint = MaterialTheme.colorScheme.primary + ) + NavigationListItem( + icon = Icons.Default.Code, // Need PR icon, placeholder for now + label = stringResource(Res.string.pull_requests), + count = uiState.pullRequestsCount, + iconTint = MaterialTheme.colorScheme.secondary + ) + NavigationListItem( + icon = Icons.Outlined.ChatBubbleOutline, + label = stringResource(Res.string.discussions), + count = uiState.discussionsCount, + iconTint = MaterialTheme.colorScheme.tertiary + ) + NavigationListItem( + icon = Icons.Outlined.PlayCircleOutline, + label = stringResource(Res.string.actions), + count = null, + iconTint = MaterialTheme.colorScheme.secondary + ) + NavigationListItem( + icon = Icons.Default.PlayArrow, // Need tag icon, placeholder + label = stringResource(Res.string.releases), + count = uiState.releasesCount, + iconTint = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (uiState.releasesCount > 0) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimens.SpacingLarge, vertical = Dimens.SpacingMedium), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(Dimens.SpacingMedium) + ) { + Row( + modifier = Modifier.padding(Dimens.SpacingMediumLarge), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(Res.string.latest), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + RoundedCornerShape(Dimens.SpacingSmall) + ) + .padding(horizontal = Dimens.SpacingMediumSmall, vertical = Dimens.SpacingExtraSmall) + ) + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + Text( + text = uiState.latestReleaseVersion, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + Text( + text = uiState.latestReleaseAge, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + NavigationListItem( + icon = Icons.Outlined.Person, + label = stringResource(Res.string.contributors), + count = uiState.contributorsCount, + iconTint = MaterialTheme.colorScheme.secondary + ) + NavigationListItem( + icon = Icons.Outlined.AccountCircle, // Placeholder for watcher icon + label = stringResource(Res.string.watchers), + count = uiState.watchersCount, + iconTint = MaterialTheme.colorScheme.tertiary + ) + } +} + +@Composable +fun NavigationListItem( + icon: ImageVector, + label: String, + count: Int?, + iconTint: androidx.compose.ui.graphics.Color +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimens.SpacingLarge, vertical = Dimens.SpacingMediumLarge), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(Dimens.IconSizeMedium) + ) + Spacer(modifier = Modifier.width(Dimens.SpacingLarge)) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + if (count != null) { + Text( + text = count.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +fun BranchSection(uiState: RepositoryDetailUiState) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimens.SpacingLarge, vertical = Dimens.SpacingMedium), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(Res.string.current_branch), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = uiState.currentBranch, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + TextButton(onClick = { /* TODO */ }) { + Text( + text = stringResource(Res.string.change), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge + ) + } + } +} + +@Composable +fun MoreRowsSection() { + Column { + NavigationListItem( + icon = Icons.Default.Code, + label = stringResource(Res.string.code), + count = null, + iconTint = MaterialTheme.colorScheme.onSurface + ) + NavigationListItem( + icon = Icons.Outlined.Info, // Placeholder for commit icon + label = stringResource(Res.string.commits), + count = null, + iconTint = MaterialTheme.colorScheme.onSurface + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ReadmeSection(uiState: RepositoryDetailUiState) { + Column(modifier = Modifier.padding(horizontal = Dimens.SpacingLarge)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(Res.string.readme_md), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + TextButton(onClick = { /* TODO */ }) { + Text( + text = stringResource(Res.string.edit), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } + } + Spacer(modifier = Modifier.height(Dimens.SpacingMedium)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingMedium), + verticalArrangement = Arrangement.spacedBy(Dimens.SpacingMedium) + ) { + uiState.badges.forEach { badge -> + SuggestionChip( + onClick = { }, + label = { Text(badge, style = MaterialTheme.typography.labelSmall) } + ) + } + } + Spacer(modifier = Modifier.height(Dimens.SpacingLarge)) + Text( + text = uiState.readmeTitle, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(Dimens.SpacingMedium)) + Text( + text = uiState.readmeBodyPreview, + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(Dimens.SpacingExtraLarge)) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryDetailUiState.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryDetailUiState.kt new file mode 100644 index 0000000..971706d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryDetailUiState.kt @@ -0,0 +1,23 @@ +package dev.therealashik.github.repository + +data class RepositoryDetailUiState( + val ownerAvatarUrl: String = "", + val ownerName: String = "", + val repoName: String = "", + val starsCount: Int = 0, + val forksCount: Int = 0, + val isStarred: Boolean = false, + val issuesCount: Int = 0, + val pullRequestsCount: Int = 0, + val discussionsCount: Int = 0, + val actionsCount: Int = 0, // usually no count for actions but added for consistency if needed + val releasesCount: Int = 0, + val latestReleaseVersion: String = "", + val latestReleaseAge: String = "", + val contributorsCount: Int = 0, + val watchersCount: Int = 0, + val currentBranch: String = "", + val badges: List = emptyList(), + val readmeTitle: String = "", + val readmeBodyPreview: String = "" +) diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryDetailViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryDetailViewModel.kt new file mode 100644 index 0000000..f3398d4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryDetailViewModel.kt @@ -0,0 +1,40 @@ +package dev.therealashik.github.repository + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class RepositoryDetailViewModel : ViewModel() { + private val _uiState = MutableStateFlow( + RepositoryDetailUiState( + ownerAvatarUrl = "https://avatars.githubusercontent.com/u/1?v=4", // placeholder + ownerName = "google", + repoName = "accompanist", + starsCount = 2, + forksCount = 1, + isStarred = false, + issuesCount = 11, + pullRequestsCount = 4, + discussionsCount = 3, + actionsCount = 0, + releasesCount = 5, + latestReleaseVersion = "v0.32.0", + latestReleaseAge = "2 years ago", + contributorsCount = 1, + watchersCount = 0, + currentBranch = "main", + badges = listOf("build: passing", "kotlin: 1.9.0", "compose: 1.5.0", "backend: Ktor", "license: Apache", "platform: Android"), + readmeTitle = "Accompanist", + readmeBodyPreview = "A collection of extension libraries for Jetpack Compose." + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + fun toggleStar() { + _uiState.value = _uiState.value.copy( + isStarred = !_uiState.value.isStarred, + starsCount = if (_uiState.value.isStarred) _uiState.value.starsCount - 1 else _uiState.value.starsCount + 1 + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryListScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryListScreen.kt new file mode 100644 index 0000000..15ed248 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryListScreen.kt @@ -0,0 +1,234 @@ +package dev.therealashik.github.repository + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.ForkRight +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.therealashik.github.Dimens +import github.composeapp.generated.resources.Res +import github.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RepositoryListScreen( + viewModel: RepositoryListViewModel, + onBack: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + text = stringResource(Res.string.repo_owner), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(Res.string.repo_list_title), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back) + ) + } + }, + actions = { + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = stringResource(Res.string.search) + ) + } + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = stringResource(Res.string.add) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurface + ) + ) + } + ) { innerPadding -> + when (val state = uiState) { + is RepositoryListUiState.Loading -> { + Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + is RepositoryListUiState.Error -> { + Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(state.message, color = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(Dimens.SpacingMedium)) + Button(onClick = { viewModel.loadData() }) { Text(stringResource(Res.string.retry)) } + } + } + } + is RepositoryListUiState.Success -> { + Column(Modifier.fillMaxSize().padding(innerPadding)) { + FilterChipsRow() + Spacer(modifier = Modifier.height(Dimens.SpacingSmall)) + HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) + LazyColumn(Modifier.fillMaxSize()) { + items(state.repositories) { repo -> + RepositoryItemRow(repo = repo) + HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) + } + } + } + } + } + } +} + +@Composable +private fun FilterChipsRow() { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = Dimens.SpacingMedium, vertical = Dimens.SpacingSmall), + horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingSmall) + ) { + FilterChipItem(label = stringResource(Res.string.filter_all)) + FilterChipItem(label = stringResource(Res.string.filter_language)) + FilterChipItem(label = stringResource(Res.string.filter_sort)) + } +} + +@Composable +private fun FilterChipItem(label: String) { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) { + Row( + modifier = Modifier.padding(horizontal = Dimens.SpacingMediumSmall, vertical = Dimens.SpacingSmall), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.width(Dimens.SpacingExtraSmall)) + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = null, + modifier = Modifier.size(Dimens.SpacingMedium) + ) + } + } +} + +@Composable +private fun RepositoryItemRow(repo: RepositoryItem) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Dimens.SpacingMedium) + ) { + Text( + text = repo.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + + if (repo.description != null) { + Spacer(modifier = Modifier.height(Dimens.SpacingExtraSmall)) + Text( + text = repo.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + if (repo.forkedFrom != null) { + Spacer(modifier = Modifier.height(Dimens.SpacingSmall)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Outlined.ForkRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(Dimens.SpacingMedium) + ) + Spacer(modifier = Modifier.width(Dimens.SpacingExtraSmall)) + Text( + text = repo.forkedFrom, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(Dimens.SpacingMediumSmall)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Star, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(Dimens.SpacingMedium) + ) + Spacer(modifier = Modifier.width(Dimens.SpacingExtraSmall)) + Text( + text = repo.stars.toString(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (repo.language != null) { + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + Box( + modifier = Modifier + .size(Dimens.IndicatorSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) // Primary for purple dot + ) + Spacer(modifier = Modifier.width(Dimens.SpacingExtraSmall)) + Text( + text = repo.language, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryListUiState.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryListUiState.kt new file mode 100644 index 0000000..3d6be38 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryListUiState.kt @@ -0,0 +1,16 @@ +package dev.therealashik.github.repository + +sealed class RepositoryListUiState { + data object Loading : RepositoryListUiState() + data class Error(val message: String) : RepositoryListUiState() + data class Success(val repositories: List) : RepositoryListUiState() +} + +data class RepositoryItem( + val id: String, + val name: String, + val description: String?, + val forkedFrom: String?, + val stars: Int, + val language: String? +) diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryListViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryListViewModel.kt new file mode 100644 index 0000000..d7cd695 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/repository/RepositoryListViewModel.kt @@ -0,0 +1,42 @@ +package dev.therealashik.github.repository + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.therealashik.github.data.GitHubApiClient +import dev.therealashik.github.data.createTokenStorage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class RepositoryListViewModel : ViewModel() { + private val apiClient = GitHubApiClient(createTokenStorage()) + private val _uiState = MutableStateFlow(RepositoryListUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { loadData() } + + fun loadData() { + viewModelScope.launch { + _uiState.value = RepositoryListUiState.Loading + apiClient.getUserRepos(perPage = 100) + .onSuccess { repos -> + _uiState.value = RepositoryListUiState.Success( + repos.map { r -> + RepositoryItem( + id = r.id.toString(), + name = r.name, + description = r.description, + forkedFrom = null, + stars = r.stars, + language = r.language + ) + } + ) + } + .onFailure { _uiState.value = RepositoryListUiState.Error(it.message ?: "Unknown error") } + } + } + + override fun onCleared() { apiClient.close() } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/AccountsSheet.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/AccountsSheet.kt new file mode 100644 index 0000000..68fa741 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/AccountsSheet.kt @@ -0,0 +1,172 @@ +package dev.therealashik.github.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.* +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.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import github.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +data class AccountItem( + val username: String, + val notificationCount: Int, + val isActive: Boolean +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountsSheet( + onDismiss: () -> Unit, + onAddAccount: () -> Unit, + accounts: List = listOf( + AccountItem("therealashik", 5, true), + AccountItem("work-account", 0, false) + ) +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + dragHandle = { BottomSheetDefaults.DragHandle() }, + containerColor = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = SettingsTokens.PaddingLarge) + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SettingsTokens.PaddingLarge, vertical = SettingsTokens.PaddingMedium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(Res.string.content_description_close) + ) + } + Spacer(modifier = Modifier.width(SettingsTokens.PaddingMedium)) + Text( + text = stringResource(Res.string.accounts_sheet_title), + style = MaterialTheme.typography.titleLarge + ) + } + TextButton(onClick = { /* TODO */ }) { + Text( + text = stringResource(Res.string.accounts_sheet_edit), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge + ) + } + } + + Divider() + + // Accounts List + LazyColumn { + items(accounts) { account -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { /* TODO */ } + .padding(horizontal = SettingsTokens.PaddingLarge, vertical = SettingsTokens.PaddingLarge), + verticalAlignment = Alignment.CenterVertically + ) { + // Avatar Placeholder + Box( + modifier = Modifier + .size(SettingsTokens.IconSizeLarge) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.width(SettingsTokens.PaddingLarge)) + + Text( + text = account.username, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + + if (account.notificationCount > 0) { + // Notification Badge + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(end = SettingsTokens.PaddingMedium) + ) { + Row( + modifier = Modifier.padding(horizontal = SettingsTokens.PaddingMedium, vertical = SettingsTokens.PaddingSmall), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = null, + tint = MaterialTheme.colorScheme.onError, + modifier = Modifier.size(SettingsTokens.IconSizeSmall) + ) + Spacer(modifier = Modifier.width(SettingsTokens.PaddingSmall)) + Text( + text = account.notificationCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onError + ) + } + } + } + + if (account.isActive) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(Res.string.content_description_active_account), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + + item { + // Add Account Button + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onAddAccount() } + .padding(horizontal = SettingsTokens.PaddingLarge, vertical = SettingsTokens.PaddingLarge), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(Res.string.accounts_sheet_add_account), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/AddPatScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/AddPatScreen.kt new file mode 100644 index 0000000..2ede6a9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/AddPatScreen.kt @@ -0,0 +1,97 @@ +package dev.therealashik.github.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import github.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddPatScreen( + onNavigateBack: () -> Unit, + onSuccess: () -> Unit, + viewModel: AddPatViewModel = viewModel { AddPatViewModel() } +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var passwordVisible by remember { mutableStateOf(false) } + + LaunchedEffect(uiState.success) { + if (uiState.success) onSuccess() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.add_pat_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.content_description_back)) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(SettingsTokens.PaddingLarge), + verticalArrangement = Arrangement.spacedBy(SettingsTokens.PaddingLarge) + ) { + Text( + text = stringResource(Res.string.add_pat_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + OutlinedTextField( + value = uiState.token, + onValueChange = viewModel::onTokenChange, + label = { Text(stringResource(Res.string.add_pat_label)) }, + placeholder = { Text(stringResource(Res.string.add_pat_placeholder)) }, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = null + ) + } + }, + isError = uiState.error != null, + supportingText = uiState.error?.let { { Text(it) } }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = viewModel::saveToken, + enabled = !uiState.isLoading && uiState.token.isNotBlank(), + modifier = Modifier.fillMaxWidth() + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(SettingsTokens.IconSizeSmall), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = SettingsTokens.DividerThickness + ) + } else { + Text(stringResource(Res.string.add_pat_save)) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/AddPatViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/AddPatViewModel.kt new file mode 100644 index 0000000..feda3e9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/AddPatViewModel.kt @@ -0,0 +1,53 @@ +package dev.therealashik.github.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.therealashik.github.data.GitHubApiClient +import dev.therealashik.github.data.createTokenStorage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class AddPatUiState( + val token: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val success: Boolean = false +) + +class AddPatViewModel : ViewModel() { + private val tokenStorage = createTokenStorage() + private val apiClient = GitHubApiClient(tokenStorage) + + private val _uiState = MutableStateFlow(AddPatUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onTokenChange(value: String) { + _uiState.value = _uiState.value.copy(token = value, error = null) + } + + fun saveToken() { + val token = _uiState.value.token.trim() + if (token.isBlank()) { + _uiState.value = _uiState.value.copy(error = "Token cannot be empty") + return + } + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + tokenStorage.saveToken(token) + apiClient.getAuthenticatedUser().fold( + onSuccess = { _uiState.value = _uiState.value.copy(isLoading = false, success = true) }, + onFailure = { + tokenStorage.clearToken() + _uiState.value = _uiState.value.copy(isLoading = false, error = "Invalid token or network error") + } + ) + } + } + + override fun onCleared() { + super.onCleared() + apiClient.close() + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/CodeOptionsScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/CodeOptionsScreen.kt new file mode 100644 index 0000000..e12a12d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/CodeOptionsScreen.kt @@ -0,0 +1,207 @@ +package dev.therealashik.github.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import github.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CodeOptionsScreen( + onNavigateBack: () -> Unit, + viewModel: CodeOptionsViewModel = viewModel { CodeOptionsViewModel() } +) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.code_options_title), + style = MaterialTheme.typography.titleLarge + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.content_description_back)) + } + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + item { + SettingsToggleRow( + title = stringResource(Res.string.code_options_scrollable_file_path), + checked = uiState.scrollableFilePath, + onCheckedChange = { viewModel.toggleScrollableFilePath() } + ) + SectionDivider() + + SettingsToggleRow( + title = stringResource(Res.string.code_options_show_line_numbers), + checked = uiState.showLineNumbers, + onCheckedChange = { viewModel.toggleShowLineNumbers() } + ) + SectionDivider() + + SettingsToggleRow( + title = stringResource(Res.string.code_options_always_dark_theme), + checked = uiState.alwaysUseDarkTheme, + onCheckedChange = { viewModel.toggleAlwaysUseDarkTheme() } + ) + SectionDivider() + + SettingsToggleRow( + title = stringResource(Res.string.code_options_override_font_size), + checked = uiState.overrideSystemFontSize, + onCheckedChange = { viewModel.toggleOverrideSystemFontSize() } + ) + SectionDivider() + + SettingsToggleRow( + title = stringResource(Res.string.code_options_wrap_lines), + checked = uiState.wrapLines, + onCheckedChange = { viewModel.toggleWrapLines() } + ) + SectionDivider() + } + + // Preview Section + item { + SettingsSectionHeader(stringResource(Res.string.code_options_preview)) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(SettingsTokens.PaddingLarge), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = SettingsTokens.PaddingMedium, vertical = SettingsTokens.PaddingSmall), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = stringResource(Res.string.content_description_collapse), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(Res.string.code_preview_file_path), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f).padding(horizontal = SettingsTokens.PaddingMedium) + ) + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(Res.string.content_description_copy), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(Res.string.content_description_more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Code block + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.scrim) + .padding(SettingsTokens.PaddingLarge) + ) { + val codeColorKeyword = MaterialTheme.colorScheme.primary + val codeColorString = MaterialTheme.colorScheme.tertiary + val codeColorNormal = MaterialTheme.colorScheme.onSurface + + val funStr = stringResource(Res.string.code_preview_fun) + val mainStr = stringResource(Res.string.code_preview_main) + val printlnStr = stringResource(Res.string.code_preview_println) + val helloWorldStr = stringResource(Res.string.code_preview_hello_world) + val endStr = stringResource(Res.string.code_preview_end) + + val annotatedCode = buildAnnotatedString { + withStyle(style = SpanStyle(color = codeColorKeyword)) { + append(funStr) + } + withStyle(style = SpanStyle(color = codeColorNormal)) { + append(mainStr) + } + withStyle(style = SpanStyle(color = codeColorNormal)) { + append(printlnStr) + } + withStyle(style = SpanStyle(color = codeColorString)) { + append(helloWorldStr) + } + withStyle(style = SpanStyle(color = codeColorNormal)) { + append(endStr) + } + } + + if (uiState.showLineNumbers) { + Row { + Column( + modifier = Modifier.padding(end = SettingsTokens.PaddingLarge), + horizontalAlignment = Alignment.End + ) { + Text("1", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium) + Text("2", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium) + Text("3", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium) + } + Text( + text = annotatedCode, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } else { + Text( + text = annotatedCode, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/CodeOptionsUiState.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/CodeOptionsUiState.kt new file mode 100644 index 0000000..e25bac2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/CodeOptionsUiState.kt @@ -0,0 +1,9 @@ +package dev.therealashik.github.settings + +data class CodeOptionsUiState( + val scrollableFilePath: Boolean = true, + val showLineNumbers: Boolean = true, + val alwaysUseDarkTheme: Boolean = false, + val overrideSystemFontSize: Boolean = false, + val wrapLines: Boolean = false +) diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/CodeOptionsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/CodeOptionsViewModel.kt new file mode 100644 index 0000000..d529097 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/CodeOptionsViewModel.kt @@ -0,0 +1,32 @@ +package dev.therealashik.github.settings + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class CodeOptionsViewModel : ViewModel() { + private val _uiState = MutableStateFlow(CodeOptionsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun toggleScrollableFilePath() { + _uiState.update { it.copy(scrollableFilePath = !it.scrollableFilePath) } + } + + fun toggleShowLineNumbers() { + _uiState.update { it.copy(showLineNumbers = !it.showLineNumbers) } + } + + fun toggleAlwaysUseDarkTheme() { + _uiState.update { it.copy(alwaysUseDarkTheme = !it.alwaysUseDarkTheme) } + } + + fun toggleOverrideSystemFontSize() { + _uiState.update { it.copy(overrideSystemFontSize = !it.overrideSystemFontSize) } + } + + fun toggleWrapLines() { + _uiState.update { it.copy(wrapLines = !it.wrapLines) } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/NotificationOptionsScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/NotificationOptionsScreen.kt new file mode 100644 index 0000000..1ed1512 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/NotificationOptionsScreen.kt @@ -0,0 +1,282 @@ +package dev.therealashik.github.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import github.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationOptionsScreen( + onNavigateBack: () -> Unit, + viewModel: NotificationOptionsViewModel = viewModel { NotificationOptionsViewModel() } +) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.notification_options_title), + style = MaterialTheme.typography.titleLarge + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.content_description_back)) + } + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // General Section + item { + SettingsSectionHeader(stringResource(Res.string.notification_options_section_general)) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { /* TODO */ } + .padding(horizontal = SettingsTokens.PaddingLarge, vertical = SettingsTokens.PaddingLarge), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(Res.string.notification_options_system_options), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(Res.string.notification_options_system_options_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.Default.Settings, + contentDescription = stringResource(Res.string.content_description_system_settings), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + SettingsRow( + title = stringResource(Res.string.notification_options_working_hours), + subtitle = stringResource(Res.string.notification_options_working_hours_desc), + onClick = { /* TODO */ } + ) + SectionDivider() + } + + // Push Notifications Types + item { + SettingsSectionHeader(stringResource(Res.string.notification_options_section_push_types)) + SettingsToggleRow( + title = stringResource(Res.string.notification_options_direct_mentions), + checked = uiState.directMentions, + onCheckedChange = { viewModel.toggleDirectMentions() } + ) + SettingsToggleRow( + title = stringResource(Res.string.notification_options_review_requested), + checked = uiState.reviewRequested, + onCheckedChange = { viewModel.toggleReviewRequested() } + ) + SettingsToggleRow( + title = stringResource(Res.string.notification_options_assigned), + checked = uiState.assigned, + onCheckedChange = { viewModel.toggleAssigned() } + ) + SettingsToggleRow( + title = stringResource(Res.string.notification_options_deployment_review), + checked = uiState.deploymentReview, + onCheckedChange = { viewModel.toggleDeploymentReview() } + ) + SettingsToggleRow( + title = stringResource(Res.string.notification_options_pull_request_review), + checked = uiState.pullRequestReview, + onCheckedChange = { viewModel.togglePullRequestReview() } + ) + SettingsToggleRow( + title = stringResource(Res.string.notification_options_workflow_runs), + checked = uiState.workflowRuns, + onCheckedChange = { viewModel.toggleWorkflowRuns() } + ) + SettingsToggleRow( + title = stringResource(Res.string.notification_options_failed_workflows), + checked = uiState.failedWorkflowsOnly, + onCheckedChange = { viewModel.toggleFailedWorkflowsOnly() } + ) + SectionDivider() + } + + // Live Notifications + item { + SettingsSectionHeader(stringResource(Res.string.notification_options_section_live)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SettingsTokens.PaddingLarge, vertical = SettingsTokens.PaddingLarge), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f).padding(end = SettingsTokens.PaddingLarge)) { + Text( + text = stringResource(Res.string.notification_options_live_agent), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(Res.string.notification_options_live_agent_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = uiState.liveAgentUpdates, + onCheckedChange = { viewModel.toggleLiveAgentUpdates() } + ) + } + SectionDivider() + } + + // Swipe Options + item { + SettingsSectionHeader(stringResource(Res.string.notification_options_section_swipe)) + + // Left Swipe + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SettingsTokens.PaddingLarge, vertical = SettingsTokens.PaddingMedium), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(Res.string.notification_options_left_swipe), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = uiState.leftSwipeAction, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + TextButton(onClick = { /* TODO */ }) { + Text( + text = stringResource(Res.string.notification_options_change), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge + ) + } + } + + // Right Swipe + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SettingsTokens.PaddingLarge, vertical = SettingsTokens.PaddingMedium), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(Res.string.notification_options_right_swipe), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = uiState.rightSwipeAction, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + TextButton(onClick = { /* TODO */ }) { + Text( + text = stringResource(Res.string.notification_options_change), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge + ) + } + } + + // Swipe Preview Placeholder + Card( + modifier = Modifier + .fillMaxWidth() + .padding(SettingsTokens.PaddingLarge), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(SettingsTokens.PaddingLarge), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.NotificationsOff, + contentDescription = stringResource(Res.string.content_description_swipe_preview_icon), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(SettingsTokens.PaddingLarge)) + Column(modifier = Modifier.weight(1f)) { + Box( + modifier = Modifier + .fillMaxWidth(0.7f) + .height(SettingsTokens.SwipePreviewLineHeight) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f)) + ) + Spacer(modifier = Modifier.height(SettingsTokens.PaddingMedium)) + Box( + modifier = Modifier + .fillMaxWidth(0.4f) + .height(SettingsTokens.SwipePreviewLineHeight) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f)) + ) + } + } + } + } + } + } +} + +@Composable +fun SettingsToggleRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(horizontal = SettingsTokens.PaddingLarge, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/NotificationOptionsUiState.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/NotificationOptionsUiState.kt new file mode 100644 index 0000000..54a406e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/NotificationOptionsUiState.kt @@ -0,0 +1,14 @@ +package dev.therealashik.github.settings + +data class NotificationOptionsUiState( + val directMentions: Boolean = true, + val reviewRequested: Boolean = true, + val assigned: Boolean = true, + val deploymentReview: Boolean = true, + val pullRequestReview: Boolean = true, + val workflowRuns: Boolean = true, + val failedWorkflowsOnly: Boolean = true, + val liveAgentUpdates: Boolean = true, + val leftSwipeAction: String = "Mark as done", + val rightSwipeAction: String = "Unsubscribe" +) diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/NotificationOptionsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/NotificationOptionsViewModel.kt new file mode 100644 index 0000000..eaaf4a7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/NotificationOptionsViewModel.kt @@ -0,0 +1,44 @@ +package dev.therealashik.github.settings + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class NotificationOptionsViewModel : ViewModel() { + private val _uiState = MutableStateFlow(NotificationOptionsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun toggleDirectMentions() { + _uiState.update { it.copy(directMentions = !it.directMentions) } + } + + fun toggleReviewRequested() { + _uiState.update { it.copy(reviewRequested = !it.reviewRequested) } + } + + fun toggleAssigned() { + _uiState.update { it.copy(assigned = !it.assigned) } + } + + fun toggleDeploymentReview() { + _uiState.update { it.copy(deploymentReview = !it.deploymentReview) } + } + + fun togglePullRequestReview() { + _uiState.update { it.copy(pullRequestReview = !it.pullRequestReview) } + } + + fun toggleWorkflowRuns() { + _uiState.update { it.copy(workflowRuns = !it.workflowRuns) } + } + + fun toggleFailedWorkflowsOnly() { + _uiState.update { it.copy(failedWorkflowsOnly = !it.failedWorkflowsOnly) } + } + + fun toggleLiveAgentUpdates() { + _uiState.update { it.copy(liveAgentUpdates = !it.liveAgentUpdates) } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/SettingsScreen.kt new file mode 100644 index 0000000..143a220 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/SettingsScreen.kt @@ -0,0 +1,236 @@ +package dev.therealashik.github.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import github.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onNavigateBack: () -> Unit, + onNavigateToNotificationOptions: () -> Unit, + onNavigateToCodeOptions: () -> Unit, + onNavigateToAddPat: () -> Unit, + viewModel: SettingsViewModel = viewModel { SettingsViewModel() } +) { + val uiState by viewModel.uiState.collectAsState() + var showAccountsSheet by remember { mutableStateOf(false) } + + // Refresh token state when returning to this screen + val lifecycle = LocalLifecycleOwner.current.lifecycle + val lifecycleState by lifecycle.currentStateFlow.collectAsState() + LaunchedEffect(lifecycleState) { + if (lifecycleState == Lifecycle.State.RESUMED) viewModel.refresh() + } + + if (showAccountsSheet) { + AccountsSheet( + onDismiss = { showAccountsSheet = false }, + onAddAccount = { showAccountsSheet = false; onNavigateToAddPat() } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.settings_title), + style = MaterialTheme.typography.titleLarge + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.content_description_back)) + } + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Notifications Section + item { + SettingsSectionHeader(stringResource(Res.string.settings_section_notifications)) + SettingsRow( + title = stringResource(Res.string.settings_notification_options), + onClick = onNavigateToNotificationOptions + ) + SectionDivider() + } + + // General Section + item { + SettingsSectionHeader(stringResource(Res.string.settings_section_general)) + SettingsRow( + title = stringResource(Res.string.settings_theme), + subtitle = uiState.themeValue, + onClick = { /* TODO */ } + ) + SettingsRow( + title = stringResource(Res.string.settings_code_options), + onClick = onNavigateToCodeOptions + ) + SettingsRow( + title = stringResource(Res.string.settings_language), + subtitle = uiState.languageValue, + onClick = { /* TODO */ } + ) + SettingsRowWithBadge( + title = stringResource(Res.string.settings_accounts), + count = uiState.accountsCount, + onClick = { showAccountsSheet = true } + ) + SettingsRow( + title = stringResource(Res.string.settings_app_lock), + onClick = { /* TODO */ } + ) + SectionDivider() + } + + // Subscriptions Section + item { + SettingsSectionHeader(stringResource(Res.string.settings_section_subscriptions)) + SettingsRow( + title = stringResource(Res.string.settings_copilot), + subtitle = uiState.copilotTier, + onClick = { /* TODO */ } + ) + SectionDivider() + } + + // More Options Section + item { + SettingsSectionHeader(stringResource(Res.string.settings_section_more_options)) + SettingsRow(title = stringResource(Res.string.settings_share_feedback), onClick = { /* TODO */ }) + SettingsRow(title = stringResource(Res.string.settings_get_help), onClick = { /* TODO */ }) + SettingsRow(title = stringResource(Res.string.settings_terms_of_service), onClick = { /* TODO */ }) + SettingsRow(title = stringResource(Res.string.settings_privacy_policy), onClick = { /* TODO */ }) + SettingsRow(title = stringResource(Res.string.settings_open_source_libraries), onClick = { /* TODO */ }) + SettingsRow( + title = stringResource(Res.string.settings_sign_out), + titleColor = MaterialTheme.colorScheme.error, + onClick = { /* TODO */ } + ) + SectionDivider() + } + + // Footer + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = SettingsTokens.PaddingHuge), + contentAlignment = Alignment.Center + ) { + Text( + text = uiState.appVersion, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +fun SettingsSectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = SettingsTokens.PaddingLarge, end = SettingsTokens.PaddingLarge, top = SettingsTokens.PaddingExtraLarge, bottom = SettingsTokens.PaddingMedium) + ) +} + +@Composable +fun SettingsRow( + title: String, + subtitle: String? = null, + titleColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = SettingsTokens.PaddingLarge, vertical = SettingsTokens.PaddingLarge), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = titleColor + ) + } + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +fun SettingsRowWithBadge( + title: String, + count: Int, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = SettingsTokens.PaddingLarge, vertical = SettingsTokens.PaddingLarge), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.padding(start = SettingsTokens.PaddingMedium) + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + } + } +} + +@Composable +fun SectionDivider() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(SettingsTokens.DividerThickness) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/SettingsTokens.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/SettingsTokens.kt new file mode 100644 index 0000000..3daa5ea --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/SettingsTokens.kt @@ -0,0 +1,18 @@ +package dev.therealashik.github.settings + +import androidx.compose.ui.unit.dp + +object SettingsTokens { + val PaddingSmall = 4.dp + val PaddingMedium = 8.dp + val PaddingLarge = 16.dp + val PaddingExtraLarge = 24.dp + val PaddingHuge = 32.dp + + val IconSizeSmall = 16.dp + val IconSizeMedium = 24.dp + val IconSizeLarge = 40.dp + + val DividerThickness = 8.dp + val SwipePreviewLineHeight = 12.dp +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/SettingsUiState.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/SettingsUiState.kt new file mode 100644 index 0000000..b074098 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/SettingsUiState.kt @@ -0,0 +1,9 @@ +package dev.therealashik.github.settings + +data class SettingsUiState( + val themeValue: String = "Follow system", + val languageValue: String = "Follow system", + val accountsCount: Int = 2, + val copilotTier: String = "Copilot Free", + val appVersion: String = "GitHub Mobile v1.258.0-beta (10323)" +) diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/SettingsViewModel.kt new file mode 100644 index 0000000..aac07e9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/settings/SettingsViewModel.kt @@ -0,0 +1,23 @@ +package dev.therealashik.github.settings + +import androidx.lifecycle.ViewModel +import dev.therealashik.github.data.createTokenStorage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class SettingsViewModel : ViewModel() { + private val tokenStorage = createTokenStorage() + + private val _uiState = MutableStateFlow(buildState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun refresh() { + _uiState.value = buildState() + } + + private fun buildState(): SettingsUiState { + val hasToken = tokenStorage.getToken() != null + return SettingsUiState(accountsCount = if (hasToken) 1 else 0) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/theme/Dimensions.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/theme/Dimensions.kt new file mode 100644 index 0000000..2923d94 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/theme/Dimensions.kt @@ -0,0 +1,31 @@ +package dev.therealashik.github.theme + +import androidx.compose.ui.unit.dp + +object Dimensions { + val PaddingExtraSmall = 4.dp + val PaddingSmall = 8.dp + val PaddingMedium = 16.dp + val PaddingLarge = 24.dp + + val SpacingExtraSmall = 4.dp + val SpacingSmall = 8.dp + val SpacingMedium = 16.dp + val SpacingLarge = 24.dp + val SpacingExtraLarge = 32.dp + + val IconSizeSmall = 16.dp + val IconSizeMedium = 24.dp + val IconSizeLarge = 32.dp + + val AvatarSizeSmall = 24.dp + val AvatarSizeMedium = 32.dp + val AvatarSizeLarge = 48.dp + + val BorderWidth = 1.dp + val ThinBorderWidth = 0.5.dp + val IndicatorDotSize = 8.dp + + val Zero = 0.dp + val ReleaseBannerHeight = 100.dp +} diff --git a/composeApp/src/iosMain/kotlin/dev/therealashik/github/data/TokenStorage.ios.kt b/composeApp/src/iosMain/kotlin/dev/therealashik/github/data/TokenStorage.ios.kt new file mode 100644 index 0000000..f487971 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/dev/therealashik/github/data/TokenStorage.ios.kt @@ -0,0 +1,16 @@ +package dev.therealashik.github.data + +import platform.Foundation.NSUserDefaults + +private class IosTokenStorage : TokenStorage { + // TODO: Replace with Keychain (Security framework) for production + private val defaults = NSUserDefaults.standardUserDefaults + + override fun saveToken(token: String) { defaults.setObject(token, KEY) } + override fun getToken(): String? = defaults.stringForKey(KEY) + override fun clearToken() { defaults.removeObjectForKey(KEY) } + + companion object { private const val KEY = "pat_token" } +} + +actual fun createTokenStorage(): TokenStorage = IosTokenStorage() diff --git a/composeApp/src/jvmMain/kotlin/dev/therealashik/github/data/TokenStorage.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/therealashik/github/data/TokenStorage.jvm.kt new file mode 100644 index 0000000..6edc38b --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/dev/therealashik/github/data/TokenStorage.jvm.kt @@ -0,0 +1,15 @@ +package dev.therealashik.github.data + +import java.util.prefs.Preferences + +private class JvmTokenStorage : TokenStorage { + private val prefs = Preferences.userRoot().node("dev/therealashik/github") + + override fun saveToken(token: String) { prefs.put(KEY, token) } + override fun getToken(): String? = prefs.get(KEY, null)?.takeIf { it.isNotEmpty() } + override fun clearToken() { prefs.remove(KEY) } + + companion object { private const val KEY = "pat_token" } +} + +actual fun createTokenStorage(): TokenStorage = JvmTokenStorage() diff --git a/composeApp/src/webMain/kotlin/dev/therealashik/github/data/TokenStorage.web.kt b/composeApp/src/webMain/kotlin/dev/therealashik/github/data/TokenStorage.web.kt new file mode 100644 index 0000000..5272eb3 --- /dev/null +++ b/composeApp/src/webMain/kotlin/dev/therealashik/github/data/TokenStorage.web.kt @@ -0,0 +1,10 @@ +package dev.therealashik.github.data + +private class WebTokenStorage : TokenStorage { + private var token: String? = null + override fun saveToken(token: String) { this.token = token } + override fun getToken(): String? = token + override fun clearToken() { token = null } +} + +actual fun createTokenStorage(): TokenStorage = WebTokenStorage() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 25cdcc5..d46beed 100755 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,10 @@ composeMultiplatform = "1.10.3" junit = "4.13.2" kotlin = "2.3.21" kotlinx-coroutines = "1.10.2" +kotlinx-serialization = "1.7.3" +ktor = "3.1.3" material3 = "1.10.0-alpha05" +navigation-compose = "2.9.2" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -35,6 +38,15 @@ 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-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" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -42,4 +54,5 @@ androidLibrary = { id = "com.android.library", version.ref = "agp" } composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } \ No newline at end of file +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file