├── composeApp ├── src │ ├── commonMain │ │ ├── kotlin │ │ │ ├── presentation │ │ │ │ ├── widgets │ │ │ │ │ ├── SwipeContainer.kt │ │ │ │ │ ├── IndentedBox.kt │ │ │ │ │ ├── SquircleBadge.kt │ │ │ │ │ ├── RowOrColumnLayout.kt │ │ │ │ │ └── LabelledIcon.kt │ │ │ │ ├── Route.kt │ │ │ │ ├── screens │ │ │ │ │ ├── details │ │ │ │ │ │ ├── WebviewTabContent.kt │ │ │ │ │ │ ├── CommentsTabContent.kt │ │ │ │ │ │ ├── ItemDetailsSection.kt │ │ │ │ │ │ ├── ItemCommentsSection.kt │ │ │ │ │ │ ├── DetailsTopBar.kt │ │ │ │ │ │ ├── ShareSheet.kt │ │ │ │ │ │ └── DetailsScreen.kt │ │ │ │ │ └── main │ │ │ │ │ │ └── ItemRowWidget.kt │ │ │ │ ├── repositories │ │ │ │ │ └── ItemRepositoryImpl.kt │ │ │ │ └── viewmodels │ │ │ │ │ ├── MainViewModel.kt │ │ │ │ │ └── DetailsViewModel.kt │ │ │ ├── Greeting.kt │ │ │ ├── modules │ │ │ │ ├── RepositoryModule.kt │ │ │ │ ├── ViewModelModule.kt │ │ │ │ ├── UseCaseModule.kt │ │ │ │ └── DataModule.kt │ │ │ ├── domain │ │ │ │ ├── models │ │ │ │ │ ├── AppError.kt │ │ │ │ │ ├── PollOption.kt │ │ │ │ │ ├── Category.kt │ │ │ │ │ ├── Poll.kt │ │ │ │ │ ├── Story.kt │ │ │ │ │ ├── Ask.kt │ │ │ │ │ ├── User.kt │ │ │ │ │ ├── Job.kt │ │ │ │ │ ├── Comment.kt │ │ │ │ │ └── Item.kt │ │ │ │ ├── interactors │ │ │ │ │ ├── GetComments.kt │ │ │ │ │ ├── GetItems.kt │ │ │ │ │ ├── GetStories.kt │ │ │ │ │ └── GetPollOptions.kt │ │ │ │ └── repositories │ │ │ │ │ └── ItemRepository.kt │ │ │ ├── utils │ │ │ │ └── Constants.kt │ │ │ ├── extensions │ │ │ │ ├── ItemExtension.kt │ │ │ │ ├── UrlExtension.kt │ │ │ │ └── TimeExtension.kt │ │ │ ├── App.kt │ │ │ ├── data │ │ │ │ ├── local │ │ │ │ │ └── AppPreferences.kt │ │ │ │ └── remote │ │ │ │ │ ├── models │ │ │ │ │ └── RawItem.kt │ │ │ │ │ └── ApiHandler.kt │ │ │ ├── Platform.kt │ │ │ └── ui │ │ │ │ ├── Type.kt │ │ │ │ └── Theme.kt │ │ └── composeResources │ │ │ ├── font │ │ │ ├── product_sans_bold.ttf │ │ │ ├── product_sans_italic.ttf │ │ │ ├── product_sans_regular.ttf │ │ │ └── google_sans_code_regular.ttf │ │ │ ├── drawable │ │ │ ├── ic_alt_arrow_down_linear.xml │ │ │ ├── ic_link_minimalistic_linear.xml │ │ │ ├── ic_arrow_left_linear.xml │ │ │ ├── ic_clock_circle_linear.xml │ │ │ ├── ic_info_circle_linear.xml │ │ │ ├── ic_square_top_down_linear.xml │ │ │ ├── ic_user_circle_linear.xml │ │ │ ├── ic_square_share_line_linear.xml │ │ │ ├── ic_refresh_linear.xml │ │ │ ├── ic_like_outline.xml │ │ │ ├── ic_global_outline.xml │ │ │ ├── ic_comments_share.xml │ │ │ ├── ic_chat_line_linear.xml │ │ │ └── ic_launcher_mono.xml │ │ │ ├── values-zh-rTW │ │ │ └── strings.xml │ │ │ └── values │ │ │ └── strings.xml │ ├── androidMain │ │ ├── res │ │ │ ├── values │ │ │ │ ├── strings.xml │ │ │ │ ├── colors.xml │ │ │ │ └── theme.xml │ │ │ ├── drawable │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── pulse.xml │ │ │ └── values-v29 │ │ │ │ └── theme.xml │ │ ├── kotlin │ │ │ ├── com │ │ │ │ └── jarvislin │ │ │ │ │ └── hackernews │ │ │ │ │ ├── HnKmp.kt │ │ │ │ │ └── MainActivity.kt │ │ │ └── Platform.android.kt │ │ └── AndroidManifest.xml │ └── iosMain │ │ └── kotlin │ │ ├── MainViewController.kt │ │ └── Platform.ios.kt └── build.gradle.kts ├── iosApp ├── Configuration │ └── Config.xcconfig ├── iosApp │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── AppIcon-29.png │ │ │ ├── AppIcon@2x.png │ │ │ ├── AppIcon@3x.png │ │ │ ├── AppIcon-20@2x.png │ │ │ ├── AppIcon-20@3x.png │ │ │ ├── AppIcon-29@2x.png │ │ │ ├── AppIcon-29@3x.png │ │ │ ├── AppIcon-40@2x.png │ │ │ ├── AppIcon-40@3x.png │ │ │ ├── AppIcon~ipad.png │ │ │ ├── AppIcon-20~ipad.png │ │ │ ├── AppIcon-29~ipad.png │ │ │ ├── AppIcon-40~ipad.png │ │ │ ├── AppIcon@2x~ipad.png │ │ │ ├── AppIcon-20@2x~ipad.png │ │ │ ├── AppIcon-29@2x~ipad.png │ │ │ ├── AppIcon-40@2x~ipad.png │ │ │ ├── AppIcon-60@2x~car.png │ │ │ ├── AppIcon-60@3x~car.png │ │ │ ├── AppIcon-83.5@2x~ipad.png │ │ │ ├── AppIcon~ios-marketing.png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── iOSApp.swift │ ├── ContentView.swift │ └── Info.plist └── iosApp.xcodeproj │ └── project.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── resources ├── ic_launcher-playstore.png ├── screenshots-v6 │ ├── ios │ │ ├── dark-about.png │ │ ├── dark-home.png │ │ ├── dark-share.png │ │ ├── light-about.png │ │ ├── light-home.png │ │ ├── light-share.png │ │ ├── dark-comments.png │ │ ├── dark-webview.png │ │ ├── light-webview.png │ │ └── light-comments.png │ └── android │ │ ├── dark-home.png │ │ ├── dark-about.png │ │ ├── dark-desktop.png │ │ ├── dark-share.png │ │ ├── dark-webview.png │ │ ├── light-about.png │ │ ├── light-home.png │ │ ├── light-share.png │ │ ├── dark-comments.png │ │ ├── light-comments.png │ │ ├── light-desktop.png │ │ └── light-webview.png └── hn-app-icon-foreground.svg ├── gradle.properties ├── .gitignore ├── .fleet └── receipt.json ├── settings.gradle.kts ├── README.md ├── AGENTS.md ├── gradlew.bat ├── terms.md └── privacy.md /composeApp/src/commonMain/kotlin/presentation/widgets/SwipeContainer.kt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iosApp/Configuration/Config.xcconfig: -------------------------------------------------------------------------------- 1 | TEAM_ID=T4ACFKQRDQ 2 | BUNDLE_ID=com.jarvislin.hackernews 3 | APP_NAME=Pulse 4 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Pulse 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /resources/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/ic_launcher-playstore.png -------------------------------------------------------------------------------- /resources/screenshots-v6/ios/dark-about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/ios/dark-about.png -------------------------------------------------------------------------------- /resources/screenshots-v6/ios/dark-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/ios/dark-home.png -------------------------------------------------------------------------------- /resources/screenshots-v6/ios/dark-share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/ios/dark-share.png -------------------------------------------------------------------------------- /resources/screenshots-v6/ios/light-about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/ios/light-about.png -------------------------------------------------------------------------------- /resources/screenshots-v6/ios/light-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/ios/light-home.png -------------------------------------------------------------------------------- /resources/screenshots-v6/ios/light-share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/ios/light-share.png -------------------------------------------------------------------------------- /iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } -------------------------------------------------------------------------------- /resources/screenshots-v6/android/dark-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/android/dark-home.png -------------------------------------------------------------------------------- /resources/screenshots-v6/ios/dark-comments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/ios/dark-comments.png -------------------------------------------------------------------------------- /resources/screenshots-v6/ios/dark-webview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/ios/dark-webview.png -------------------------------------------------------------------------------- /resources/screenshots-v6/ios/light-webview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/ios/light-webview.png -------------------------------------------------------------------------------- /resources/screenshots-v6/android/dark-about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/android/dark-about.png -------------------------------------------------------------------------------- /resources/screenshots-v6/android/dark-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/android/dark-desktop.png -------------------------------------------------------------------------------- /resources/screenshots-v6/android/dark-share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/android/dark-share.png -------------------------------------------------------------------------------- /resources/screenshots-v6/android/dark-webview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/android/dark-webview.png -------------------------------------------------------------------------------- /resources/screenshots-v6/android/light-about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/android/light-about.png -------------------------------------------------------------------------------- /resources/screenshots-v6/android/light-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/android/light-home.png -------------------------------------------------------------------------------- /resources/screenshots-v6/android/light-share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/android/light-share.png -------------------------------------------------------------------------------- /resources/screenshots-v6/ios/light-comments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/ios/light-comments.png -------------------------------------------------------------------------------- /resources/screenshots-v6/android/dark-comments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/android/dark-comments.png -------------------------------------------------------------------------------- /resources/screenshots-v6/android/light-comments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/android/light-comments.png -------------------------------------------------------------------------------- /resources/screenshots-v6/android/light-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/android/light-desktop.png -------------------------------------------------------------------------------- /resources/screenshots-v6/android/light-webview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/resources/screenshots-v6/android/light-webview.png -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/MainViewController.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.ui.window.ComposeUIViewController 2 | 3 | fun MainViewController() = ComposeUIViewController { App() } -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #DD7740 4 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon@2x.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon@3x.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarvislin/HackerNews-KMP/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon~ipad.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/theme.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/modules/ViewModelModule.kt: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import org.koin.dsl.module 4 | import presentation.viewmodels.DetailsViewModel 5 | import presentation.viewmodels.MainViewModel 6 | 7 | val viewModelModule = module { 8 | single { MainViewModel(get(), get(), get()) } // use single for keeping state 9 | factory { DetailsViewModel(get(), get()) } // use factory for cleaning state every time the screen is closed 10 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/modules/UseCaseModule.kt: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import domain.interactors.GetComments 4 | import domain.interactors.GetItems 5 | import domain.interactors.GetPollOptions 6 | import domain.interactors.GetStories 7 | import org.koin.dsl.module 8 | 9 | val useCaseModule = module { 10 | factory { GetStories(get()) } 11 | factory { GetItems(get()) } 12 | factory { GetComments(get()) } 13 | factory { GetPollOptions(get()) } 14 | } -------------------------------------------------------------------------------- /.fleet/receipt.json: -------------------------------------------------------------------------------- 1 | // Project generated by Kotlin Multiplatform Wizard 2 | { 3 | "spec": { 4 | "template_id": "kmt", 5 | "targets": { 6 | "android": { 7 | "ui": [ 8 | "compose" 9 | ] 10 | }, 11 | "ios": { 12 | "ui": [ 13 | "compose" 14 | ] 15 | } 16 | } 17 | }, 18 | "timestamp": "2024-06-20T08:43:05.487831893Z" 19 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_alt_arrow_down_linear.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_link_minimalistic_linear.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/repositories/ItemRepository.kt: -------------------------------------------------------------------------------- 1 | package domain.repositories 2 | 3 | import domain.models.Category 4 | import domain.models.Comment 5 | import domain.models.Item 6 | import kotlinx.coroutines.flow.Flow 7 | 8 | interface ItemRepository { 9 | suspend fun fetchItems(ids: List): List> 10 | suspend fun fetchStories(category: Category): Result> 11 | suspend fun fetchComments(depth:Int, ids: List): Flow> 12 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_arrow_left_linear.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /iosApp/iosApp/ContentView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import ComposeApp 4 | 5 | struct ComposeView: UIViewControllerRepresentable { 6 | func makeUIViewController(context: Context) -> UIViewController { 7 | MainViewControllerKt.MainViewController() 8 | } 9 | 10 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} 11 | } 12 | 13 | struct ContentView: View { 14 | var body: some View { 15 | ComposeView() 16 | .ignoresSafeArea(.all) 17 | } 18 | } 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_clock_circle_linear.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 16 | 17 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/extensions/UrlExtension.kt: -------------------------------------------------------------------------------- 1 | package extensions 2 | 3 | import io.ktor.http.Url 4 | import utils.Constants 5 | import kotlin.time.ExperimentalTime 6 | 7 | 8 | @OptIn(ExperimentalTime::class) 9 | fun String.toUrl(): Url? = runCatching { Url(this) }.getOrNull() 10 | 11 | fun Url.trimmedHostName(): String { 12 | val hostName = host 13 | return if (hostName.startsWith("www.")) { 14 | hostName.substring(4) 15 | } else { 16 | hostName 17 | } 18 | } 19 | 20 | fun Url.faviconUrl(): String = 21 | "https://www.google.com/s2/favicons?domain=$host&sz=128" 22 | 23 | fun Url.isPdf(): Boolean = 24 | rawSegments.lastOrNull()?.endsWith(Constants.PDF_EXTENSION) ?: false 25 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_info_circle_linear.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/interactors/GetPollOptions.kt: -------------------------------------------------------------------------------- 1 | package domain.interactors 2 | 3 | import domain.models.PollOption 4 | import domain.models.UnknownError 5 | import domain.models.getPoint 6 | import domain.repositories.ItemRepository 7 | 8 | class GetPollOptions(private val repository: ItemRepository) { 9 | suspend operator fun invoke(ids: List): Result> = 10 | repository.fetchItems(ids) 11 | .mapNotNull { it.getOrNull() } 12 | .map { it as PollOption } 13 | .sortedByDescending { it.getPoint() } 14 | .let { 15 | if (it.size == ids.size) Result.success(it) 16 | else Result.failure(UnknownError) 17 | } 18 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/jarvislin/hackernews/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.jarvislin.hackernews 2 | 3 | import App 4 | import android.os.Bundle 5 | import androidx.activity.ComponentActivity 6 | import androidx.activity.compose.setContent 7 | import androidx.activity.enableEdgeToEdge 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.tooling.preview.Preview 10 | 11 | class MainActivity : ComponentActivity() { 12 | override fun onCreate(savedInstanceState: Bundle?) { 13 | super.onCreate(savedInstanceState) 14 | 15 | enableEdgeToEdge() 16 | 17 | setContent { 18 | App() 19 | } 20 | } 21 | } 22 | 23 | @Preview 24 | @Composable 25 | fun AppAndroidPreview() { 26 | App() 27 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_square_top_down_linear.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 17 | 18 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_user_circle_linear.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 19 | 20 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/App.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.foundation.isSystemInDarkTheme 2 | import androidx.compose.material3.MaterialTheme 3 | import androidx.compose.runtime.Composable 4 | import modules.dataModule 5 | import modules.repositoryModule 6 | import modules.useCaseModule 7 | import modules.viewModelModule 8 | import org.jetbrains.compose.ui.tooling.preview.Preview 9 | import org.koin.compose.KoinApplication 10 | import presentation.RootScreen 11 | import ui.AppTheme 12 | 13 | @Composable 14 | @Preview 15 | fun App() { 16 | KoinApplication(application = { 17 | modules( 18 | dataModule, 19 | repositoryModule, 20 | useCaseModule, 21 | viewModelModule 22 | ) 23 | }) { 24 | AppTheme { 25 | RootScreen() 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "HackerNewsKMP" 2 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 3 | 4 | pluginManagement { 5 | repositories { 6 | google { 7 | mavenContent { 8 | includeGroupAndSubgroups("androidx") 9 | includeGroupAndSubgroups("com.android") 10 | includeGroupAndSubgroups("com.google") 11 | } 12 | } 13 | mavenCentral() 14 | gradlePluginPortal() 15 | } 16 | } 17 | 18 | dependencyResolutionManagement { 19 | repositories { 20 | google { 21 | mavenContent { 22 | includeGroupAndSubgroups("androidx") 23 | includeGroupAndSubgroups("com.android") 24 | includeGroupAndSubgroups("com.google") 25 | } 26 | } 27 | mavenCentral() 28 | } 29 | } 30 | 31 | include(":composeApp") -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_square_share_line_linear.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 17 | 18 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/local/AppPreferences.kt: -------------------------------------------------------------------------------- 1 | package data.local 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import androidx.datastore.preferences.core.edit 6 | import androidx.datastore.preferences.core.stringSetPreferencesKey 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.map 9 | 10 | 11 | class AppPreferences(private val dataStore: DataStore) { 12 | val seenItemList: Flow> = 13 | dataStore.data.map { preferences -> 14 | preferences[SEEN_ITEMS] ?: emptySet() 15 | } 16 | 17 | suspend fun markItemAsSeen(itemId: String) { 18 | dataStore.edit { preferences -> 19 | val currentSeenItems = preferences[SEEN_ITEMS] ?: emptySet() 20 | preferences[SEEN_ITEMS] = currentSeenItems + itemId 21 | } 22 | } 23 | 24 | companion object { 25 | val SEEN_ITEMS = stringSetPreferencesKey("seen_items") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/models/PollOption.kt: -------------------------------------------------------------------------------- 1 | package domain.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * Represents a poll option. 8 | */ 9 | @Serializable 10 | @SerialName("pollopt") 11 | data class PollOption( 12 | @SerialName("by") 13 | val userName: String, 14 | @SerialName("id") 15 | val id: Long, 16 | @SerialName("poll") 17 | val pollId: Long, 18 | @SerialName("score") 19 | val score: Int, 20 | @SerialName("text") 21 | val text: String, 22 | @SerialName("time") 23 | val time: Long, 24 | @SerialName("type") 25 | val type: String, 26 | ) : Item() 27 | 28 | val samplePollOptionJson = """ 29 | { 30 | "by" : "pg", 31 | "id" : 160705, 32 | "poll" : 160704, 33 | "score" : 335, 34 | "text" : "Yes, ban them; I'm tired of seeing Valleywag stories on News.YC.", 35 | "time" : 1207886576, 36 | "type" : "pollopt" 37 | } 38 | """.trimIndent() -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/extensions/TimeExtension.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package extensions 4 | 5 | import kotlinx.datetime.LocalDate 6 | import kotlinx.datetime.LocalDateTime 7 | import kotlinx.datetime.LocalTime 8 | import kotlinx.datetime.TimeZone 9 | import kotlinx.datetime.format.char 10 | import kotlinx.datetime.toLocalDateTime 11 | import kotlin.time.ExperimentalTime 12 | import kotlin.time.Instant 13 | 14 | object TimeExtension { 15 | @OptIn(ExperimentalTime::class) 16 | fun Long.toLocalDateTime() = toInstant() 17 | .toLocalDateTime(TimeZone.currentSystemDefault()) 18 | 19 | @OptIn(ExperimentalTime::class) 20 | fun Long.toInstant() = Instant.fromEpochSeconds(this) 21 | 22 | fun LocalDateTime.format(): String { 23 | val dateTimeFormat = LocalDateTime.Format { 24 | date(LocalDate.Formats.ISO) 25 | char(' ') 26 | time(LocalTime.Formats.ISO) 27 | } 28 | return dateTimeFormat.format(this) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/Platform.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.material3.ColorScheme 2 | import androidx.compose.material3.Typography 3 | import androidx.compose.runtime.Composable 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import com.multiplatform.webview.request.RequestInterceptor 7 | 8 | interface Platform { 9 | val name: String 10 | val appName: String 11 | val appVersionName: String 12 | val appVersionCode: Int 13 | 14 | /** 15 | * Gets the singleton DataStore instance, creating it if necessary. 16 | */ 17 | fun createDataStore(): DataStore 18 | 19 | fun webRequestInterceptor(): RequestInterceptor? 20 | 21 | fun share(title: String, text: String) 22 | 23 | fun getDefaultBrowserName(urlString: String): String? 24 | 25 | @Composable 26 | fun getScreenWidth(): Float 27 | fun isAndroid(): Boolean 28 | @Composable 29 | fun getTypography(): Typography 30 | @Composable 31 | fun getColorScheme(darkTheme: Boolean): ColorScheme 32 | } 33 | 34 | expect fun getPlatform(): Platform -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/models/Category.kt: -------------------------------------------------------------------------------- 1 | package domain.models 2 | 3 | sealed class Category(val path: String, val index: Int, val title: String) { 4 | companion object { 5 | fun from(spinnerIndex: Int): Category = when (spinnerIndex) { 6 | 0 -> TopStories 7 | 1 -> NewStories 8 | 2 -> BestStories 9 | 3 -> AskStories 10 | 4 -> ShowStories 11 | 5 -> JobStories 12 | else -> throw IllegalArgumentException("Invalid index") 13 | } 14 | 15 | fun getAll(): List = listOf( 16 | TopStories, NewStories, BestStories, AskStories, ShowStories, JobStories 17 | ) 18 | } 19 | } 20 | 21 | data object TopStories : Category("topstories.json", 0, "Top Stories") 22 | data object NewStories : Category("newstories.json", 1, "New Stories") 23 | data object BestStories : Category("beststories.json", 2, "Best Stories") 24 | data object AskStories : Category("askstories.json", 3, "Ask Stories") 25 | data object ShowStories : Category("showstories.json", 4, "Show Stories") 26 | data object JobStories : Category("jobstories.json", 5, "Job Stories") 27 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_refresh_linear.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/remote/models/RawItem.kt: -------------------------------------------------------------------------------- 1 | package data.remote.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | import kotlinx.serialization.json.Json 6 | 7 | @Serializable 8 | class RawItem( 9 | @SerialName("id") 10 | val id: Long, 11 | @SerialName("deleted") 12 | val deleted: Boolean = false, 13 | @SerialName("type") 14 | val type: String, 15 | @SerialName("by") 16 | val userName: String? = null, // if deleted 17 | @SerialName("time") 18 | val time: Long, 19 | @SerialName("text") 20 | val text: String? = null, // HTML 21 | @SerialName("dead") 22 | val dead: Boolean = false, 23 | @SerialName("parent") 24 | val parentId: Long? = null, 25 | @SerialName("poll") 26 | val pollId: Long? = null, 27 | @SerialName("kids") 28 | val commentIds: List? = null, 29 | @SerialName("url") 30 | val url: String? = null, 31 | @SerialName("score") 32 | val score: Long? = null, 33 | @SerialName("descendants") 34 | val countOfComment: Int = 0, 35 | @SerialName("title") 36 | val title: String? = null, // HTML 37 | @SerialName("parts") 38 | val optionIds: List? = null, 39 | ) { 40 | companion object { 41 | fun from(json: Json, text: String): RawItem = json.decodeFromString(text) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_like_outline.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/models/Poll.kt: -------------------------------------------------------------------------------- 1 | package domain.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * Represents a poll. 8 | */ 9 | @Serializable 10 | @SerialName("poll") 11 | data class Poll( 12 | @SerialName("by") 13 | val userName: String, 14 | @SerialName("descendants") 15 | val countOfComment: Int, 16 | @SerialName("id") 17 | val id: Long, 18 | @SerialName("kids") 19 | val commentIds: List, 20 | @SerialName("parts") 21 | val optionIds: List, 22 | @SerialName("score") 23 | val score: Int, 24 | @SerialName("text") 25 | val text: String? = null, 26 | @SerialName("time") 27 | val time: Long, 28 | @SerialName("title") 29 | val title: String, 30 | @SerialName("type") 31 | val type: String, 32 | ) : Item() 33 | 34 | val samplePollJson = """ 35 | { 36 | "by" : "pg", 37 | "descendants" : 54, 38 | "id" : 126809, 39 | "kids" : [ 126822, 126823, 126993, 126824, 126934, 127411, 126888, 127681, 126818, 126816, 126854, 127095, 126861, 127313, 127299, 126859, 126852, 126882, 126832, 127072, 127217, 126889, 127535, 126917, 126875 ], 40 | "parts" : [ 126810, 126811, 126812 ], 41 | "score" : 46, 42 | "text" : "", 43 | "time" : 1204403652, 44 | "title" : "Poll: What would happen if News.YC had explicit support for polls?", 45 | "type" : "poll" 46 | } 47 | """.trimIndent() -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/models/Story.kt: -------------------------------------------------------------------------------- 1 | package domain.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * Represents a story. 8 | */ 9 | @Serializable 10 | @SerialName("story") 11 | data class Story( 12 | @SerialName("by") 13 | val userName: String, 14 | @SerialName("descendants") 15 | val countOfComment: Int, 16 | @SerialName("id") 17 | val id: Long, 18 | @SerialName("kids") 19 | val commentIds: List = emptyList(), 20 | @SerialName("score") 21 | val score: Int, 22 | @SerialName("time") 23 | val time: Long, 24 | @SerialName("title") 25 | val title: String, 26 | @SerialName("text") 27 | val text: String? = null, 28 | @SerialName("url") 29 | val url: String? = null, 30 | @SerialName("type") 31 | val type: String, 32 | ) : Item() 33 | 34 | val sampleStoryJson = """ 35 | { 36 | "by" : "dhouston", 37 | "descendants" : 71, 38 | "id" : 8863, 39 | "kids" : [ 8952, 9224, 8917, 8884, 8887, 8943, 8869, 8958, 9005, 9671, 8940, 9067, 8908, 9055, 8865, 8881, 8872, 8873, 8955, 10403, 8903, 8928, 9125, 8998, 8901, 8902, 8907, 8894, 8878, 8870, 8980, 8934, 8876 ], 40 | "score" : 111, 41 | "time" : 1175714200, 42 | "title" : "My YC app: Dropbox - Throw away your USB drive", 43 | "type" : "story", 44 | "url" : "http://www.getdropbox.com/u/2/screencast.html" 45 | } 46 | """.trimIndent() 47 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 關於 4 | 發生了錯誤 5 | 返回 6 | Hacker News KMP App 圖示 7 | 重試 8 | 讀取中... 9 | %1$d 點 10 | Webview 錯誤: %1$s 11 | 返回 12 | 重新讀取網頁 13 | 開源函式庫 14 | 使用預設瀏覽器開啟 15 | 16 | 使用 %1$s 開啟 17 | 18 | 版本 %1$s(%2$s) • %3$s 19 | 瀏覽留言 20 | 詳細內容 21 | 返回 22 | 尚無留言 23 | 24 | %1$d 則留言 25 | %1$d 則留言 26 | 27 | 分享連結 28 | 分享留言 29 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_global_outline.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | About 3 | An error occurred 4 | Back 5 | Hacker News KMP App Icon 6 | Retry 7 | Loading... 8 | %1$d points 9 | Webview error: %1$s 10 | Go back 11 | Reload the web page 12 | Open Source Libraries 13 | Open with the default browser 14 | 15 | Open with %1$s 16 | 17 | Version %1$s(%2$s) • %3$s 18 | Browse comments 19 | Details 20 | Back 21 | No Comment 22 | 23 | %1$d comment 24 | %1$d comments 25 | 26 | Share Link 27 | Share Comments 28 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/models/Ask.kt: -------------------------------------------------------------------------------- 1 | package domain.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * Represents an Ask item. 8 | */ 9 | @Serializable 10 | @SerialName("ask") 11 | data class Ask( 12 | @SerialName("by") 13 | val userName: String, 14 | @SerialName("descendants") 15 | val countOfComment: Int? = null, 16 | @SerialName("id") 17 | val id: Long, 18 | @SerialName("kids") 19 | val commentIds: List = emptyList(), 20 | @SerialName("score") 21 | val score: Int, 22 | @SerialName("text") 23 | val text: String? = null, 24 | @SerialName("time") 25 | val time: Long, 26 | @SerialName("title") 27 | val title: String, 28 | @SerialName("type") 29 | val type: String, 30 | ) : Item() 31 | 32 | val sampleAskJson = """ 33 | { 34 | "by" : "tel", 35 | "descendants" : 16, 36 | "id" : 121003, 37 | "kids" : [ 121016, 121109, 121168 ], 38 | "score" : 25, 39 | "text" : "or HN: the Next Iteration

