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.
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.
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.