I get the impression that with Arc being released a lot of people who never had time for HN before are suddenly dropping in more often. (PG: what are the numbers on this? I'm envisioning a spike.)

Not to say that isn't great, but I'm wary of Diggification. Between links comparing programming to sex and a flurry of gratuitous, ostentatious adjectives in the headlines it's a bit concerning.

80% of the stuff that makes the front page is still pretty awesome, but what's in place to keep the signal/noise ratio high? Does the HN model still work as the community scales? What's in store for (++ HN)?", 40 | "time" : 1203647620, 41 | "title" : "Ask HN: The Arc Effect", 42 | "type" : "story" 43 | } 44 | """.trimIndent() -------------------------------------------------------------------------------- /iosApp/iosApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CADisableMinimumFrameDurationOnPhone 6 | 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.3.1 21 | CFBundleVersion 22 | 8 23 | LSRequiresIPhoneOS 24 | 25 | UIApplicationSceneManifest 26 | 27 | UIApplicationSupportsMultipleScenes 28 | 29 | 30 | UILaunchScreen 31 | 32 | UIRequiredDeviceCapabilities 33 | 34 | armv7 35 | 36 | UISupportedInterfaceOrientations 37 | 38 | UIInterfaceOrientationPortrait 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | ITSAppUsesNonExemptEncryption 43 | 44 | UISupportedInterfaceOrientations~ipad 45 | 46 | UIInterfaceOrientationPortrait 47 | UIInterfaceOrientationPortraitUpsideDown 48 | UIInterfaceOrientationLandscapeLeft 49 | UIInterfaceOrientationLandscapeRight 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/Route.kt: -------------------------------------------------------------------------------- 1 | package presentation 2 | 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.navigation.NavHostController 7 | import androidx.navigation.compose.NavHost 8 | import androidx.navigation.compose.composable 9 | import androidx.navigation.compose.rememberNavController 10 | import androidx.navigation.toRoute 11 | import presentation.screens.about.AboutRoute 12 | import presentation.screens.about.AboutScreen 13 | import presentation.screens.details.DetailsRoute 14 | import presentation.screens.details.DetailsScreen 15 | import presentation.screens.details.DetailsScreenTab 16 | import presentation.screens.main.MainRoute 17 | import presentation.screens.main.MainScreen 18 | 19 | @Composable 20 | fun RootScreen(navController: NavHostController = rememberNavController()) { 21 | NavHost( 22 | navController = navController, 23 | startDestination = MainRoute, 24 | modifier = Modifier.fillMaxSize() 25 | ) { 26 | composable { 27 | MainScreen( 28 | onClickItem = { navController.navigate(DetailsRoute(id = it.getItemId(), tab = DetailsScreenTab.Webview.name)) }, 29 | onClickComment = { navController.navigate(DetailsRoute(id = it.getItemId(), tab = DetailsScreenTab.Comments.name)) }, 30 | onClickAbout = { navController.navigate(AboutRoute) } 31 | ) 32 | } 33 | 34 | composable { backStackEntry -> 35 | val route = backStackEntry.toRoute() 36 | DetailsScreen( 37 | itemId = route.id, 38 | tab = DetailsScreenTab.from(route.tab), 39 | onBack = { navController.popBackStack() }, 40 | ) 41 | } 42 | 43 | composable { 44 | AboutScreen(onBack = { navController.popBackStack() }) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/modules/DataModule.kt: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import data.local.AppPreferences 4 | import data.remote.ApiHandler 5 | import domain.models.Ask 6 | import domain.models.Comment 7 | import domain.models.Item 8 | import domain.models.Job 9 | import domain.models.Poll 10 | import domain.models.PollOption 11 | import domain.models.Story 12 | import getPlatform 13 | import io.github.aakira.napier.DebugAntilog 14 | import io.github.aakira.napier.Napier 15 | import io.ktor.client.HttpClient 16 | import io.ktor.client.plugins.logging.Logger 17 | import io.ktor.client.plugins.logging.Logging 18 | import kotlinx.serialization.json.Json 19 | import kotlinx.serialization.modules.SerializersModule 20 | import kotlinx.serialization.modules.polymorphic 21 | import org.koin.dsl.module 22 | 23 | val dataModule = module { 24 | single { 25 | val module = SerializersModule { 26 | polymorphic(Item::class) { 27 | subclass(Ask::class, Ask.serializer()) 28 | subclass(Comment::class, Comment.serializer()) 29 | subclass(Job::class, Job.serializer()) 30 | subclass(Poll::class, Poll.serializer()) 31 | subclass(PollOption::class, PollOption.serializer()) 32 | subclass(Story::class, Story.serializer()) 33 | } 34 | } 35 | Json { 36 | serializersModule = module 37 | ignoreUnknownKeys = true 38 | classDiscriminator = "kind" // Because "type" is a named field in the HN api 39 | } 40 | } 41 | single { 42 | HttpClient { 43 | install(Logging) { 44 | logger = object : Logger { 45 | override fun log(message: String) { 46 | Napier.i(message) 47 | } 48 | } 49 | } 50 | }.also { Napier.base(DebugAntilog()) } 51 | } 52 | single { ApiHandler } 53 | 54 | single { getPlatform().createDataStore() } 55 | 56 | single { AppPreferences(get()) } 57 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/remote/ApiHandler.kt: -------------------------------------------------------------------------------- 1 | package data.remote 2 | 3 | import domain.models.NetworkError 4 | import domain.models.ParseError 5 | import domain.models.UnknownError 6 | import io.github.aakira.napier.Napier 7 | import io.ktor.client.statement.HttpResponse 8 | import io.ktor.client.statement.bodyAsText 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.IO 11 | import kotlinx.coroutines.withContext 12 | import kotlinx.serialization.KSerializer 13 | import kotlinx.serialization.json.Json 14 | 15 | /** 16 | * Handler to run API requests. 17 | */ 18 | object ApiHandler { 19 | /** 20 | * Run a suspend block and return the response body as a string. 21 | */ 22 | suspend fun run( 23 | block: suspend () -> HttpResponse 24 | ): Result = withContext(Dispatchers.IO) { 25 | try { 26 | val result = block() 27 | if (result.status.value in 200..299) { 28 | Result.success(result.bodyAsText()) 29 | } else { 30 | Result.failure(UnknownError) 31 | } 32 | } catch (exception: Exception) { 33 | Napier.e(exception.message ?: "Failed to fetch data", exception) 34 | Result.failure(NetworkError) 35 | } 36 | } 37 | 38 | /** 39 | * Run a suspend block and parse the response body to a type. 40 | */ 41 | suspend fun runAndParse( 42 | json: Json, 43 | type: KSerializer, 44 | block: suspend () -> HttpResponse 45 | ): Result { 46 | val result = run(block) 47 | return if (result.isSuccess) { 48 | try { 49 | Result.success(json.decodeFromString(type, result.getOrThrow())) 50 | } catch (exception: Exception) { 51 | Napier.e(exception.message ?: "Failed to parse data", exception) 52 | Result.failure(ParseError) 53 | } 54 | } else { 55 | Result.failure(result.exceptionOrNull() ?: UnknownError) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/widgets/IndentedBox.kt: -------------------------------------------------------------------------------- 1 | package presentation.widgets 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.BoxScope 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.draw.drawWithContent 12 | import androidx.compose.ui.geometry.Offset 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.graphics.StrokeCap 15 | import androidx.compose.ui.unit.dp 16 | import org.jetbrains.compose.ui.tooling.preview.Preview 17 | import ui.AppPreview 18 | 19 | @Composable 20 | private fun commentDepthColor(depth: Int): Color { 21 | return when (depth % 4) { 22 | 0 -> MaterialTheme.colorScheme.primaryContainer 23 | 1 -> MaterialTheme.colorScheme.secondaryContainer 24 | 2 -> MaterialTheme.colorScheme.inversePrimary 25 | else -> MaterialTheme.colorScheme.surfaceVariant 26 | } 27 | } 28 | 29 | @Composable 30 | fun IndentedBox( 31 | depth: Int, 32 | modifier: Modifier = Modifier, 33 | content: @Composable BoxScope.() -> Unit 34 | ) { 35 | val paddingStart = 12.dp * (depth + 1) 36 | val color = commentDepthColor(depth) 37 | Box( 38 | modifier = modifier 39 | .padding(horizontal = 16.dp) 40 | .padding(start = (paddingStart - 12.dp), top = 6.dp, bottom = 6.dp) 41 | .drawWithContent { 42 | drawLine( 43 | color = color, 44 | start = Offset.Zero, 45 | end = Offset(0f, size.height), 46 | strokeWidth = 2.dp.toPx(), 47 | cap = StrokeCap.Round 48 | ) 49 | drawContent() 50 | } 51 | .padding(start = 12.dp), 52 | content = content 53 | ) 54 | } 55 | 56 | @Preview 57 | @Composable 58 | private fun Preview_CommentWidget() { 59 | AppPreview { 60 | Column { 61 | listOf(0, 1, 2, 3, 4, 5, 3, 3).forEach { 62 | IndentedBox(depth = it) { 63 | Text("Indented $it") 64 | } 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/screens/details/WebviewTabContent.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalMaterial3Api::class) 2 | 3 | package presentation.screens.details 4 | 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.material3.CircularProgressIndicator 8 | import androidx.compose.material3.ExperimentalMaterial3Api 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.Color 14 | import com.multiplatform.webview.web.WebView 15 | import com.multiplatform.webview.web.WebViewNavigator 16 | import com.multiplatform.webview.web.WebViewState 17 | import com.multiplatform.webview.web.rememberWebViewNavigator 18 | import com.multiplatform.webview.web.rememberWebViewState 19 | import extensions.isPdf 20 | import extensions.toUrl 21 | import getPlatform 22 | import utils.Constants 23 | 24 | @Composable 25 | fun WebviewTabContent( 26 | url: String, 27 | modifier: Modifier = Modifier, 28 | defaultBackgroundColor: Color = MaterialTheme.colorScheme.background, 29 | webViewNavigator: WebViewNavigator = rememberWebViewNavigator( 30 | requestInterceptor = getPlatform().webRequestInterceptor() 31 | ), 32 | webViewState: WebViewState = rememberWebViewState( 33 | url = wrapUrl(url), 34 | extraSettings = { 35 | backgroundColor = Color.White 36 | iOSWebSettings.underPageBackgroundColor = defaultBackgroundColor 37 | } 38 | ), 39 | ) { 40 | Box(modifier = modifier.fillMaxSize()) { 41 | WebView( 42 | navigator = webViewNavigator, 43 | state = webViewState, 44 | modifier = Modifier 45 | .fillMaxSize() 46 | ) 47 | if (webViewState.isLoading) { 48 | CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) 49 | } 50 | } 51 | } 52 | 53 | /** 54 | * Some URL's need to be wrapped to be viewable. For instance, to view a PDF, 55 | * it can be wrapped to be viewed from google docs. 56 | */ 57 | private fun wrapUrl(rawUrl: String): String = 58 | when { 59 | (getPlatform().isAndroid() && rawUrl.toUrl()?.isPdf() == true) -> Constants.URL_GOOGLE_DOCS + rawUrl 60 | else -> rawUrl 61 | } 62 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/widgets/SquircleBadge.kt: -------------------------------------------------------------------------------- 1 | package presentation.widgets 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.RowScope 7 | import androidx.compose.foundation.layout.defaultMinSize 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.material3.BadgeDefaults 10 | import androidx.compose.material3.LocalContentColor 11 | import androidx.compose.material3.LocalTextStyle 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.contentColorFor 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.CompositionLocalProvider 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.unit.dp 20 | import sv.lib.squircleshape.SquircleShape 21 | 22 | /** 23 | * Like Material3 Badge, but using the SquircleShape. 24 | */ 25 | @Composable 26 | fun SquircleBadge( 27 | modifier: Modifier = Modifier, 28 | containerColor: Color = MaterialTheme.colorScheme.primaryContainer, 29 | contentColor: Color = contentColorFor(containerColor), 30 | content: @Composable (RowScope.() -> Unit)? = null, 31 | ) { 32 | val size = 16.dp 33 | val shape = SquircleShape(8.dp) 34 | 35 | // Draw badge container. 36 | Row( 37 | modifier = 38 | modifier 39 | .defaultMinSize(minWidth = size, minHeight = size) 40 | .background(color = containerColor, shape = shape) 41 | .then( 42 | if (content != null) 43 | Modifier.padding(horizontal = 4.dp) 44 | else Modifier 45 | ), 46 | verticalAlignment = Alignment.CenterVertically, 47 | horizontalArrangement = Arrangement.Center, 48 | ) { 49 | if (content != null) { 50 | // Not using Surface composable because it blocks touch propagation behind it. 51 | val mergedStyle = LocalTextStyle.current.merge(MaterialTheme.typography.labelSmall) 52 | CompositionLocalProvider( 53 | LocalContentColor provides contentColor, 54 | LocalTextStyle provides mergedStyle, 55 | content = { content() }, 56 | ) 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/repositories/ItemRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package presentation.repositories 2 | 3 | import data.remote.ApiHandler 4 | import domain.models.Category 5 | import domain.models.Comment 6 | import domain.models.Item 7 | import domain.repositories.ItemRepository 8 | import io.ktor.client.HttpClient 9 | import io.ktor.client.request.get 10 | import kotlinx.coroutines.async 11 | import kotlinx.coroutines.awaitAll 12 | import kotlinx.coroutines.coroutineScope 13 | import kotlinx.coroutines.flow.Flow 14 | import kotlinx.coroutines.flow.flow 15 | import kotlinx.serialization.builtins.ListSerializer 16 | import kotlinx.serialization.builtins.serializer 17 | import kotlinx.serialization.json.Json 18 | 19 | class ItemRepositoryImpl( 20 | private val client: HttpClient, 21 | private val json: Json, 22 | private val apiHandler: ApiHandler 23 | ) : ItemRepository { 24 | override suspend fun fetchItems(ids: List): List> = coroutineScope { 25 | ids.map { async { fetchItem(it) } }.awaitAll() 26 | } 27 | 28 | private suspend fun fetchItem(id: Long): Result { 29 | val result = apiHandler.run { client.get("$API_URL/item/$id.json") } 30 | return if (result.isSuccess) { 31 | Result.success(Item.from(json, result.getOrThrow())) 32 | } else { 33 | Result.failure(result.exceptionOrNull()!!) 34 | } 35 | } 36 | 37 | override suspend fun fetchStories(category: Category): Result> = 38 | apiHandler.runAndParse(json, ListSerializer(Long.serializer())) { 39 | client.get("$API_URL/${category.path}") 40 | } 41 | 42 | override suspend fun fetchComments(depth: Int, ids: List): Flow> = flow { 43 | val left = ids.toMutableList() 44 | 45 | while (left.isNotEmpty()) { 46 | val id = left.removeAt(0) 47 | val result = fetchItem(id) 48 | if (result.isSuccess) { 49 | val comment = result.getOrThrow() as? Comment 50 | if (comment != null) { 51 | emit(Result.success(comment)) 52 | if (comment.commentIds.isNotEmpty()) { 53 | left.addAll(0, comment.commentIds) 54 | } 55 | } 56 | } else { 57 | emit(Result.failure(result.exceptionOrNull()!!)) 58 | } 59 | } 60 | } 61 | 62 | companion object { 63 | private const val API_URL = "https://hacker-news.firebaseio.com/v0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /resources/hn-app-icon-foreground.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Static Badge](https://img.shields.io/badge/Platform-iOS-blue?style=flat) 2 | ![Static Badge](https://img.shields.io/badge/Platform-Android-green?style=flat) 3 | ![GitHub License](https://img.shields.io/github/license/jarvislin/HackerNews-KMP?style=flat) 4 | 5 | 6 | # Hacker News KMP 7 | ![hn_16_9](https://github.com/jarvislin/HackerNews-KMP/assets/3839951/bc29705a-6e69-474c-8453-91485d99b458) 8 | 9 | This project is designed to showcase the capabilities of **Kotlin Multiplatform Compose** by implementing both Android and iOS apps. The aim is to demonstrate how effectively this framework can create cross-platform applications and learn new concepts I hadn't encountered before. 10 | 11 | 12 | ## Download 13 | 14 | 15 | 16 | 17 | 18 | ## Article 19 | 20 | [How to Develop and Publish an App on Two Platforms Within a Week?](https://medium.com/p/918cea37dda2) 21 | 22 | 23 | ## Tech Stack 24 | 25 | 1. Entire project written in [Kotlin](https://kotlinlang.org/) 26 | 2. UI developed with [Compose Multiplatform](https://www.jetbrains.com/lp/compose-multiplatform/), following [Material 3](https://m3.material.io/) guidelines 27 | 3. Asynchronous tasks handled with [Coroutines](https://github.com/Kotlin/kotlinx.coroutines) 28 | 4. Dependency injection managed with [Koin](https://github.com/InsertKoinIO/koin) 29 | 5. API interactions handled by [Ktor Client](https://github.com/ktorio/ktor) 30 | 6. Time conversions using [kotlinx-datetime](https://github.com/Kotlin/kotlinx-datetime) 31 | 7. Serialization managed by [kotlinx-serialization](https://github.com/Kotlin/kotlinx.serialization) 32 | 33 | For the full list of dependencies used in the project, please check [this file](https://github.com/jarvislin/HackerNews-KMP/blob/main/gradle/libs.versions.toml). 34 | 35 | ## Architecture 36 | 37 | Architecture follows MVVM and Clean Architecture. 38 | 39 | ![clean_architecture_mvvm](https://github.com/jarvislin/HackerNews-KMP/assets/3839951/a3823b81-1e99-4457-bf7c-fcbe5051ed34) 40 | 41 | ## Contribution 42 | 43 | You're welcome to submit PRs to this repo! I'm using Git Flow, so please create a feature branch for your work. When you're ready to submit a PR, set the base branch to develop. I'll review it as soon as I can. 44 | 45 | ## License 46 | 47 | [Mozilla Public License Version 2.0](https://github.com/jarvislin/HackerNews-KMP/blob/main/LICENSE) 48 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/models/User.kt: -------------------------------------------------------------------------------- 1 | package domain.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * Represents an Ask item. 8 | */ 9 | @Serializable 10 | @SerialName("user") 11 | data class User( 12 | @SerialName("id") 13 | val id: String, 14 | @SerialName("created") 15 | val created: Long, 16 | @SerialName("karma") 17 | val karma: Int, 18 | @SerialName("about") 19 | val about: String, 20 | @SerialName("submitted") 21 | val submitted: List = emptyList(), 22 | ) 23 | 24 | val sampleUserJson = """ 25 | { 26 | "about" : "This is a test", 27 | "created" : 1173923446, 28 | "id" : "jl", 29 | "karma" : 2937, 30 | "submitted" : [ 8265435, 8168423, 8090946, 8090326, 7699907, 7637962, 7596179, 7596163, 7594569, 7562135, 7562111, 7494708, 7494171, 7488093, 7444860, 7327817, 7280290, 7278694, 7097557, 7097546, 7097254, 7052857, 7039484, 6987273, 6649999, 6649706, 6629560, 6609127, 6327951, 6225810, 6111999, 5580079, 5112008, 4907948, 4901821, 4700469, 4678919, 3779193, 3711380, 3701405, 3627981, 3473004, 3473000, 3457006, 3422158, 3136701, 2943046, 2794646, 2482737, 2425640, 2411925, 2408077, 2407992, 2407940, 2278689, 2220295, 2144918, 2144852, 1875323, 1875295, 1857397, 1839737, 1809010, 1788048, 1780681, 1721745, 1676227, 1654023, 1651449, 1641019, 1631985, 1618759, 1522978, 1499641, 1441290, 1440993, 1436440, 1430510, 1430208, 1385525, 1384917, 1370453, 1346118, 1309968, 1305415, 1305037, 1276771, 1270981, 1233287, 1211456, 1210688, 1210682, 1194189, 1193914, 1191653, 1190766, 1190319, 1189925, 1188455, 1188177, 1185884, 1165649, 1164314, 1160048, 1159156, 1158865, 1150900, 1115326, 933897, 924482, 923918, 922804, 922280, 922168, 920332, 919803, 917871, 912867, 910426, 902506, 891171, 807902, 806254, 796618, 786286, 764412, 764325, 642566, 642564, 587821, 575744, 547504, 532055, 521067, 492164, 491979, 383935, 383933, 383930, 383927, 375462, 263479, 258389, 250751, 245140, 243472, 237445, 229393, 226797, 225536, 225483, 225426, 221084, 213940, 213342, 211238, 210099, 210007, 209913, 209908, 209904, 209903, 170904, 165850, 161566, 158388, 158305, 158294, 156235, 151097, 148566, 146948, 136968, 134656, 133455, 129765, 126740, 122101, 122100, 120867, 120492, 115999, 114492, 114304, 111730, 110980, 110451, 108420, 107165, 105150, 104735, 103188, 103187, 99902, 99282, 99122, 98972, 98417, 98416, 98231, 96007, 96005, 95623, 95487, 95475, 95471, 95467, 95326, 95322, 94952, 94681, 94679, 94678, 94420, 94419, 94393, 94149, 94008, 93490, 93489, 92944, 92247, 91713, 90162, 90091, 89844, 89678, 89498, 86953, 86109, 85244, 85195, 85194, 85193, 85192, 84955, 84629, 83902, 82918, 76393, 68677, 61565, 60542, 47745, 47744, 41098, 39153, 38678, 37741, 33469, 12897, 6746, 5252, 4752, 4586, 4289 ] 31 | } 32 | """.trimIndent() -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/models/Job.kt: -------------------------------------------------------------------------------- 1 | package domain.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * Represents a job. 8 | */ 9 | @Serializable 10 | @SerialName("job") 11 | data class Job( 12 | @SerialName("by") 13 | val userName: String, 14 | @SerialName("id") 15 | val id: Long, 16 | @SerialName("score") 17 | val score: Int, 18 | @SerialName("text") 19 | val text: String? = null, 20 | @SerialName("time") 21 | val time: Long, 22 | @SerialName("title") 23 | val title: String, 24 | @SerialName("url") 25 | val url: String? = null, 26 | @SerialName("type") 27 | val type: String, 28 | ) : Item() 29 | 30 | val sampleJobJson = """ 31 | { 32 | "by" : "justin", 33 | "id" : 192327, 34 | "score" : 6, 35 | "text" : "Justin.tv is the biggest live video site online. We serve hundreds of thousands of video streams a day, and have supported up to 50k live concurrent viewers. Our site is growing every week, and we just added a 10 gbps line to our colo. Our unique visitors are up 900% since January.

There are a lot of pieces that fit together to make Justin.tv work: our video cluster, IRC server, our web app, and our monitoring and search services, to name a few. A lot of our website is dependent on Flash, and we're looking for talented Flash Engineers who know AS2 and AS3 very well who want to be leaders in the development of our Flash.

Responsibilities

    * Contribute to product design and implementation discussions\n    * Implement projects from the idea phase to production\n    * Test and iterate code before and after production release \n
\nQualifications

    * You should know AS2, AS3, and maybe a little be of Flex.\n    * Experience building web applications.\n    * A strong desire to work on website with passionate users and ideas for how to improve it.\n    * Experience hacking video streams, python, Twisted or rails all a plus.\n
\nWhile we're growing rapidly, Justin.tv is still a small, technology focused company, built by hackers for hackers. Seven of our ten person team are engineers or designers. We believe in rapid development, and push out new code releases every week. We're based in a beautiful office in the SOMA district of SF, one block from the caltrain station. If you want a fun job hacking on code that will touch a lot of people, JTV is for you.

Note: You must be physically present in SF to work for JTV. Completing the technical problem at http://www.justin.tv/problems/bml will go a long way with us. Cheers!", 36 | "time" : 1210981217, 37 | "title" : "Justin.tv is looking for a Lead Flash Engineer!", 38 | "type" : "job", 39 | "url" : "" 40 | } 41 | """.trimIndent() -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_comments_share.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/ui/Type.kt: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.text.TextStyle 6 | import androidx.compose.ui.text.font.FontFamily 7 | import androidx.compose.ui.text.font.FontStyle 8 | import androidx.compose.ui.text.font.FontWeight 9 | import androidx.compose.ui.text.style.LineHeightStyle 10 | import hackernewskmp.composeapp.generated.resources.Res 11 | import hackernewskmp.composeapp.generated.resources.google_sans_code_regular 12 | import hackernewskmp.composeapp.generated.resources.product_sans_bold 13 | import hackernewskmp.composeapp.generated.resources.product_sans_italic 14 | import hackernewskmp.composeapp.generated.resources.product_sans_regular 15 | import org.jetbrains.compose.resources.Font 16 | 17 | 18 | @Composable 19 | fun googleSansCodeFontFamily() = FontFamily( 20 | Font(resource = Res.font.google_sans_code_regular), 21 | ) 22 | 23 | @Composable 24 | fun productSansFontFamily() = FontFamily( 25 | Font(resource = Res.font.product_sans_regular), 26 | Font(resource = Res.font.product_sans_italic, style = FontStyle.Italic), 27 | Font(resource = Res.font.product_sans_bold, weight = FontWeight.Bold) 28 | ) 29 | 30 | // Default Material 3 typography values 31 | val baseline = Typography() 32 | 33 | // Fix for line height issue on iOS 34 | // See: https://github.com/jarvislin/HackerNews-KMP/issues/15 35 | val trimmedTextStyle = TextStyle( 36 | lineHeightStyle = LineHeightStyle( 37 | alignment = LineHeightStyle.Alignment.Proportional, 38 | trim = LineHeightStyle.Trim.Both 39 | ) 40 | ) 41 | 42 | @Composable 43 | fun appTypography() = Typography( 44 | displayLarge = baseline.displayLarge.copy(fontFamily = productSansFontFamily()), 45 | displayMedium = baseline.displayMedium.copy(fontFamily = productSansFontFamily()), 46 | displaySmall = baseline.displaySmall.copy(fontFamily = productSansFontFamily()), 47 | headlineLarge = baseline.headlineLarge.copy(fontFamily = productSansFontFamily()), 48 | headlineMedium = baseline.headlineMedium.copy(fontFamily = productSansFontFamily()), 49 | headlineSmall = baseline.headlineSmall.copy(fontFamily = productSansFontFamily()), 50 | titleLarge = baseline.titleLarge.copy(fontFamily = productSansFontFamily()), 51 | titleMedium = baseline.titleMedium.copy(fontFamily = productSansFontFamily()), 52 | titleSmall = baseline.titleSmall.copy(fontFamily = productSansFontFamily()), 53 | bodyLarge = baseline.bodyLarge.copy(fontFamily = productSansFontFamily()), 54 | bodyMedium = baseline.bodyMedium.copy(fontFamily = productSansFontFamily()), 55 | bodySmall = baseline.bodySmall.copy(fontFamily = productSansFontFamily()), 56 | labelLarge = baseline.labelLarge.copy(fontFamily = productSansFontFamily()), 57 | labelMedium = baseline.labelMedium.copy(fontFamily = productSansFontFamily()), 58 | labelSmall = baseline.labelSmall.copy(fontFamily = productSansFontFamily()), 59 | ) 60 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Repository Guidelines 2 | 3 | ## Project Structure & Module Organization 4 | HackerNews-KMP centralizes shared Kotlin Multiplatform code inside `composeApp/`. Business logic lives in `composeApp/src/commonMain/kotlin` split into `data/` (network + persistence), `domain/` (use cases), `presentation/` (view models), and `ui/` (screens + components). Platform wrappers sit in `composeApp/src/androidMain` and `composeApp/src/iosMain`. The Swift host is under `iosApp/iosApp`, with per-target settings in `iosApp/Configuration`. Marketing assets and store art remain in `resources/`, while Gradle build logic stays at the repo root (`build.gradle.kts`, `gradle/`). 5 | 6 | ## Build, Test, and Development Commands 7 | - `./gradlew :composeApp:assembleDebug` — builds the Android debug APK in `composeApp/build/outputs/apk`. 8 | - `./gradlew :composeApp:bundleRelease` — produces the Play-ready AAB and runs release code shrinkage. 9 | - `./gradlew :composeApp:check` — executes all unit tests and multiplatform verifications. 10 | - `open iosApp/iosApp.xcodeproj` — launch Xcode, pick a simulator or device, and hit `⌘R` to build/run the iOS app (Gradle-generated `ComposeApp.framework` is already linked). 11 | 12 | ## Coding Style & Naming Conventions 13 | Follow the official Kotlin style guide: 4-space indents, trailing commas for multiline literals, and prefer `val` for immutability. Compose functions use PascalCase nouns (`HnStoryList`, `StoryToolbar`). View-models live under `presentation/.../viewmodel` and end with `ViewModel`. Keep files focused per feature; cross-cutting helpers belong in `extensions/` or `utils/`. When editing Swift, mirror Kotlin naming and keep modules namespaced `HN...` to avoid collisions. 14 | 15 | ## Testing Guidelines 16 | Use `kotlin.test` for common logic and platform runners for target-specific code. Add suites under `composeApp/src/commonTest/kotlin` (shared) or `composeApp/src/androidUnitTest`. Name classes `FeatureScenarioTest` and mirror the package under test. Run `./gradlew :composeApp:testDebugUnitTest` for Android JVM tests and `./gradlew :composeApp:iosSimulatorArm64Test` before pushing to validate iOS frameworks. Aim to cover new view-model branches and data mappers; document flaky test exclusions in the PR. 17 | 18 | ## Commit & Pull Request Guidelines 19 | Work on feature branches (`feature/` or `fix/`) off `develop`, mirroring the repo’s Git Flow history. Keep commit subjects under 72 characters, imperative, and optionally prefix with the scope (`Feature:`, `Fix:`) already in the log. Each PR should explain the user impact, list key Gradle/Xcode commands you ran, attach screenshots or screen recordings for UI changes, and link related issues or store checklist items. Request review only after CI (`:composeApp:check`) is green. 20 | 21 | ## Security & Configuration Tips 22 | Never commit personal API tokens; shared configuration stays in `local.properties` and `iosApp/Configuration/Config.xcconfig`, which are gitignored. If you need new secrets, document placeholder names and update `privacy.md`/`terms.md` when behavior changes. Review bundles for accidental debug logging before tagging releases. 23 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_chat_line_linear.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "filename": "AppIcon@2x.png", 5 | "idiom": "iphone", 6 | "scale": "2x", 7 | "size": "60x60" 8 | }, 9 | { 10 | "filename": "AppIcon@3x.png", 11 | "idiom": "iphone", 12 | "scale": "3x", 13 | "size": "60x60" 14 | }, 15 | { 16 | "filename": "AppIcon~ipad.png", 17 | "idiom": "ipad", 18 | "scale": "1x", 19 | "size": "76x76" 20 | }, 21 | { 22 | "filename": "AppIcon@2x~ipad.png", 23 | "idiom": "ipad", 24 | "scale": "2x", 25 | "size": "76x76" 26 | }, 27 | { 28 | "filename": "AppIcon-83.5@2x~ipad.png", 29 | "idiom": "ipad", 30 | "scale": "2x", 31 | "size": "83.5x83.5" 32 | }, 33 | { 34 | "filename": "AppIcon-40@2x.png", 35 | "idiom": "iphone", 36 | "scale": "2x", 37 | "size": "40x40" 38 | }, 39 | { 40 | "filename": "AppIcon-40@3x.png", 41 | "idiom": "iphone", 42 | "scale": "3x", 43 | "size": "40x40" 44 | }, 45 | { 46 | "filename": "AppIcon-40~ipad.png", 47 | "idiom": "ipad", 48 | "scale": "1x", 49 | "size": "40x40" 50 | }, 51 | { 52 | "filename": "AppIcon-40@2x~ipad.png", 53 | "idiom": "ipad", 54 | "scale": "2x", 55 | "size": "40x40" 56 | }, 57 | { 58 | "filename": "AppIcon-20@2x.png", 59 | "idiom": "iphone", 60 | "scale": "2x", 61 | "size": "20x20" 62 | }, 63 | { 64 | "filename": "AppIcon-20@3x.png", 65 | "idiom": "iphone", 66 | "scale": "3x", 67 | "size": "20x20" 68 | }, 69 | { 70 | "filename": "AppIcon-20~ipad.png", 71 | "idiom": "ipad", 72 | "scale": "1x", 73 | "size": "20x20" 74 | }, 75 | { 76 | "filename": "AppIcon-20@2x~ipad.png", 77 | "idiom": "ipad", 78 | "scale": "2x", 79 | "size": "20x20" 80 | }, 81 | { 82 | "filename": "AppIcon-29.png", 83 | "idiom": "iphone", 84 | "scale": "1x", 85 | "size": "29x29" 86 | }, 87 | { 88 | "filename": "AppIcon-29@2x.png", 89 | "idiom": "iphone", 90 | "scale": "2x", 91 | "size": "29x29" 92 | }, 93 | { 94 | "filename": "AppIcon-29@3x.png", 95 | "idiom": "iphone", 96 | "scale": "3x", 97 | "size": "29x29" 98 | }, 99 | { 100 | "filename": "AppIcon-29~ipad.png", 101 | "idiom": "ipad", 102 | "scale": "1x", 103 | "size": "29x29" 104 | }, 105 | { 106 | "filename": "AppIcon-29@2x~ipad.png", 107 | "idiom": "ipad", 108 | "scale": "2x", 109 | "size": "29x29" 110 | }, 111 | { 112 | "filename": "AppIcon-60@2x~car.png", 113 | "idiom": "car", 114 | "scale": "2x", 115 | "size": "60x60" 116 | }, 117 | { 118 | "filename": "AppIcon-60@3x~car.png", 119 | "idiom": "car", 120 | "scale": "3x", 121 | "size": "60x60" 122 | }, 123 | { 124 | "filename": "AppIcon~ios-marketing.png", 125 | "idiom": "ios-marketing", 126 | "scale": "1x", 127 | "size": "1024x1024" 128 | } 129 | ], 130 | "info": { 131 | "author": "iconkitchen", 132 | "version": 1 133 | } 134 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable/pulse.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 13 | 16 | 19 | 22 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/screens/details/CommentsTabContent.kt: -------------------------------------------------------------------------------- 1 | package presentation.screens.details 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.lazy.rememberLazyListState 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.CompositionLocalProvider 10 | import androidx.compose.runtime.LaunchedEffect 11 | import androidx.compose.runtime.collectAsState 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.runtime.mutableStateOf 14 | import androidx.compose.runtime.remember 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.platform.LocalUriHandler 17 | import androidx.compose.ui.platform.UriHandler 18 | import androidx.compose.ui.unit.dp 19 | import domain.models.Item 20 | import domain.models.Poll 21 | import domain.models.getCommentIds 22 | import presentation.screens.main.ItemLoadingWidget 23 | import presentation.viewmodels.DetailsViewModel 24 | import utils.Constants 25 | 26 | @Composable 27 | fun CommentsTabContent( 28 | item: Item, 29 | contentPadding: PaddingValues, 30 | viewModel: DetailsViewModel, 31 | modifier: Modifier = Modifier, 32 | ) { 33 | val state by viewModel.state 34 | val pollOptions by viewModel.pollOptions.collectAsState() 35 | 36 | LaunchedEffect(item) { 37 | viewModel.fetchItem(item) 38 | } 39 | 40 | val localUriHandler = LocalUriHandler.current 41 | val uriHandler by remember { 42 | mutableStateOf(object : UriHandler { 43 | override fun openUri(uri: String) { 44 | localUriHandler.openUri(decodeUrl(uri)) 45 | } 46 | }) 47 | } 48 | 49 | CompositionLocalProvider(LocalUriHandler provides uriHandler) { 50 | LazyColumn( 51 | modifier = modifier.fillMaxSize(), 52 | contentPadding = contentPadding, 53 | verticalArrangement = Arrangement.spacedBy(8.dp) 54 | ) { 55 | // Content 56 | item(key = "header-${item.getItemId()}") { ItemDetailsSection(item, pollOptions) } 57 | 58 | // Comments 59 | if (viewModel.hasComments()) { 60 | item.getCommentIds().forEach { commentId -> 61 | commentItem( 62 | commentId = commentId, 63 | depth = 0, 64 | getComment = viewModel::getComment, 65 | isCollapsed = viewModel::isCollapsed, 66 | onToggleCollapse = viewModel::toggleCollapse, 67 | countDescendants = viewModel::countDescendants, 68 | ) 69 | } 70 | } 71 | else if (state.loadingComments) { 72 | item(key = "loading-comments") { ItemLoadingWidget() } 73 | } else { 74 | //TODO: error state? 75 | } 76 | } 77 | } 78 | } 79 | 80 | private fun decodeUrl(url: String): String { 81 | val entityPattern = Regex(Constants.REGEX_PATTERN) 82 | return url.replace(entityPattern) { matchResult -> 83 | val codePoint = matchResult.groupValues[1].toInt(16) 84 | CharArray(1) { codePoint.toChar() }.concatToString() 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_launcher_mono.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 13 | 16 | 19 | 22 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/viewmodels/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package presentation.viewmodels 2 | 3 | import androidx.compose.runtime.State 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import data.local.AppPreferences 8 | import domain.interactors.GetItems 9 | import domain.interactors.GetStories 10 | import domain.models.Category 11 | import domain.models.Item 12 | import domain.models.TopStories 13 | import kotlinx.coroutines.launch 14 | 15 | class MainViewModel( 16 | private val getStories: GetStories, 17 | private val getItems: GetItems, 18 | private val appPreferences: AppPreferences, 19 | ) : ViewModel() { 20 | private val _state = mutableStateOf(MainState()) 21 | val state: State = _state 22 | 23 | init { 24 | viewModelScope.launch { 25 | appPreferences.seenItemList.collect { 26 | _state.value = state.value.copy(seenItemsIds = it) 27 | } 28 | } 29 | } 30 | 31 | fun loadNextPage() { 32 | if (state.value.loading) return 33 | if (state.value.error != null) return 34 | 35 | viewModelScope.launch { 36 | _state.value = state.value.copy(loading = true) 37 | if (state.value.itemIds.isEmpty()) { 38 | getStories(state.value.currentCategory) 39 | .onSuccess { _state.value = state.value.copy(itemIds = state.value.itemIds + it) } 40 | .onFailure { _state.value = state.value.copy(error = it) } 41 | } 42 | val nextPageIds = state.value.itemIds.drop(state.value.currentPage * PAGE_SIZE).take(PAGE_SIZE) 43 | val newItems = getItems(nextPageIds) 44 | _state.value = state.value.copy( 45 | loading = false, 46 | refreshing = false, 47 | items = state.value.items + newItems, 48 | currentPage = state.value.currentPage + 1, 49 | ) 50 | } 51 | } 52 | 53 | fun reset() { 54 | _state.value = state.value.copy( 55 | loading = false, itemIds = emptyList(), items = emptyList(), currentPage = 0, error = null 56 | ) 57 | } 58 | 59 | fun onPullToRefresh() { 60 | _state.value = state.value.copy( 61 | refreshing = true, loading = false, itemIds = emptyList(), items = emptyList(), currentPage = 0, error = null 62 | ) 63 | loadNextPage() 64 | } 65 | 66 | fun onClickCategory(item: Category) { 67 | if (state.value.currentCategory == item) return 68 | _state.value = state.value.copy( 69 | currentCategory = item, 70 | loading = false, 71 | refreshing = false, 72 | itemIds = emptyList(), 73 | items = emptyList(), 74 | currentPage = 0, 75 | error = null 76 | ) 77 | loadNextPage() 78 | } 79 | 80 | fun markItemAsSeen(item: Item) { 81 | viewModelScope.launch { 82 | appPreferences.markItemAsSeen(item.getItemId().toString()) 83 | } 84 | } 85 | 86 | companion object { 87 | const val PAGE_SIZE = 20 88 | } 89 | } 90 | 91 | data class MainState( 92 | val items: List = emptyList(), 93 | val itemIds: List = emptyList(), 94 | val seenItemsIds: Set = emptySet(), 95 | val loading: Boolean = false, 96 | val refreshing: Boolean = false, 97 | val error: Throwable? = null, 98 | val currentPage: Int = 0, 99 | val currentCategory: Category = TopStories, 100 | ) -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/widgets/RowOrColumnLayout.kt: -------------------------------------------------------------------------------- 1 | package presentation.widgets 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Surface 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.graphics.Color 12 | import androidx.compose.ui.layout.Layout 13 | import androidx.compose.ui.unit.dp 14 | import org.jetbrains.compose.ui.tooling.preview.Preview 15 | import ui.AppPreview 16 | import kotlin.math.max 17 | 18 | /** 19 | * A layout that arranges its primary and secondary content in a row if they fit, 20 | * otherwise arranges them in a column. 21 | * 22 | * In a row configuration, the primary content is placed at the start and the secondary 23 | * content is placed at the end. 24 | * 25 | * In a column configuration, the primary content is placed at the top and the secondary 26 | * content is placed at the bottom, both start-aligned. 27 | */ 28 | @Composable 29 | fun RowOrColumnLayout( 30 | modifier: Modifier = Modifier, 31 | primary: @Composable () -> Unit, 32 | secondary: @Composable () -> Unit, 33 | ) { 34 | Layout( 35 | content = { 36 | primary() 37 | secondary() 38 | }, 39 | modifier = modifier 40 | ) { measurables, constraints -> 41 | require(measurables.size == 2) { "RowOrColumnLayout requires exactly two children." } 42 | 43 | val primaryPlaceable = measurables[0].measure(constraints.copy(minWidth = 0)) 44 | val secondaryPlaceable = measurables[1].measure(constraints.copy(minWidth = 0)) 45 | 46 | val availableWidth = constraints.maxWidth 47 | 48 | if (primaryPlaceable.width + secondaryPlaceable.width <= availableWidth) { 49 | // Place in a Row 50 | val height = max(primaryPlaceable.height, secondaryPlaceable.height) 51 | layout(availableWidth, height) { 52 | primaryPlaceable.placeRelative(0, (height - primaryPlaceable.height) / 2) 53 | secondaryPlaceable.placeRelative( 54 | x = availableWidth - secondaryPlaceable.width, 55 | y = (height - secondaryPlaceable.height) / 2 56 | ) 57 | } 58 | } else { 59 | // Place in a Column 60 | val width = max(primaryPlaceable.width, secondaryPlaceable.width) 61 | val height = primaryPlaceable.height + secondaryPlaceable.height 62 | layout(width, height) { 63 | primaryPlaceable.placeRelative(0, 0) 64 | secondaryPlaceable.placeRelative(0, primaryPlaceable.height) 65 | } 66 | } 67 | } 68 | } 69 | 70 | @Preview(name = "Row - Fits", widthDp = 400) 71 | @Preview(name = "Column - Does not fit", widthDp = 250) 72 | @Composable 73 | private fun RowOrColumnLayoutPreview() { 74 | AppPreview { 75 | RowOrColumnLayout( 76 | primary = { 77 | Text( 78 | text = "Primary Content (Longer text)", 79 | modifier = Modifier 80 | .background(Color.Yellow) 81 | .padding(8.dp) 82 | ) 83 | }, 84 | secondary = { 85 | Text( 86 | text = "Secondary", 87 | modifier = Modifier 88 | .background(Color.Green) 89 | .padding(8.dp) 90 | ) 91 | } 92 | ) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/Platform.ios.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.material3.ColorScheme 2 | import androidx.compose.material3.Typography 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.ExperimentalComposeUiApi 5 | import androidx.compose.ui.platform.LocalWindowInfo 6 | import androidx.datastore.core.DataStore 7 | import androidx.datastore.preferences.core.PreferenceDataStoreFactory 8 | import androidx.datastore.preferences.core.Preferences 9 | import com.multiplatform.webview.request.RequestInterceptor 10 | import kotlinx.cinterop.BetaInteropApi 11 | import kotlinx.cinterop.ExperimentalForeignApi 12 | import okio.Path.Companion.toPath 13 | import platform.Foundation.NSBundle 14 | import platform.Foundation.NSDocumentDirectory 15 | import platform.Foundation.NSFileManager 16 | import platform.Foundation.NSString 17 | import platform.Foundation.NSURL 18 | import platform.Foundation.NSUserDomainMask 19 | import platform.Foundation.create 20 | import platform.UIKit.UIActivityViewController 21 | import platform.UIKit.UIApplication 22 | import platform.UIKit.UIDevice 23 | import ui.baseline 24 | import ui.darkScheme 25 | import ui.lightScheme 26 | import utils.Constants.DATASTORE_FILE_NAME 27 | 28 | @ExperimentalComposeUiApi 29 | class IOSPlatform : Platform { 30 | override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion 31 | override val appName: String = "Pulse" 32 | override val appVersionName: String = NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "1.0" 33 | override val appVersionCode: Int = (NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleVersion") as? String)?.toIntOrNull() ?: 0 34 | 35 | @OptIn(ExperimentalForeignApi::class) 36 | override fun createDataStore(): DataStore = 37 | PreferenceDataStoreFactory.createWithPath( 38 | produceFile = { 39 | val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory( 40 | directory = NSDocumentDirectory, 41 | inDomain = NSUserDomainMask, 42 | appropriateForURL = null, 43 | create = false, 44 | error = null, 45 | ) 46 | val path = requireNotNull(documentDirectory).path + "/$DATASTORE_FILE_NAME" 47 | path.toPath() 48 | } 49 | ) 50 | 51 | override fun webRequestInterceptor(): RequestInterceptor? = null 52 | 53 | @OptIn(BetaInteropApi::class) 54 | override fun share(title: String, text: String) { 55 | val activityItems = listOf( 56 | NSString.create(text) 57 | ) 58 | 59 | val activityViewController = UIActivityViewController( 60 | activityItems = activityItems, 61 | applicationActivities = null 62 | ) 63 | 64 | // Get the top-most view controller to present the activity view controller 65 | val rootViewController = UIApplication.sharedApplication.keyWindow?.rootViewController 66 | rootViewController?.presentViewController(activityViewController, animated = true, completion = null) 67 | } 68 | 69 | override fun getDefaultBrowserName(urlString: String): String? = null 70 | 71 | @Composable 72 | override fun getScreenWidth(): Float = 73 | LocalWindowInfo.current.containerSize.width.toFloat() 74 | 75 | override fun isAndroid(): Boolean = false 76 | 77 | @Composable 78 | override fun getTypography(): Typography = baseline 79 | 80 | @Composable 81 | override fun getColorScheme(darkTheme: Boolean): ColorScheme = 82 | if (darkTheme) { 83 | darkScheme 84 | } else { 85 | lightScheme 86 | } 87 | } 88 | 89 | @ExperimentalComposeUiApi 90 | actual fun getPlatform(): Platform = IOSPlatform() -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | app-version-name = "1.3.1" 3 | app-version-code = "8" 4 | 5 | agp = "8.13.0" 6 | android-compileSdk = "36" 7 | android-minSdk = "26" 8 | android-targetSdk = "36" 9 | compose-plugin = "1.9.3" 10 | 11 | kotlin = "2.2.21" 12 | 13 | androidx-activityCompose = "1.11.0" 14 | 15 | # https://github.com/coil-kt/coil/releases 16 | coil = "3.3.0" 17 | 18 | # https://github.com/KevinnZou/compose-webview-multiplatform/releases 19 | compose-webview-multiplatform = "2.0.3" 20 | 21 | # https://developer.android.com/kotlin/multiplatform/datastore 22 | datastore = "1.1.7" 23 | 24 | # https://github.com/cbeyls/HtmlConverterCompose/releases 25 | htmlconverter = "1.1.0" 26 | 27 | # https://insert-koin.io/docs/setup/koin/ 28 | koin = "4.1.1" 29 | 30 | # https://github.com/Kotlin/kotlinx.serialization/releases 31 | kotlinx-serialization-json = "1.9.0" 32 | 33 | # https://github.com/Kotlin/kotlinx-datetime/releases 34 | kotlinx-datetime = "0.7.1" 35 | 36 | # https://ktor.io/docs/client-create-multiplatform-application.html#build-script 37 | ktor = "3.3.2" 38 | 39 | # https://github.com/AAkira/Napier/releases 40 | napier = "2.7.1" 41 | 42 | # https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-navigation-routing.html 43 | navigation-compose = "2.9.1" 44 | 45 | # https://github.com/stoyan-vuchev/squircle-shape/releases 46 | squircleShape = "4.0.0" 47 | 48 | # https://mvnrepository.com/artifact/org.jetbrains.compose.ui/ui-backhandler 49 | uiBackhandler = "1.9.3" 50 | 51 | [libraries] 52 | androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } 53 | androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } 54 | coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } 55 | coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } 56 | compose-webview-multiplatform = { module = "io.github.kevinnzou:compose-webview-multiplatform", version.ref = "compose-webview-multiplatform" } 57 | htmlconverter = { module = "be.digitalia.compose.htmlconverter:htmlconverter", version.ref = "htmlconverter" } 58 | koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } 59 | kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } 60 | androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } 61 | kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } 62 | ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } 63 | ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } 64 | ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } 65 | ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } 66 | napier = { module = "io.github.aakira:napier", version.ref = "napier" } 67 | navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" } 68 | squircle-shape = { module = "com.stoyanvuchev:squircle-shape", version.ref = "squircleShape" } 69 | ui-backhandler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "uiBackhandler" } 70 | 71 | [plugins] 72 | androidApplication = { id = "com.android.application", version.ref = "agp" } 73 | androidLibrary = { id = "com.android.library", version.ref = "agp" } 74 | jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } 75 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 76 | kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 77 | serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/models/Comment.kt: -------------------------------------------------------------------------------- 1 | package domain.models 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * A comment on a story/ask/poll/comment. 8 | */ 9 | @Serializable 10 | @SerialName("comment") 11 | data class Comment( 12 | @SerialName("by") 13 | val userName: String, 14 | @SerialName("id") 15 | val id: Long, 16 | @SerialName("kids") 17 | val commentIds: List = emptyList(), 18 | @SerialName("parent") 19 | val parentId: Long, 20 | @SerialName("text") 21 | val text: String, 22 | @SerialName("time") 23 | val time: Long, 24 | @SerialName("type") 25 | val type: String, 26 | ) : Item() 27 | 28 | val sampleCommentsJson = listOf( 29 | """ 30 | { 31 | "by" : "norvig", 32 | "id" : 2921983, 33 | "kids" : [ 2922097, 2922429, 2924562, 2922709, 2922573, 2922140, 2922141 ], 34 | "parent" : 2921506, 35 | "text" : "Aw shucks, guys ... you make me blush with your compliments.

Tell you what, Ill make a deal: I'll keep writing if you keep reading. K?", 36 | "time" : 1314211127, 37 | "type" : "comment" 38 | } 39 | """.trimIndent(), 40 | """ 41 | { 42 | "by" : "pchristensen", 43 | "id" : 2922097, 44 | "kids" : [ 2923189 ], 45 | "parent" : 2921983, 46 | "text" : "Deal. You promise?

Since you're here, what do you feel like is a bigger constraint for Google (or the worldwide technical economy) - software engineering discipline or computer science fundamentals? I understand that you work in research, but for a hugely profitable company, so you have the insight to give a good answer.", 47 | "time" : 1314213033, 48 | "type" : "comment" 49 | } 50 | """.trimIndent(), 51 | """ 52 | { 53 | "by" : "norvig", 54 | "id" : 2923189, 55 | "parent" : 2922097, 56 | "text" : "Promise.

Good question. I think the engineering discipline part is much harder. I'm not sure that is because the problems really are harder: messier, ill-defined, changing over time; or whether it is that the academic community has focused on more well-defined formal/fundamental questions and mostly nailed them, so what we're left with is the harder messier stuff. Certainly it is easier for me to find someone to hire fresh out of college who has excellent CS fundamentals than to find someone with strong engineering discipline. And while my title included \"Research\", we all work very closely with Engineering.", 57 | "time" : 1314230753, 58 | "type" : "comment" 59 | } 60 | 61 | """.trimIndent(), 62 | """ 63 | { 64 | "by" : "pstuart", 65 | "id" : 2922429, 66 | "parent" : 2921983, 67 | "text" : "Having no formal CS education (I consider myself a coder, not a programmer) I found your spell checker example to be intimidatingly beautiful. It feels like learning to play guitar and hearing Hendrix play: it's fun to do it but kind of depressing knowing I'll never come close.", 68 | "time" : 1314218807, 69 | "type" : "comment" 70 | } 71 | """.trimIndent(), 72 | """ 73 | { 74 | "deleted" : true, 75 | "id" : 2924562, 76 | "parent" : 2921983, 77 | "time" : 1314273417, 78 | "type" : "comment" 79 | } 80 | """.trimIndent(), 81 | """ 82 | { 83 | "by" : "webspiderus", 84 | "id" : 2922709, 85 | "parent" : 2921983, 86 | "text" : "and I hope you keep teaching as well! it was a real pleasure taking CS 221 from you, I hope everyone can be so lucky in the future :)", 87 | "time" : 1314224573, 88 | "type" : "comment" 89 | } 90 | """.trimIndent(), 91 | """ 92 | { 93 | "by" : "sgoranson", 94 | "id" : 2922573, 95 | "parent" : 2921983, 96 | "text" : "sounds like you had a pretty awesome attic growing up! all I had was my sister's old REO Speedwagon LPs", 97 | "time" : 1314221734, 98 | "type" : "comment" 99 | } 100 | """.trimIndent(), 101 | """ 102 | { 103 | "by" : "cema", 104 | "id" : 2922140, 105 | "parent" : 2921983, 106 | "text" : "It's a deal!", 107 | "time" : 1314213578, 108 | "type" : "comment" 109 | } 110 | """.trimIndent(), 111 | """ 112 | { 113 | "dead" : true, 114 | "deleted" : true, 115 | "id" : 2922141, 116 | "parent" : 2921983, 117 | "time" : 1314213582, 118 | "type" : "comment" 119 | } 120 | 121 | """.trimIndent(), 122 | ) -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/widgets/LabelledIcon.kt: -------------------------------------------------------------------------------- 1 | package presentation.widgets 2 | 3 | import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.material3.Icon 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.runtime.mutableStateOf 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.runtime.setValue 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.graphics.painter.Painter 20 | import androidx.compose.ui.text.Placeholder 21 | import androidx.compose.ui.text.style.TextOverflow 22 | import androidx.compose.ui.unit.dp 23 | import coil3.compose.AsyncImage 24 | import coil3.compose.AsyncImagePainter 25 | import hackernewskmp.composeapp.generated.resources.Res 26 | import hackernewskmp.composeapp.generated.resources.ic_chat_line_linear 27 | import org.jetbrains.compose.resources.painterResource 28 | import org.jetbrains.compose.ui.tooling.preview.Preview 29 | import ui.AppPreview 30 | import ui.trimmedTextStyle 31 | 32 | 33 | @Composable 34 | fun LabelledIcon( 35 | label: String, 36 | icon: Painter? = null, 37 | modifier: Modifier = Modifier 38 | ) { 39 | Row( 40 | modifier = modifier, 41 | verticalAlignment = Alignment.CenterVertically, 42 | horizontalArrangement = spacedBy(4.dp) 43 | ) { 44 | if (icon != null) { 45 | Icon( 46 | painter = icon, 47 | contentDescription = null, 48 | modifier = Modifier 49 | .size(16.dp), 50 | ) 51 | } 52 | Text( 53 | text = label, 54 | fontSize = MaterialTheme.typography.bodySmall.fontSize, 55 | style = trimmedTextStyle, 56 | maxLines = 1, 57 | overflow = TextOverflow.Ellipsis 58 | ) 59 | } 60 | } 61 | 62 | @Composable 63 | fun LabelledIcon( 64 | label: String, 65 | placeholder: Painter? = null, 66 | url: String? = null, 67 | modifier: Modifier = Modifier 68 | ) { 69 | Row( 70 | modifier = modifier, 71 | verticalAlignment = Alignment.CenterVertically, 72 | horizontalArrangement = spacedBy(4.dp) 73 | ) { 74 | if (url != null) { 75 | Box( 76 | modifier = Modifier 77 | .size(16.dp) 78 | ) { 79 | var showPlaceholder by remember { mutableStateOf(true) } 80 | if (placeholder != null && showPlaceholder) { 81 | Icon( 82 | painter = placeholder, 83 | contentDescription = null, 84 | modifier = Modifier.fillMaxSize(), 85 | ) 86 | } 87 | AsyncImage( 88 | model = url, 89 | contentDescription = null, 90 | modifier = Modifier.fillMaxSize(), 91 | onState = { state -> 92 | showPlaceholder = when (state) { 93 | is AsyncImagePainter.State.Success -> false 94 | else -> true 95 | } 96 | } 97 | ) 98 | } 99 | } 100 | Text( 101 | text = label, 102 | fontSize = MaterialTheme.typography.bodySmall.fontSize, 103 | style = trimmedTextStyle, 104 | maxLines = 1, 105 | overflow = TextOverflow.Ellipsis 106 | ) 107 | } 108 | } 109 | 110 | @Preview 111 | @Composable 112 | fun Preview_LabelledIcon() { 113 | AppPreview { 114 | Column(verticalArrangement = spacedBy(8.dp)) { 115 | LabelledIcon( 116 | label = "Sample Label", 117 | icon = painterResource(Res.drawable.ic_chat_line_linear) 118 | ) 119 | LabelledIcon( 120 | label = "Favicon", 121 | url = "https://www.google.com/s2/favicons?domain=github.com&sz=128", 122 | placeholder = painterResource(Res.drawable.ic_chat_line_linear) 123 | ) 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/viewmodels/DetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package presentation.viewmodels 2 | 3 | import androidx.compose.runtime.State 4 | import androidx.compose.runtime.mutableStateMapOf 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import domain.interactors.GetComments 9 | import domain.interactors.GetPollOptions 10 | import domain.models.Comment 11 | import domain.models.Item 12 | import domain.models.Poll 13 | import domain.models.PollOption 14 | import domain.models.getCommentIds 15 | import domain.models.getTitle 16 | import extensions.shareCommentsText 17 | import extensions.shareLinkText 18 | import getPlatform 19 | import io.github.aakira.napier.Napier 20 | import kotlinx.coroutines.flow.MutableStateFlow 21 | import kotlinx.coroutines.flow.asStateFlow 22 | import kotlinx.coroutines.flow.takeWhile 23 | import kotlinx.coroutines.launch 24 | 25 | class DetailsViewModel( 26 | private val getComments: GetComments, 27 | private val getPollOptions: GetPollOptions 28 | ) : ViewModel() { 29 | private val _state = mutableStateOf(DetailsState()) 30 | val state: State = _state 31 | 32 | private val _comments = mutableStateMapOf() 33 | 34 | private val _pollOptions = MutableStateFlow>(emptyList()) 35 | val pollOptions = _pollOptions.asStateFlow() 36 | 37 | private val _collapsedStates = mutableStateMapOf() 38 | 39 | fun fetchItem(item: Item) { 40 | if (item is Poll) loadPollOptions(item.optionIds) 41 | loadComments(item.getCommentIds()) 42 | } 43 | fun loadComments(ids: List) { 44 | viewModelScope.launch { 45 | _state.value = state.value.copy(loadingComments = true) 46 | getComments(ids) 47 | .takeWhile { result -> 48 | val shouldContinue = result.isSuccess 49 | if (shouldContinue.not()) { 50 | _state.value = state.value.copy( 51 | loadingComments = false, 52 | error = result.exceptionOrNull() 53 | ) 54 | } 55 | shouldContinue 56 | } 57 | .collect { result -> 58 | result.getOrNull()?.let { _comments[it.id] = it } 59 | } 60 | _state.value = state.value.copy(loadingComments = false) 61 | } 62 | } 63 | 64 | fun hasComments() = _comments.isNotEmpty() 65 | 66 | fun loadPollOptions(optionIds: List) { 67 | viewModelScope.launch { 68 | _state.value = state.value.copy(loadingPollOptions = true) 69 | getPollOptions(optionIds).fold( 70 | onSuccess = { 71 | _pollOptions.value = it 72 | _state.value = state.value.copy( 73 | loadingPollOptions = false 74 | ) 75 | }, 76 | onFailure = { 77 | _state.value = state.value.copy( 78 | error = it, 79 | loadingPollOptions = false 80 | ) 81 | } 82 | ) 83 | } 84 | } 85 | 86 | fun getComment(id: Long): Comment? = _comments[id] 87 | 88 | fun isCollapsed(commentId: Long): Boolean = _collapsedStates[commentId] ?: false 89 | 90 | fun toggleCollapse(commentId: Long) { 91 | if (_comments[commentId]?.commentIds?.isNotEmpty() == true) 92 | _collapsedStates[commentId] = !(_collapsedStates[commentId] ?: false) 93 | } 94 | 95 | fun countDescendants(commentId: Long): Int { 96 | val sum = _comments[commentId] 97 | ?.run { commentIds.size + commentIds.sumOf(::countDescendants) } 98 | ?: 0 99 | return sum 100 | } 101 | 102 | fun reset() { 103 | _state.value = DetailsState() 104 | } 105 | 106 | fun shareLink(item: Item) { 107 | val shareTitle = item.getTitle() 108 | val shareText = item.shareLinkText() 109 | getPlatform().share(shareTitle, shareText) 110 | } 111 | 112 | fun shareComments(item: Item) { 113 | val shareTitle = item.getTitle() 114 | val shareText = item.shareCommentsText() 115 | getPlatform().share(shareTitle, shareText) 116 | } 117 | } 118 | 119 | data class DetailsState( 120 | val loadingPollOptions: Boolean = false, 121 | val loadingComments: Boolean = false, 122 | val error: Throwable? = null 123 | ) -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/models/Item.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package domain.models 4 | 5 | import data.remote.models.RawItem 6 | import extensions.TimeExtension.format 7 | import extensions.TimeExtension.toInstant 8 | import kotlin.time.Clock 9 | import kotlin.time.Instant 10 | import kotlinx.datetime.TimeZone 11 | import kotlinx.datetime.toLocalDateTime 12 | import kotlinx.serialization.Serializable 13 | import kotlinx.serialization.json.Json 14 | import kotlin.time.DurationUnit 15 | import kotlin.time.ExperimentalTime 16 | 17 | /** 18 | * Base class for all items. 19 | */ 20 | @Serializable 21 | sealed class Item { 22 | fun getItemId(): Long = when (this) { 23 | is Ask -> id 24 | is Comment -> id 25 | is Job -> id 26 | is Poll -> id 27 | is PollOption -> id 28 | is Story -> id 29 | } 30 | 31 | companion object { 32 | private const val TYPE_STORY = "story" 33 | private const val TYPE_COMMENT = "comment" 34 | private const val TYPE_JOB = "job" 35 | private const val TYPE_POLL = "poll" 36 | private const val TYPE_POLL_OPTION = "pollopt" 37 | 38 | fun from(json: Json, text: String): Item? { 39 | val item = RawItem.from(json, text) 40 | if (item.deleted || item.dead) return null 41 | return when (item.type) { 42 | TYPE_STORY -> { 43 | // An "Ask" is by convention a story with no URL 44 | if (item.url != null) json.decodeFromString(text) 45 | else json.decodeFromString(text) 46 | } 47 | TYPE_COMMENT -> json.decodeFromString(text) 48 | TYPE_JOB -> json.decodeFromString(text) 49 | TYPE_POLL -> json.decodeFromString(text) 50 | TYPE_POLL_OPTION -> json.decodeFromString(text) 51 | else -> null // ignore unknown types 52 | } 53 | } 54 | } 55 | } 56 | 57 | fun Item.getCommentCount(): Int? = when (this) { 58 | is Ask -> countOfComment 59 | is Poll -> countOfComment 60 | is Story -> countOfComment 61 | else -> null 62 | } 63 | 64 | @OptIn(ExperimentalTime::class) 65 | fun Item.getInstant(): Instant = when (this) { 66 | is Ask -> time 67 | is Job -> time 68 | is Poll -> time 69 | is PollOption -> time 70 | is Story -> time 71 | is Comment -> time 72 | }.toInstant() 73 | 74 | @OptIn(ExperimentalTime::class) 75 | fun Item.getFormattedDiffTime(): String = 76 | when (val diff = Clock.System.now().minus(getInstant()).toLong(DurationUnit.SECONDS)) { 77 | in 0..60 -> "$diff seconds ago" 78 | in 60..3600 -> "${diff / 60} minutes ago" 79 | in 3600..86400 -> "${diff / 3600} hours ago" 80 | else -> "${diff / 86400} days ago" 81 | } 82 | 83 | @OptIn(ExperimentalTime::class) 84 | fun Item.getFormattedDiffTimeShort(): String = 85 | when (val diff = Clock.System.now().minus(getInstant()).toLong(DurationUnit.SECONDS)) { 86 | in 0..60 -> "${diff}s" 87 | in 60..3600 -> "${diff / 60}m" 88 | in 3600..86400 -> "${diff / 3600}h" 89 | else -> "${diff / 86400}d" 90 | } 91 | 92 | @OptIn(ExperimentalTime::class) 93 | fun Item.getFormattedTime(): String = 94 | getInstant().toLocalDateTime(TimeZone.currentSystemDefault()).format() 95 | 96 | fun Item.getTitle(): String = when (this) { 97 | is Ask -> title 98 | is Job -> title 99 | is Poll -> title 100 | is Story -> title 101 | else -> error("Unsupported item type") 102 | } 103 | 104 | fun Item.getText(): String? = when (this) { 105 | is Ask -> text 106 | is Job -> text 107 | is Poll -> text 108 | is Story -> text 109 | is Comment -> text 110 | else -> error("Unsupported item type") 111 | } 112 | 113 | fun Item.getUrl(): String? = when (this) { 114 | is Job -> url 115 | is Story -> url 116 | else -> null 117 | } 118 | 119 | fun Item.getUserName(): String = when (this) { 120 | is Ask -> userName 121 | is Comment -> userName 122 | is Job -> userName 123 | is Poll -> userName 124 | is PollOption -> userName 125 | is Story -> userName 126 | } 127 | 128 | fun Item.getPoint(): Int = when (this) { 129 | is Ask -> score 130 | is Comment -> error("Unsupported item type") 131 | is Job -> score 132 | is Poll -> score 133 | is PollOption -> score 134 | is Story -> score 135 | } 136 | 137 | fun Item.getCommentIds(): List = when (this) { 138 | is Story -> commentIds 139 | is Ask -> commentIds 140 | is Comment -> commentIds 141 | else -> emptyList() 142 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/Platform.android.kt: -------------------------------------------------------------------------------- 1 | import android.content.Context 2 | import android.content.Intent 3 | import android.content.pm.PackageManager 4 | import android.os.Build 5 | import androidx.compose.material3.ColorScheme 6 | import androidx.compose.material3.Typography 7 | import androidx.compose.material3.dynamicDarkColorScheme 8 | import androidx.compose.material3.dynamicLightColorScheme 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.platform.LocalConfiguration 11 | import androidx.compose.ui.platform.LocalContext 12 | import androidx.compose.ui.platform.LocalDensity 13 | import androidx.compose.ui.unit.dp 14 | import androidx.core.net.toUri 15 | import androidx.datastore.core.DataStore 16 | import androidx.datastore.preferences.core.PreferenceDataStoreFactory 17 | import androidx.datastore.preferences.core.Preferences 18 | import com.jarvislin.hackernews.BuildConfig 19 | import com.jarvislin.hackernews.HnKmp 20 | import com.jarvislin.hackernews.R 21 | import com.multiplatform.webview.request.RequestInterceptor 22 | import com.multiplatform.webview.request.WebRequest 23 | import com.multiplatform.webview.request.WebRequestInterceptResult 24 | import com.multiplatform.webview.web.WebViewNavigator 25 | import io.github.aakira.napier.Napier 26 | import okio.Path.Companion.toPath 27 | import ui.appTypography 28 | import ui.darkScheme 29 | import ui.lightScheme 30 | import utils.Constants.DATASTORE_FILE_NAME 31 | 32 | class AndroidPlatform(private val context: Context) : Platform { 33 | override val name: String = "Android ${Build.VERSION.SDK_INT}" 34 | 35 | override val appName: String = context.getString(R.string.app_name) 36 | 37 | override val appVersionName: String = BuildConfig.VERSION_NAME 38 | 39 | override val appVersionCode: Int = BuildConfig.VERSION_CODE 40 | 41 | override fun createDataStore(): DataStore = 42 | PreferenceDataStoreFactory.createWithPath( 43 | produceFile = { context.filesDir.resolve(DATASTORE_FILE_NAME).absolutePath.toPath() } 44 | ) 45 | 46 | override fun webRequestInterceptor(): RequestInterceptor = 47 | object: RequestInterceptor { 48 | override fun onInterceptUrlRequest( 49 | request: WebRequest, 50 | navigator: WebViewNavigator 51 | ): WebRequestInterceptResult { 52 | if (request.url.startsWith("intent://")) { 53 | try { 54 | val intent = Intent.parseUri(request.url, Intent.URI_INTENT_SCHEME) 55 | context.startActivity(intent) 56 | return WebRequestInterceptResult.Reject 57 | } catch (e: Exception) { 58 | Napier.i("Failed to parse and start intent", e) 59 | } 60 | } 61 | return WebRequestInterceptResult.Allow 62 | } 63 | } 64 | 65 | override fun share(title: String, text: String) { 66 | val shareIntent = Intent(Intent.ACTION_SEND).apply { 67 | type = "text/plain" 68 | putExtra(Intent.EXTRA_SUBJECT, title) 69 | putExtra(Intent.EXTRA_TEXT, text) 70 | } 71 | context.startActivity(Intent.createChooser(shareIntent, "Share via").apply { 72 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 73 | }) 74 | } 75 | 76 | override fun getDefaultBrowserName(urlString: String): String? { 77 | val intent = Intent(Intent.ACTION_VIEW, urlString.toUri()) 78 | val resolveInfo = context.packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) 79 | val pkgName = resolveInfo?.activityInfo?.packageName ?: return null 80 | return try { 81 | val appInfo = context.packageManager.getApplicationInfo(pkgName, 0) 82 | context.packageManager.getApplicationLabel(appInfo).toString() 83 | } catch (e: Exception) { 84 | Napier.i("Could not get application info", e) 85 | null 86 | } 87 | } 88 | 89 | @Composable 90 | override fun getScreenWidth(): Float = 91 | with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp.dp.toPx() } 92 | 93 | override fun isAndroid(): Boolean = true 94 | 95 | @Composable 96 | override fun getTypography(): Typography = appTypography() 97 | 98 | @Composable 99 | override fun getColorScheme(darkTheme: Boolean): ColorScheme { 100 | val ctx = LocalContext.current 101 | val supportsDynamic = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S 102 | return when { 103 | supportsDynamic && darkTheme -> dynamicDarkColorScheme(ctx) 104 | supportsDynamic && !darkTheme -> dynamicLightColorScheme(ctx) 105 | darkTheme -> darkScheme 106 | else -> lightScheme 107 | } 108 | } 109 | } 110 | 111 | actual fun getPlatform(): Platform = AndroidPlatform(HnKmp.instance) -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/screens/details/ItemDetailsSection.kt: -------------------------------------------------------------------------------- 1 | package presentation.screens.details 2 | 3 | import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.defaultMinSize 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.material3.AssistChip 14 | import androidx.compose.material3.Card 15 | import androidx.compose.material3.CardDefaults 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.MaterialTheme 18 | import androidx.compose.material3.Text 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.remember 21 | import androidx.compose.ui.Alignment 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.graphics.painter.Painter 24 | import androidx.compose.ui.unit.dp 25 | import be.digitalia.compose.htmlconverter.htmlToAnnotatedString 26 | import domain.models.Item 27 | import domain.models.Poll 28 | import domain.models.PollOption 29 | import domain.models.getFormattedDiffTimeShort 30 | import domain.models.getPoint 31 | import domain.models.getText 32 | import domain.models.getTitle 33 | import domain.models.getUserName 34 | import hackernewskmp.composeapp.generated.resources.Res 35 | import hackernewskmp.composeapp.generated.resources.ic_clock_circle_linear 36 | import hackernewskmp.composeapp.generated.resources.ic_like_outline 37 | import hackernewskmp.composeapp.generated.resources.ic_user_circle_linear 38 | import org.jetbrains.compose.resources.painterResource 39 | import ui.trimmedTextStyle 40 | 41 | 42 | @Composable 43 | fun ItemDetailsSection( 44 | item: Item, 45 | pollOptions: List 46 | ) { 47 | Column(Modifier.padding(horizontal = 16.dp)) { 48 | Text( 49 | text = item.getTitle(), 50 | style = MaterialTheme.typography.titleLarge, 51 | ) 52 | Spacer(modifier = Modifier.height(8.dp)) 53 | Row( 54 | horizontalArrangement = spacedBy(8.dp) 55 | ) { 56 | HeaderChip( 57 | label = item.getPoint().toString(), 58 | icon = painterResource(Res.drawable.ic_like_outline) 59 | ) 60 | HeaderChip( 61 | label = item.getFormattedDiffTimeShort(), 62 | icon = painterResource(Res.drawable.ic_clock_circle_linear) 63 | ) 64 | HeaderChip( 65 | label = item.getUserName(), 66 | icon = painterResource(Res.drawable.ic_user_circle_linear) 67 | ) 68 | } 69 | if (item is Poll) { 70 | PollContent(pollOptions) 71 | } 72 | item.getText()?.let { text -> 73 | val annotated = remember(text) { htmlToAnnotatedString(text) } 74 | Text( 75 | text = annotated, 76 | style = MaterialTheme.typography.bodyLarge, 77 | modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp) 78 | ) 79 | } 80 | } 81 | } 82 | 83 | @Composable 84 | private fun HeaderChip( 85 | label: String, 86 | modifier: Modifier = Modifier, 87 | icon: Painter? = null, 88 | ) { 89 | AssistChip( 90 | modifier = modifier, 91 | onClick = { }, 92 | label = { Text(label) }, 93 | leadingIcon = icon?.let{ { Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp)) } } 94 | ) 95 | } 96 | 97 | @Composable 98 | private fun PollContent( 99 | pollOptions: List, 100 | modifier: Modifier = Modifier, 101 | ) { 102 | Column(modifier = modifier) { 103 | Spacer(modifier = Modifier.height(8.dp)) 104 | pollOptions.forEachIndexed { index: Int, option: PollOption -> 105 | PollOptionWidget(option, pollOptions.size, index) 106 | } 107 | } 108 | } 109 | 110 | @Composable 111 | fun PollOptionWidget(option: PollOption, size: Int, index: Int) { 112 | Row(verticalAlignment = Alignment.CenterVertically) { 113 | Card(colors = CardDefaults.cardColors(MaterialTheme.colorScheme.tertiaryContainer)) { 114 | Box( 115 | modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) 116 | .defaultMinSize(minWidth = 24.dp), 117 | contentAlignment = Alignment.Center 118 | ) { 119 | Text( 120 | text = "${option.score}", 121 | color = MaterialTheme.colorScheme.onTertiaryContainer, 122 | fontSize = MaterialTheme.typography.bodySmall.fontSize, 123 | style = trimmedTextStyle, 124 | ) 125 | } 126 | } 127 | Text( 128 | text = option.text, 129 | fontSize = MaterialTheme.typography.bodyMedium.fontSize, 130 | modifier = Modifier.padding(start = 10.dp) 131 | ) 132 | } 133 | if (index < size - 1) { 134 | Spacer(modifier = Modifier.height(12.dp)) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /terms.md: -------------------------------------------------------------------------------- 1 | **Terms & Conditions** 2 | 3 | These terms and conditions applies to the Hacker News KMP app (hereby referred to as "Application") for mobile devices that was created by Jarvis Lin (hereby referred to as "Service Provider") as a Free service. 4 | 5 | Upon downloading or utilizing the Application, you are automatically agreeing to the following terms. It is strongly advised that you thoroughly read and understand these terms prior to using the Application. Unauthorized copying, modification of the Application, any part of the Application, or our trademarks is strictly prohibited. Any attempts to extract the source code of the Application, translate the Application into other languages, or create derivative versions are not permitted. All trademarks, copyrights, database rights, and other intellectual property rights related to the Application remain the property of the Service Provider. 6 | 7 | The Service Provider is dedicated to ensuring that the Application is as beneficial and efficient as possible. As such, they reserve the right to modify the Application or charge for their services at any time and for any reason. The Service Provider assures you that any charges for the Application or its services will be clearly communicated to you. 8 | 9 | The Application stores and processes personal data that you have provided to the Service Provider in order to provide the Service. It is your responsibility to maintain the security of your phone and access to the Application. The Service Provider strongly advise against jailbreaking or rooting your phone, which involves removing software restrictions and limitations imposed by the official operating system of your device. Such actions could expose your phone to malware, viruses, malicious programs, compromise your phone's security features, and may result in the Application not functioning correctly or at all. 10 | 11 | Please note that the Application utilizes third-party services that have their own Terms and Conditions. Below are the links to the Terms and Conditions of the third-party service providers used by the Application: 12 | 13 | * [Google Play Services](https://policies.google.com/terms) 14 | 15 | Please be aware that the Service Provider does not assume responsibility for certain aspects. Some functions of the Application require an active internet connection, which can be Wi-Fi or provided by your mobile network provider. The Service Provider cannot be held responsible if the Application does not function at full capacity due to lack of access to Wi-Fi or if you have exhausted your data allowance. 16 | 17 | If you are using the application outside of a Wi-Fi area, please be aware that your mobile network provider's agreement terms still apply. Consequently, you may incur charges from your mobile provider for data usage during the connection to the application, or other third-party charges. By using the application, you accept responsibility for any such charges, including roaming data charges if you use the application outside of your home territory (i.e., region or country) without disabling data roaming. If you are not the bill payer for the device on which you are using the application, they assume that you have obtained permission from the bill payer. 18 | 19 | Similarly, the Service Provider cannot always assume responsibility for your usage of the application. For instance, it is your responsibility to ensure that your device remains charged. If your device runs out of battery and you are unable to access the Service, the Service Provider cannot be held responsible. 20 | 21 | In terms of the Service Provider's responsibility for your use of the application, it is important to note that while they strive to ensure that it is updated and accurate at all times, they do rely on third parties to provide information to them so that they can make it available to you. The Service Provider accepts no liability for any loss, direct or indirect, that you experience as a result of relying entirely on this functionality of the application. 22 | 23 | The Service Provider may wish to update the application at some point. The application is currently available as per the requirements for the operating system (and for any additional systems they decide to extend the availability of the application to) may change, and you will need to download the updates if you want to continue using the application. The Service Provider does not guarantee that it will always update the application so that it is relevant to you and/or compatible with the particular operating system version installed on your device. However, you agree to always accept updates to the application when offered to you. The Service Provider may also wish to cease providing the application and may terminate its use at any time without providing termination notice to you. Unless they inform you otherwise, upon any termination, (a) the rights and licenses granted to you in these terms will end; (b) you must cease using the application, and (if necessary) delete it from your device. 24 | 25 | **Changes to These Terms and Conditions** 26 | 27 | The Service Provider may periodically update their Terms and Conditions. Therefore, you are advised to review this page regularly for any changes. The Service Provider will notify you of any changes by posting the new Terms and Conditions on this page. 28 | 29 | These terms and conditions are effective as of 2024-06-26 30 | 31 | **Contact Us** 32 | 33 | If you have any questions or suggestions about the Terms and Conditions, please do not hesitate to contact the Service Provider at admin@jarvislin.com. 34 | 35 | * * * 36 | 37 | This Terms and Conditions page was generated by [App Privacy Policy Generator](https://app-privacy-policy-generator.nisrulz.com/) 38 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/ui/Theme.kt: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.BoxScope 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.LocalContentColor 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.darkColorScheme 11 | import androidx.compose.material3.lightColorScheme 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.CompositionLocalProvider 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.graphics.Color 16 | import androidx.compose.ui.graphics.toArgb 17 | import androidx.compose.ui.unit.dp 18 | import coil3.ColorImage 19 | import coil3.annotation.ExperimentalCoilApi 20 | import coil3.compose.AsyncImagePreviewHandler 21 | import coil3.compose.LocalAsyncImagePreviewHandler 22 | import getPlatform 23 | 24 | val lightScheme = lightColorScheme( 25 | primary = primaryLight, 26 | onPrimary = onPrimaryLight, 27 | primaryContainer = primaryContainerLight, 28 | onPrimaryContainer = onPrimaryContainerLight, 29 | secondary = secondaryLight, 30 | onSecondary = onSecondaryLight, 31 | secondaryContainer = secondaryContainerLight, 32 | onSecondaryContainer = onSecondaryContainerLight, 33 | tertiary = tertiaryLight, 34 | onTertiary = onTertiaryLight, 35 | tertiaryContainer = tertiaryContainerLight, 36 | onTertiaryContainer = onTertiaryContainerLight, 37 | error = errorLight, 38 | onError = onErrorLight, 39 | errorContainer = errorContainerLight, 40 | onErrorContainer = onErrorContainerLight, 41 | background = backgroundLight, 42 | onBackground = onBackgroundLight, 43 | surface = surfaceLight, 44 | onSurface = onSurfaceLight, 45 | surfaceVariant = surfaceVariantLight, 46 | onSurfaceVariant = onSurfaceVariantLight, 47 | outline = outlineLight, 48 | outlineVariant = outlineVariantLight, 49 | scrim = scrimLight, 50 | inverseSurface = inverseSurfaceLight, 51 | inverseOnSurface = inverseOnSurfaceLight, 52 | inversePrimary = inversePrimaryLight, 53 | surfaceDim = surfaceDimLight, 54 | surfaceBright = surfaceBrightLight, 55 | surfaceContainerLowest = surfaceContainerLowestLight, 56 | surfaceContainerLow = surfaceContainerLowLight, 57 | surfaceContainer = surfaceContainerLight, 58 | surfaceContainerHigh = surfaceContainerHighLight, 59 | surfaceContainerHighest = surfaceContainerHighestLight, 60 | ) 61 | 62 | val darkScheme = darkColorScheme( 63 | primary = primaryDark, 64 | onPrimary = onPrimaryDark, 65 | primaryContainer = primaryContainerDark, 66 | onPrimaryContainer = onPrimaryContainerDark, 67 | secondary = secondaryDark, 68 | onSecondary = onSecondaryDark, 69 | secondaryContainer = secondaryContainerDark, 70 | onSecondaryContainer = onSecondaryContainerDark, 71 | tertiary = tertiaryDark, 72 | onTertiary = onTertiaryDark, 73 | tertiaryContainer = tertiaryContainerDark, 74 | onTertiaryContainer = onTertiaryContainerDark, 75 | error = errorDark, 76 | onError = onErrorDark, 77 | errorContainer = errorContainerDark, 78 | onErrorContainer = onErrorContainerDark, 79 | background = backgroundDark, 80 | onBackground = onBackgroundDark, 81 | surface = surfaceDark, 82 | onSurface = onSurfaceDark, 83 | surfaceVariant = surfaceVariantDark, 84 | onSurfaceVariant = onSurfaceVariantDark, 85 | outline = outlineDark, 86 | outlineVariant = outlineVariantDark, 87 | scrim = scrimDark, 88 | inverseSurface = inverseSurfaceDark, 89 | inverseOnSurface = inverseOnSurfaceDark, 90 | inversePrimary = inversePrimaryDark, 91 | surfaceDim = surfaceDimDark, 92 | surfaceBright = surfaceBrightDark, 93 | surfaceContainerLowest = surfaceContainerLowestDark, 94 | surfaceContainerLow = surfaceContainerLowDark, 95 | surfaceContainer = surfaceContainerDark, 96 | surfaceContainerHigh = surfaceContainerHighDark, 97 | surfaceContainerHighest = surfaceContainerHighestDark, 98 | ) 99 | 100 | @OptIn(ExperimentalCoilApi::class) 101 | @Composable 102 | fun AppPreview( 103 | darkTheme: Boolean = isSystemInDarkTheme(), 104 | content: @Composable BoxScope.() -> Unit 105 | ) { 106 | val colorScheme = if (darkTheme) darkScheme else lightScheme 107 | val previewHandler = AsyncImagePreviewHandler { request -> 108 | request.placeholder() ?: ColorImage(Color.Gray.toArgb()) 109 | } 110 | MaterialTheme(colorScheme = colorScheme) { 111 | CompositionLocalProvider( 112 | LocalContentColor provides MaterialTheme.colorScheme.onBackground, 113 | LocalAsyncImagePreviewHandler provides previewHandler 114 | ) { 115 | Box( 116 | modifier = Modifier 117 | .background(MaterialTheme.colorScheme.background) 118 | .padding(16.dp), 119 | content = content 120 | ) 121 | } 122 | } 123 | } 124 | 125 | @Composable 126 | fun AppTheme( 127 | darkTheme: Boolean = isSystemInDarkTheme(), 128 | content: @Composable () -> Unit 129 | ) { 130 | MaterialTheme( 131 | typography = getPlatform().getTypography(), 132 | colorScheme = getPlatform().getColorScheme(darkTheme), 133 | ) { 134 | CompositionLocalProvider( 135 | LocalContentColor provides MaterialTheme.colorScheme.onBackground, 136 | content = content 137 | ) 138 | } 139 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/screens/main/ItemRowWidget.kt: -------------------------------------------------------------------------------- 1 | package presentation.screens.main 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.Icon 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Text 11 | import androidx.compose.material3.minimumInteractiveComponentSize 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.draw.alpha 16 | import androidx.compose.ui.text.font.FontWeight 17 | import androidx.compose.ui.unit.dp 18 | import domain.models.Item 19 | import domain.models.getCommentCount 20 | import domain.models.getFormattedDiffTimeShort 21 | import domain.models.getPoint 22 | import domain.models.getTitle 23 | import domain.models.getUrl 24 | import domain.models.sampleAskJson 25 | import domain.models.sampleJobJson 26 | import domain.models.samplePollJson 27 | import domain.models.sampleStoryJson 28 | import extensions.faviconUrl 29 | import extensions.trimmedHostName 30 | import hackernewskmp.composeapp.generated.resources.Res 31 | import hackernewskmp.composeapp.generated.resources.ic_chat_line_linear 32 | import hackernewskmp.composeapp.generated.resources.ic_clock_circle_linear 33 | import hackernewskmp.composeapp.generated.resources.ic_like_outline 34 | import hackernewskmp.composeapp.generated.resources.ic_link_minimalistic_linear 35 | import io.ktor.http.Url 36 | import kotlinx.serialization.json.Json 37 | import org.jetbrains.compose.resources.painterResource 38 | import org.jetbrains.compose.ui.tooling.preview.Preview 39 | import presentation.widgets.LabelledIcon 40 | import ui.AppPreview 41 | import kotlin.time.ExperimentalTime 42 | 43 | 44 | @Composable 45 | fun ItemRowWidget( 46 | item: Item, 47 | seen: Boolean, 48 | onClickItem: () -> Unit, 49 | onClickComment: () -> Unit, 50 | modifier: Modifier = Modifier 51 | ) { 52 | Row( 53 | modifier = modifier 54 | .padding(horizontal = 16.dp, vertical = 4.dp) 55 | .alpha(if (seen) 0.4f else 1f) 56 | ) { 57 | Column( 58 | verticalArrangement = spacedBy(8.dp), 59 | modifier = Modifier 60 | .weight(1f) 61 | .clickable(onClick = onClickItem) 62 | .padding(8.dp) 63 | ) { 64 | Text( 65 | text = item.getTitle(), 66 | style = MaterialTheme.typography.titleMedium, 67 | fontWeight = FontWeight.Bold, 68 | ) 69 | Row( 70 | verticalAlignment = Alignment.CenterVertically, 71 | horizontalArrangement = spacedBy(8.dp) 72 | ) { 73 | item.getUrl()?.let { urlString -> 74 | val url = Url(urlString) 75 | LabelledIcon( 76 | label = url.trimmedHostName(), 77 | url = url.faviconUrl(), 78 | placeholder = painterResource(Res.drawable.ic_link_minimalistic_linear), 79 | ) 80 | } 81 | LabelledIcon( 82 | label = item.getPoint().toString(), 83 | icon = painterResource(Res.drawable.ic_like_outline), 84 | ) 85 | LabelledIcon( 86 | label = item.getFormattedDiffTimeShort(), 87 | icon = painterResource(Res.drawable.ic_clock_circle_linear), 88 | ) 89 | } 90 | } 91 | item.getCommentCount()?.let { commentCount -> 92 | Column( 93 | horizontalAlignment = Alignment.CenterHorizontally, 94 | modifier = Modifier 95 | .clickable(onClick = onClickComment) 96 | .padding(top = 8.dp, bottom = 8.dp) 97 | .minimumInteractiveComponentSize() 98 | ) { 99 | Icon( 100 | painter = painterResource(Res.drawable.ic_chat_line_linear), 101 | contentDescription = null, 102 | ) 103 | Text( 104 | text = "$commentCount", 105 | style = MaterialTheme.typography.labelLarge, 106 | ) 107 | 108 | } 109 | } 110 | } 111 | } 112 | 113 | @Preview 114 | @Composable 115 | private fun Preview_ItemRowWidget_Dark() { 116 | Preview_ItemRowWidget(darkTheme = true) 117 | } 118 | 119 | @Preview 120 | @Composable 121 | private fun Preview_ItemRowWidget_Light() { 122 | Preview_ItemRowWidget(darkTheme = false) 123 | } 124 | 125 | @Composable 126 | private fun Preview_ItemRowWidget(darkTheme: Boolean) { 127 | AppPreview(darkTheme = darkTheme) { 128 | Column { 129 | previewItems.forEachIndexed { index, item -> 130 | ItemRowWidget( 131 | item = item, 132 | seen = index % 2 == 0, 133 | onClickItem = {}, 134 | onClickComment = {}, 135 | ) 136 | } 137 | } 138 | } 139 | } 140 | 141 | @OptIn(ExperimentalTime::class) 142 | val previewItems: List = 143 | listOf( 144 | sampleStoryJson, 145 | sampleAskJson, 146 | sampleJobJson, 147 | samplePollJson, 148 | ) 149 | .map { Item.from(Json, it)!! } -------------------------------------------------------------------------------- /privacy.md: -------------------------------------------------------------------------------- 1 | **Privacy Policy** 2 | 3 | This privacy policy applies to the Hacker News KMP app (hereby referred to as "Application") for mobile devices that was created by Jarvis Lin (hereby referred to as "Service Provider") as a Free service. This service is intended for use "AS IS". 4 | 5 | **Information Collection and Use** 6 | 7 | The Application collects information when you download and use it. This information may include information such as 8 | 9 | * Your device's Internet Protocol address (e.g. IP address) 10 | * The pages of the Application that you visit, the time and date of your visit, the time spent on those pages 11 | * The time spent on the Application 12 | * The operating system you use on your mobile device 13 | 14 | The Application does not gather precise information about the location of your mobile device. 15 | 16 | The Application collects your device's location, which helps the Service Provider determine your approximate geographical location and make use of in below ways: 17 | 18 | * Geolocation Services: The Service Provider utilizes location data to provide features such as personalized content, relevant recommendations, and location-based services. 19 | * Analytics and Improvements: Aggregated and anonymized location data helps the Service Provider to analyze user behavior, identify trends, and improve the overall performance and functionality of the Application. 20 | * Third-Party Services: Periodically, the Service Provider may transmit anonymized location data to external services. These services assist them in enhancing the Application and optimizing their offerings. 21 | 22 | The Service Provider may use the information you provided to contact you from time to time to provide you with important information, required notices and marketing promotions. 23 | 24 | For a better experience, while using the Application, the Service Provider may require you to provide us with certain personally identifiable information. The information that the Service Provider request will be retained by them and used as described in this privacy policy. 25 | 26 | **Third Party Access** 27 | 28 | Only aggregated, anonymized data is periodically transmitted to external services to aid the Service Provider in improving the Application and their service. The Service Provider may share your information with third parties in the ways that are described in this privacy statement. 29 | 30 | Please note that the Application utilizes third-party services that have their own Privacy Policy about handling data. Below are the links to the Privacy Policy of the third-party service providers used by the Application: 31 | 32 | * [Google Play Services](https://www.google.com/policies/privacy/) 33 | 34 | The Service Provider may disclose User Provided and Automatically Collected Information: 35 | 36 | * as required by law, such as to comply with a subpoena, or similar legal process; 37 | * when they believe in good faith that disclosure is necessary to protect their rights, protect your safety or the safety of others, investigate fraud, or respond to a government request; 38 | * with their trusted services providers who work on their behalf, do not have an independent use of the information we disclose to them, and have agreed to adhere to the rules set forth in this privacy statement. 39 | 40 | **Opt-Out Rights** 41 | 42 | You can stop all collection of information by the Application easily by uninstalling it. You may use the standard uninstall processes as may be available as part of your mobile device or via the mobile application marketplace or network. 43 | 44 | **Data Retention Policy** 45 | 46 | The Service Provider will retain User Provided data for as long as you use the Application and for a reasonable time thereafter. If you'd like them to delete User Provided Data that you have provided via the Application, please contact them at admin@jarvislin.com and they will respond in a reasonable time. 47 | 48 | **Children** 49 | 50 | The Service Provider does not use the Application to knowingly solicit data from or market to children under the age of 13. 51 | 52 | The Application does not address anyone under the age of 13\. The Service Provider does not knowingly collect personally identifiable information from children under 13 years of age. In the case the Service Provider discover that a child under 13 has provided personal information, the Service Provider will immediately delete this from their servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact the Service Provider (admin@jarvislin.com) so that they will be able to take the necessary actions. 53 | 54 | **Security** 55 | 56 | The Service Provider is concerned about safeguarding the confidentiality of your information. The Service Provider provides physical, electronic, and procedural safeguards to protect information the Service Provider processes and maintains. 57 | 58 | **Changes** 59 | 60 | This Privacy Policy may be updated from time to time for any reason. The Service Provider will notify you of any changes to the Privacy Policy by updating this page with the new Privacy Policy. You are advised to consult this Privacy Policy regularly for any changes, as continued use is deemed approval of all changes. 61 | 62 | This privacy policy is effective as of 2024-06-26 63 | 64 | **Your Consent** 65 | 66 | By using the Application, you are consenting to the processing of your information as set forth in this Privacy Policy now and as amended by us. 67 | 68 | **Contact Us** 69 | 70 | If you have any questions regarding privacy while using the Application, or have questions about the practices, please contact the Service Provider via email at admin@jarvislin.com. 71 | 72 | * * * 73 | 74 | This privacy policy page was generated by [App Privacy Policy Generator](https://app-privacy-policy-generator.nisrulz.com/) 75 | -------------------------------------------------------------------------------- /composeApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 2 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 3 | 4 | plugins { 5 | alias(libs.plugins.kotlinMultiplatform) 6 | alias(libs.plugins.androidApplication) 7 | alias(libs.plugins.jetbrainsCompose) 8 | alias(libs.plugins.compose.compiler) 9 | alias(libs.plugins.serialization) 10 | } 11 | 12 | kotlin { 13 | androidTarget { 14 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 15 | compilerOptions { 16 | jvmTarget.set(JvmTarget.JVM_11) 17 | } 18 | } 19 | 20 | listOf( 21 | iosX64(), 22 | iosArm64(), 23 | iosSimulatorArm64() 24 | ).forEach { iosTarget -> 25 | iosTarget.binaries.framework { 26 | baseName = "ComposeApp" 27 | isStatic = true 28 | } 29 | } 30 | 31 | sourceSets { 32 | iosMain.dependencies { 33 | implementation(libs.ktor.client.darwin) 34 | } 35 | androidMain.dependencies { 36 | implementation(libs.ktor.client.okhttp) 37 | implementation(compose.preview) 38 | implementation(libs.androidx.activity.compose) 39 | } 40 | commonMain.dependencies { 41 | implementation(compose.runtime) 42 | implementation(compose.foundation) 43 | implementation(compose.material3) 44 | implementation(compose.ui) 45 | implementation(compose.components.resources) 46 | implementation(compose.components.uiToolingPreview) 47 | implementation(libs.kotlin.serialization.json) 48 | implementation(libs.ktor.client.core) 49 | implementation(libs.ktor.client.logging) 50 | implementation(libs.koin.compose) 51 | implementation(libs.napier) 52 | implementation(libs.navigation.compose) 53 | implementation(libs.kotlinx.datetime) 54 | implementation(libs.compose.webview.multiplatform) 55 | implementation(libs.htmlconverter) 56 | implementation(libs.coil.compose) 57 | implementation(libs.coil.network.ktor3) 58 | implementation(libs.squircle.shape) 59 | implementation(libs.androidx.datastore) 60 | implementation(libs.androidx.datastore.preferences) 61 | implementation(libs.ui.backhandler) 62 | } 63 | } 64 | } 65 | 66 | android { 67 | namespace = "com.jarvislin.hackernews" 68 | compileSdk = libs.versions.android.compileSdk.get().toInt() 69 | 70 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") 71 | sourceSets["main"].res.srcDirs("src/androidMain/res") 72 | sourceSets["main"].resources.srcDirs("src/commonMain/resources") 73 | 74 | defaultConfig { 75 | applicationId = "com.jarvislin.hackernews" 76 | minSdk = libs.versions.android.minSdk.get().toInt() 77 | targetSdk = libs.versions.android.targetSdk.get().toInt() 78 | versionCode = libs.versions.app.version.code.get().toInt() 79 | versionName = libs.versions.app.version.name.get() 80 | } 81 | 82 | packaging { 83 | resources { 84 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 85 | } 86 | } 87 | buildTypes { 88 | getByName("release") { 89 | isMinifyEnabled = false 90 | } 91 | } 92 | compileOptions { 93 | sourceCompatibility = JavaVersion.VERSION_11 94 | targetCompatibility = JavaVersion.VERSION_11 95 | } 96 | buildFeatures { 97 | compose = true 98 | buildConfig = true 99 | } 100 | dependencies { 101 | debugImplementation(compose.uiTooling) 102 | } 103 | } 104 | 105 | /** 106 | * Convenient hook to run code generation type tasks when project is built. 107 | * See: https://medium.com/@rrmunro/building-deploying-a-simple-kmp-app-part-6-release-ci-on-github-bfc8bb2783cc 108 | */ 109 | tasks.named("generateComposeResClass") { 110 | dependsOn("updatePlistVersion") 111 | } 112 | 113 | /** 114 | * Pulls the latest appVersion from libs.versions.toml, and updates 115 | * the `Info.plist` file in the iosApp project. 116 | * See: https://medium.com/@rrmunro/building-deploying-a-simple-kmp-app-part-6-release-ci-on-github-bfc8bb2783cc 117 | */ 118 | tasks.register("updatePlistVersion") { 119 | val plistFile = project.file("../iosApp/iosApp/Info.plist") // Path to `Info.plist` file in iOS app project 120 | 121 | inputs.property("versionName", libs.versions.app.version.name) 122 | inputs.property("versionCode", libs.versions.app.version.code) 123 | outputs.file(plistFile) 124 | 125 | doLast { 126 | if (!plistFile.exists()) { 127 | throw GradleException("Info.plist not found at ${plistFile.absolutePath}") 128 | } 129 | 130 | val appVersionName: String = libs.versions.app.version.name.get() 131 | val appVersionCode: Int = libs.versions.app.version.code.get().toInt() 132 | 133 | var plistContent = plistFile.readText() 134 | 135 | println("Updating iOS app version name in ${plistFile.absoluteFile} to $appVersionName") 136 | plistContent = plistContent.replace( 137 | Regex("CFBundleShortVersionString\\s*.*?"), 138 | "CFBundleShortVersionString\n\t$appVersionName" 139 | ) 140 | println("Updating iOS app version code in ${plistFile.absoluteFile} to $appVersionCode") 141 | plistContent = plistContent.replace( 142 | Regex("CFBundleVersion\\s*.*?"), 143 | "CFBundleVersion\n\t$appVersionCode" 144 | ) 145 | 146 | plistFile.writeText(plistContent) 147 | } 148 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/screens/details/ItemCommentsSection.kt: -------------------------------------------------------------------------------- 1 | package presentation.screens.details 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.Spacer 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.size 11 | import androidx.compose.foundation.lazy.LazyListScope 12 | import androidx.compose.foundation.shape.RoundedCornerShape 13 | import androidx.compose.material3.Icon 14 | import androidx.compose.material3.LocalContentColor 15 | import androidx.compose.material3.LocalTextStyle 16 | import androidx.compose.material3.MaterialTheme 17 | import androidx.compose.material3.Text 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.CompositionLocalProvider 20 | import androidx.compose.runtime.remember 21 | import androidx.compose.ui.Alignment 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.draw.alpha 24 | import androidx.compose.ui.graphics.Color 25 | import androidx.compose.ui.text.font.FontWeight 26 | import androidx.compose.ui.unit.dp 27 | import be.digitalia.compose.htmlconverter.htmlToAnnotatedString 28 | import domain.models.Comment 29 | import domain.models.Item 30 | import domain.models.getFormattedDiffTimeShort 31 | import domain.models.getText 32 | import domain.models.getUserName 33 | import domain.models.sampleCommentsJson 34 | import hackernewskmp.composeapp.generated.resources.Res 35 | import hackernewskmp.composeapp.generated.resources.ic_user_circle_linear 36 | import hackernewskmp.composeapp.generated.resources.no_comment 37 | import kotlinx.serialization.json.Json 38 | import org.jetbrains.compose.resources.painterResource 39 | import org.jetbrains.compose.resources.stringResource 40 | import org.jetbrains.compose.ui.tooling.preview.Preview 41 | import presentation.widgets.IndentedBox 42 | import presentation.widgets.SquircleBadge 43 | import sv.lib.squircleshape.SquircleShape 44 | import ui.AppPreview 45 | import kotlin.time.ExperimentalTime 46 | 47 | 48 | fun LazyListScope.commentItem( 49 | commentId: Long, 50 | depth: Int, 51 | getComment: (Long) -> Comment?, 52 | isCollapsed: (Long) -> Boolean, 53 | countDescendants: (Long) -> Int, 54 | onToggleCollapse: (Long) -> Unit 55 | ) { 56 | val comment = getComment(commentId) ?: return 57 | val isCollapsedValue = isCollapsed(commentId) 58 | val descendantsCount = if (isCollapsedValue) countDescendants(commentId) else null 59 | 60 | item(key = "comment-$commentId") { 61 | CommentRow( 62 | comment = comment, 63 | depth = depth, 64 | descendantsCount = descendantsCount, 65 | onToggleCollapse = { onToggleCollapse(commentId) }, 66 | modifier = Modifier.animateItem() 67 | ) 68 | } 69 | 70 | if (!isCollapsedValue) { 71 | comment.commentIds.forEach { replyCommentId -> 72 | commentItem( 73 | commentId = replyCommentId, 74 | depth = depth + 1, 75 | getComment = getComment, 76 | isCollapsed = isCollapsed, 77 | countDescendants = countDescendants, 78 | onToggleCollapse = onToggleCollapse, 79 | ) 80 | } 81 | } 82 | } 83 | 84 | @Composable 85 | fun CommentRow( 86 | comment: Comment, 87 | depth: Int, 88 | onToggleCollapse: () -> Unit, 89 | modifier: Modifier = Modifier, 90 | descendantsCount: Int? = null, 91 | ) { 92 | val html = comment.getText() ?: stringResource(Res.string.no_comment) 93 | val username = comment.getUserName() 94 | val since = comment.getFormattedDiffTimeShort() 95 | val annotated = remember(html) { htmlToAnnotatedString(html) } 96 | 97 | IndentedBox( 98 | modifier = modifier 99 | .clickable(enabled = comment.commentIds.isNotEmpty(), onClick = onToggleCollapse), 100 | depth = depth 101 | ) { 102 | Column { 103 | CompositionLocalProvider( 104 | LocalContentColor provides MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), 105 | LocalTextStyle provides MaterialTheme.typography.bodySmall 106 | ) { 107 | Row( 108 | horizontalArrangement = spacedBy(4.dp), 109 | verticalAlignment = Alignment.CenterVertically, 110 | ) { 111 | Icon( 112 | painter = painterResource(Res.drawable.ic_user_circle_linear), 113 | contentDescription = null, 114 | modifier = Modifier.size(16.dp), 115 | ) 116 | Text( 117 | text = username, 118 | fontWeight = FontWeight.Bold 119 | ) 120 | Text( 121 | text = since, 122 | ) 123 | Spacer(modifier = Modifier.weight(1f)) 124 | SquircleBadge( 125 | modifier = Modifier 126 | .alpha(if (descendantsCount != null) 1f else 0f) 127 | ) { 128 | Text("+$descendantsCount") 129 | } 130 | } 131 | } 132 | Text( 133 | modifier = Modifier.padding(top = 12.dp), 134 | text = annotated, 135 | style = MaterialTheme.typography.bodyMedium, 136 | ) 137 | } 138 | } 139 | } 140 | 141 | @Preview 142 | @Composable 143 | private fun Preview_HtmlAnnotatedString() { 144 | val annotated = remember(sampleHtmlAnnotatedComment) { htmlToAnnotatedString(sampleHtmlAnnotatedComment) } 145 | AppPreview { 146 | Text( 147 | text = annotated, 148 | style = MaterialTheme.typography.bodyMedium, 149 | ) 150 | } 151 | } 152 | 153 | @OptIn(ExperimentalTime::class) 154 | @Preview 155 | @Composable 156 | private fun Preview_CommentWidget() { 157 | AppPreview { 158 | Column { 159 | sampleCommentsJson 160 | .mapNotNull { Item.from(Json, it) } 161 | .forEach { 162 | CommentRow( 163 | comment = it as Comment, 164 | depth = 0, 165 | onToggleCollapse = {}, 166 | modifier = Modifier, 167 | descendantsCount = 5, 168 | ) 169 | } 170 | } 171 | } 172 | } 173 | 174 | private val sampleHtmlAnnotatedComment = """ 175 |

This is a sample comment text to demonstrate the styling.

This is another paragraph with bold and italic text.

176 | """.trimIndent() -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/screens/details/DetailsTopBar.kt: -------------------------------------------------------------------------------- 1 | package presentation.screens.details 2 | 3 | import androidx.compose.foundation.border 4 | import androidx.compose.foundation.text.TextAutoSize 5 | import androidx.compose.material3.BadgedBox 6 | import androidx.compose.material3.ExperimentalMaterial3Api 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.IconButton 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Text 11 | import androidx.compose.material3.TopAppBar 12 | import androidx.compose.material3.TopAppBarDefaults 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.runtime.mutableStateOf 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.runtime.setValue 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.graphics.Color 20 | import androidx.compose.ui.graphics.graphicsLayer 21 | import androidx.compose.ui.text.style.TextOverflow 22 | import androidx.compose.ui.unit.dp 23 | import extensions.toUrl 24 | import extensions.trimmedHostName 25 | import hackernewskmp.composeapp.generated.resources.Res 26 | import hackernewskmp.composeapp.generated.resources.back_button_content_description 27 | import hackernewskmp.composeapp.generated.resources.ic_arrow_left_linear 28 | import hackernewskmp.composeapp.generated.resources.ic_chat_line_linear 29 | import hackernewskmp.composeapp.generated.resources.ic_global_outline 30 | import hackernewskmp.composeapp.generated.resources.ic_square_top_down_linear 31 | import hackernewskmp.composeapp.generated.resources.x_comments 32 | import org.jetbrains.compose.resources.painterResource 33 | import org.jetbrains.compose.resources.pluralStringResource 34 | import org.jetbrains.compose.resources.stringResource 35 | import org.jetbrains.compose.ui.tooling.preview.Preview 36 | import presentation.widgets.SquircleBadge 37 | import ui.AppPreview 38 | 39 | @OptIn(ExperimentalMaterial3Api::class) 40 | @Composable 41 | fun DetailsTopBar( 42 | selectedTab: DetailsScreenTab, 43 | urlString: String?, 44 | commentCount: Int, 45 | onTabSelected: (DetailsScreenTab) -> Unit, 46 | onBack: () -> Unit, 47 | onClickOpenExternal: () -> Unit, 48 | modifier: Modifier = Modifier, 49 | ) { 50 | val trimmedHostName = urlString?.toUrl()?.trimmedHostName() 51 | val commentsLabel = pluralStringResource(Res.plurals.x_comments, commentCount, commentCount) 52 | TopAppBar( 53 | modifier = modifier, 54 | colors = TopAppBarDefaults.topAppBarColors().run { copy(containerColor = containerColor.copy(alpha = 0.9f)) }, 55 | title = { 56 | if (selectedTab == DetailsScreenTab.Comments || trimmedHostName == null) { 57 | Text(commentsLabel) 58 | } 59 | else { 60 | Text( 61 | text = trimmedHostName, 62 | overflow = TextOverflow.Ellipsis, 63 | autoSize = TextAutoSize.StepBased( 64 | maxFontSize = MaterialTheme.typography.titleLarge.fontSize 65 | ), 66 | maxLines = 1 67 | ) 68 | } 69 | }, 70 | navigationIcon = { 71 | IconButton(onClick = onBack) { 72 | Icon( 73 | painter = painterResource(Res.drawable.ic_arrow_left_linear), 74 | contentDescription = stringResource(Res.string.back_button_content_description), 75 | tint = MaterialTheme.colorScheme.onSurface 76 | ) 77 | } 78 | }, 79 | actions = { 80 | if (selectedTab == DetailsScreenTab.Comments && trimmedHostName != null) { 81 | IconButton(onClick = {onTabSelected(DetailsScreenTab.Webview)}) { 82 | Icon( 83 | painter = painterResource(Res.drawable.ic_global_outline), 84 | contentDescription = null, 85 | tint = MaterialTheme.colorScheme.onSurface 86 | ) 87 | } 88 | } 89 | if (selectedTab == DetailsScreenTab.Webview) { 90 | BadgedBox( 91 | badge = { 92 | if (commentCount > 0) { 93 | SquircleBadge( 94 | containerColor = MaterialTheme.colorScheme.primaryContainer, 95 | contentColor = MaterialTheme.colorScheme.onPrimaryContainer, 96 | modifier = Modifier 97 | .graphicsLayer { 98 | translationX = (-10).dp.toPx() 99 | translationY = 8.dp.toPx() 100 | } 101 | ) { 102 | Text("$commentCount") 103 | } 104 | } 105 | } 106 | ) { 107 | IconButton(onClick = {onTabSelected(DetailsScreenTab.Comments)}) { 108 | Icon( 109 | painter = painterResource(Res.drawable.ic_chat_line_linear), 110 | contentDescription = null, 111 | tint = MaterialTheme.colorScheme.onSurface 112 | ) 113 | } 114 | } 115 | } 116 | IconButton(onClick = onClickOpenExternal) { 117 | Icon( 118 | painter = painterResource(Res.drawable.ic_square_top_down_linear), 119 | contentDescription = null, 120 | tint = MaterialTheme.colorScheme.onSurface 121 | ) 122 | } 123 | } 124 | ) 125 | } 126 | 127 | @Preview(widthDp = 432) 128 | @Composable 129 | private fun Preview_DetailsTopBar_Comments() { 130 | Preview_DetailsTopBar(DetailsScreenTab.Comments) 131 | } 132 | 133 | @Preview(widthDp = 432) 134 | @Composable 135 | private fun Preview_DetailsTopBar_Web() { 136 | Preview_DetailsTopBar(DetailsScreenTab.Webview) 137 | } 138 | 139 | @Preview(widthDp = 432) 140 | @Composable 141 | private fun Preview_DetailsTopBar_LongName() { 142 | Preview_DetailsTopBar( 143 | initialTab = DetailsScreenTab.Webview, 144 | urlString = "http://rfd.shared.oxide.computer.longname.com" 145 | ) 146 | } 147 | 148 | @Composable 149 | private fun Preview_DetailsTopBar( 150 | initialTab: DetailsScreenTab, 151 | urlString: String = "https://www.example.com" 152 | ) { 153 | var selectedTab by remember { mutableStateOf(initialTab) } 154 | AppPreview { 155 | DetailsTopBar( 156 | selectedTab = selectedTab, 157 | urlString = urlString, 158 | commentCount = 10, 159 | onTabSelected = {selectedTab = it}, 160 | onBack = {}, 161 | onClickOpenExternal = {}, 162 | modifier = Modifier.border(1.dp, Color.Black) 163 | ) 164 | } 165 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/screens/details/ShareSheet.kt: -------------------------------------------------------------------------------- 1 | package presentation.screens.details 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.width 10 | import androidx.compose.foundation.shape.RoundedCornerShape 11 | import androidx.compose.foundation.text.selection.SelectionContainer 12 | import androidx.compose.material3.ExperimentalMaterial3Api 13 | import androidx.compose.material3.Icon 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.ModalBottomSheet 16 | import androidx.compose.material3.Text 17 | import androidx.compose.material3.TextButton 18 | import androidx.compose.material3.rememberModalBottomSheetState 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.LaunchedEffect 21 | import androidx.compose.runtime.remember 22 | import androidx.compose.runtime.rememberCoroutineScope 23 | import androidx.compose.ui.ExperimentalComposeUiApi 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.backhandler.BackHandler 26 | import androidx.compose.ui.unit.dp 27 | import com.multiplatform.webview.web.defaultWebViewFactory 28 | import domain.models.Item 29 | import domain.models.getUrl 30 | import extensions.shareCommentsText 31 | import extensions.shareLinkText 32 | import getPlatform 33 | import hackernewskmp.composeapp.generated.resources.Res 34 | import hackernewskmp.composeapp.generated.resources.ic_comments_share 35 | import hackernewskmp.composeapp.generated.resources.ic_square_share_line_linear 36 | import hackernewskmp.composeapp.generated.resources.ic_square_top_down_linear 37 | import hackernewskmp.composeapp.generated.resources.open_with_the_default_browser 38 | import hackernewskmp.composeapp.generated.resources.open_with_x 39 | import hackernewskmp.composeapp.generated.resources.share_comments 40 | import hackernewskmp.composeapp.generated.resources.share_link 41 | import kotlinx.coroutines.launch 42 | import org.jetbrains.compose.resources.DrawableResource 43 | import org.jetbrains.compose.resources.painterResource 44 | import org.jetbrains.compose.resources.stringResource 45 | import org.jetbrains.compose.ui.tooling.preview.Preview 46 | import presentation.screens.main.previewItems 47 | import ui.AppPreview 48 | import ui.googleSansCodeFontFamily 49 | 50 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) 51 | @Composable 52 | fun DetailsShareSheet( 53 | isVisible: Boolean, 54 | item: Item, 55 | onOpenInBrowser: () -> Unit, 56 | onShareLink: () -> Unit, 57 | onShareComments: () -> Unit, 58 | onVisibility: (Boolean) -> Unit, 59 | ) { 60 | val scope = rememberCoroutineScope() 61 | val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) 62 | 63 | LaunchedEffect(isVisible) { 64 | scope.launch { 65 | if (isVisible && !bottomSheetState.isVisible) { 66 | bottomSheetState.expand() 67 | } 68 | else if (!isVisible && bottomSheetState.isVisible) { 69 | bottomSheetState.hide() 70 | } 71 | } 72 | } 73 | 74 | LaunchedEffect(bottomSheetState.isVisible) { 75 | onVisibility(bottomSheetState.isVisible) 76 | } 77 | 78 | if (isVisible) { 79 | ModalBottomSheet( 80 | sheetState = bottomSheetState, 81 | containerColor = MaterialTheme.colorScheme.surfaceContainer, 82 | onDismissRequest = {}, 83 | content = { 84 | SheetContent( 85 | item = item, 86 | onOpenInBrowser = onOpenInBrowser, 87 | onShareLink = onShareLink, 88 | onShareComments = onShareComments, 89 | ) 90 | }, 91 | ) 92 | 93 | // Handle Back Gesture / Back Press to collapse sheet 94 | BackHandler { 95 | scope.launch { 96 | bottomSheetState.hide() 97 | } 98 | } 99 | } 100 | } 101 | 102 | @Composable 103 | private fun SheetContent( 104 | item: Item, 105 | onOpenInBrowser: () -> Unit, 106 | onShareLink: () -> Unit, 107 | onShareComments: () -> Unit, 108 | ) { 109 | Column( 110 | modifier = Modifier 111 | .fillMaxWidth() 112 | .padding(start = 24.dp, end = 24.dp, bottom = 24.dp), 113 | ) { 114 | item.getUrl()?.let { urlString -> 115 | val defaultBrowserName = remember(urlString) { getPlatform().getDefaultBrowserName(urlString) } 116 | val buttonText = when { 117 | defaultBrowserName == null -> stringResource(Res.string.open_with_the_default_browser) 118 | else -> stringResource(Res.string.open_with_x, defaultBrowserName) 119 | } 120 | SheetItem( 121 | icon = Res.drawable.ic_square_top_down_linear, 122 | buttonText = buttonText, 123 | sharedText = urlString, 124 | onClick = onOpenInBrowser 125 | ) 126 | Spacer(modifier = Modifier.height(16.dp)) 127 | SheetItem( 128 | icon = Res.drawable.ic_square_share_line_linear, 129 | buttonText = stringResource(Res.string.share_link), 130 | sharedText = item.shareLinkText(), 131 | onClick = onShareLink 132 | ) 133 | Spacer(modifier = Modifier.height(16.dp)) 134 | } 135 | SheetItem( 136 | icon = Res.drawable.ic_comments_share, 137 | buttonText = stringResource(Res.string.share_comments), 138 | sharedText = item.shareCommentsText(), 139 | onClick = onShareComments 140 | ) 141 | } 142 | } 143 | 144 | @Composable 145 | private fun SheetItem( 146 | icon: DrawableResource, 147 | buttonText: String, 148 | sharedText: String, 149 | onClick: () -> Unit, 150 | ) { 151 | TextButton(onClick) { 152 | Icon( 153 | painter = painterResource(icon), 154 | contentDescription = null, 155 | ) 156 | Spacer(modifier = Modifier.width(8.dp)) 157 | Text(buttonText) 158 | } 159 | SelectionContainer( 160 | modifier = Modifier 161 | .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp)) 162 | .padding(horizontal = 8.dp, vertical = 4.dp) 163 | ) { 164 | Text( 165 | text = sharedText, 166 | color = MaterialTheme.colorScheme.onSurfaceVariant, 167 | style = MaterialTheme.typography.labelSmall, 168 | fontFamily = googleSansCodeFontFamily(), 169 | ) 170 | } 171 | } 172 | 173 | @Preview 174 | @Composable 175 | private fun Preview_SheetContent() { 176 | AppPreview { 177 | SheetContent( 178 | item = previewItems.first(), 179 | onShareLink = {}, 180 | onShareComments = {}, 181 | onOpenInBrowser = {}, 182 | ) 183 | } 184 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/screens/details/DetailsScreen.kt: -------------------------------------------------------------------------------- 1 | package presentation.screens.details 2 | 3 | import androidx.compose.animation.core.animateFloatAsState 4 | import androidx.compose.animation.core.tween 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.calculateEndPadding 8 | import androidx.compose.foundation.layout.calculateStartPadding 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material3.ExperimentalMaterial3Api 11 | import androidx.compose.material3.Scaffold 12 | import androidx.compose.material3.SnackbarHost 13 | import androidx.compose.material3.SnackbarHostState 14 | import androidx.compose.material3.SnackbarResult 15 | import androidx.compose.material3.rememberModalBottomSheetState 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.LaunchedEffect 18 | import androidx.compose.runtime.getValue 19 | import androidx.compose.runtime.mutableStateOf 20 | import androidx.compose.runtime.remember 21 | import androidx.compose.runtime.rememberCoroutineScope 22 | import androidx.compose.runtime.setValue 23 | import androidx.compose.ui.ExperimentalComposeUiApi 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.draw.alpha 26 | import androidx.compose.ui.platform.LocalLayoutDirection 27 | import androidx.compose.ui.platform.LocalUriHandler 28 | import androidx.compose.ui.unit.dp 29 | import androidx.compose.ui.zIndex 30 | import domain.models.Item 31 | import domain.models.getCommentCount 32 | import domain.models.getUrl 33 | import hackernewskmp.composeapp.generated.resources.Res 34 | import hackernewskmp.composeapp.generated.resources.an_error_occurred 35 | import hackernewskmp.composeapp.generated.resources.retry 36 | import io.github.aakira.napier.Napier 37 | import kotlinx.coroutines.launch 38 | import kotlinx.serialization.SerialName 39 | import kotlinx.serialization.Serializable 40 | import org.jetbrains.compose.resources.getString 41 | import org.koin.compose.koinInject 42 | import presentation.viewmodels.DetailsViewModel 43 | import presentation.viewmodels.MainViewModel 44 | 45 | @Serializable 46 | data class DetailsRoute( 47 | @SerialName("id") 48 | val id: Long, 49 | @SerialName("tab") 50 | val tab: String // on iOS, NavHost 2.9.1 doesn't like when this is an enum (like DetailsScreenTab) 51 | ) 52 | 53 | enum class DetailsScreenTab { 54 | Webview, Comments; 55 | 56 | companion object { 57 | fun from(value: String) = entries.first { it.name.equals(value, ignoreCase = true) } 58 | } 59 | } 60 | 61 | @Composable 62 | fun DetailsScreen( 63 | itemId: Long, 64 | tab: DetailsScreenTab, 65 | onBack: () -> Unit, 66 | ) { 67 | val detailsViewModel = koinInject() 68 | val mainViewModel = koinInject() 69 | val uriHandler = LocalUriHandler.current 70 | val state by detailsViewModel.state 71 | val snackBarHostState = remember { SnackbarHostState() } 72 | val item = mainViewModel.state.value.items.first { it.getItemId() == itemId } 73 | val onClickLink = { 74 | val url = item.getUrl() ?: error("No URL found") 75 | uriHandler.openUri(url) 76 | } 77 | val onShareLink = { 78 | detailsViewModel.shareLink(item) 79 | } 80 | val onShareComments = { 81 | detailsViewModel.shareComments(item) 82 | } 83 | 84 | DetailsScreenContent( 85 | snackBarHostState = snackBarHostState, 86 | viewModel = detailsViewModel, 87 | item = item, 88 | tab = tab, 89 | onBack = onBack, 90 | onOpenInBrowser = onClickLink, 91 | onShareLink = onShareLink, 92 | onShareComments = onShareComments, 93 | ) 94 | 95 | LaunchedEffect(Unit) { 96 | if (state.error != null) { 97 | val result = snackBarHostState.showSnackbar( 98 | message = state.error?.message ?: getString(Res.string.an_error_occurred), 99 | actionLabel = getString(Res.string.retry) 100 | ) 101 | if (result == SnackbarResult.ActionPerformed) { 102 | detailsViewModel.reset() 103 | } 104 | } 105 | } 106 | } 107 | 108 | @OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) 109 | @Composable 110 | fun DetailsScreenContent( 111 | snackBarHostState: SnackbarHostState, 112 | viewModel: DetailsViewModel, 113 | item: Item, 114 | tab: DetailsScreenTab, 115 | onBack: () -> Unit, 116 | onOpenInBrowser: () -> Unit, 117 | onShareLink: () -> Unit, 118 | onShareComments: () -> Unit, 119 | ) { 120 | val urlString = item.getUrl() 121 | var selectedTab by remember(tab, urlString) { mutableStateOf(if (urlString == null) DetailsScreenTab.Comments else tab) } 122 | var isSheetVisible by remember { mutableStateOf(false) } 123 | 124 | DetailsShareSheet( 125 | isVisible = isSheetVisible, 126 | item = item, 127 | onOpenInBrowser = { 128 | onOpenInBrowser() 129 | isSheetVisible = false 130 | }, 131 | onShareLink = { 132 | onShareLink() 133 | isSheetVisible = false 134 | }, 135 | onShareComments = { 136 | onShareComments() 137 | isSheetVisible = false 138 | }, 139 | onVisibility = { 140 | isSheetVisible = it 141 | } 142 | ) 143 | 144 | Scaffold( 145 | topBar = { 146 | DetailsTopBar( 147 | selectedTab = selectedTab, 148 | urlString = urlString, 149 | commentCount = item.getCommentCount() ?: 0, 150 | onTabSelected = { selectedTab = it }, 151 | onBack = onBack, 152 | onClickOpenExternal = { isSheetVisible = true } 153 | ) 154 | }, 155 | snackbarHost = { SnackbarHost(snackBarHostState) } 156 | ) { padding -> 157 | FadeVisibilityKeepingState( 158 | visible = selectedTab == DetailsScreenTab.Comments, 159 | ) { 160 | CommentsTabContent( 161 | item = item, 162 | contentPadding = PaddingValues( 163 | top = 8.dp + padding.calculateTopPadding(), 164 | start = padding.calculateStartPadding(LocalLayoutDirection.current), 165 | end = padding.calculateEndPadding(LocalLayoutDirection.current), 166 | bottom = padding.calculateBottomPadding() 167 | ), 168 | viewModel = viewModel, 169 | ) 170 | } 171 | if (urlString != null) { 172 | FadeVisibilityKeepingState( 173 | visible = selectedTab == DetailsScreenTab.Webview, 174 | ) { 175 | WebviewTabContent( 176 | url = urlString, 177 | modifier = Modifier 178 | .padding(padding) 179 | ) 180 | } 181 | } 182 | } 183 | } 184 | 185 | @Composable 186 | fun FadeVisibilityKeepingState( 187 | visible: Boolean, 188 | modifier: Modifier = Modifier, 189 | durationMillis: Int = 300, 190 | content: @Composable () -> Unit 191 | ) { 192 | val targetAlpha = if (visible) 1f else 0f 193 | val alpha by animateFloatAsState(targetAlpha, animationSpec = tween(durationMillis)) 194 | 195 | Box( 196 | modifier = modifier 197 | .alpha(alpha) 198 | // Drop below others when hidden to let touches pass through 199 | .zIndex(if (alpha < 0.5f) -1f else 0f) 200 | ) { 201 | content() 202 | } 203 | } 204 | --------------------------------------------------------------------------------