diff --git a/.github/workflows/android_emerge_upload.yml b/.github/workflows/android_emerge_upload.yml
index 238e445a..09b62151 100644
--- a/.github/workflows/android_emerge_upload.yml
+++ b/.github/workflows/android_emerge_upload.yml
@@ -25,7 +25,7 @@ jobs:
java-version: '17'
distribution: 'adopt'
- name: Emerge size analysis
- run: ./gradlew :app:emergeUploadReleasePerfBundle
+ run: ./gradlew :app:emergeUploadReleaseAab
env:
EMERGE_API_TOKEN: ${{ secrets.EMERGE_API_KEY }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
diff --git a/android/.editorconfig b/android/.editorconfig
deleted file mode 100644
index c6c8b362..00000000
--- a/android/.editorconfig
+++ /dev/null
@@ -1,9 +0,0 @@
-root = true
-
-[*]
-indent_style = space
-indent_size = 2
-end_of_line = lf
-charset = utf-8
-trim_trailing_whitespace = true
-insert_final_newline = true
diff --git a/android/.gitignore b/android/.gitignore
index 8df34213..aa724b77 100644
--- a/android/.gitignore
+++ b/android/.gitignore
@@ -1,12 +1,15 @@
-build/
+*.iml
.gradle
-
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
/captures
.externalNativeBuild
.cxx
-
-/local.properties
local.properties
-
-.idea/
-*.iml
\ No newline at end of file
diff --git a/android/app/.gitignore b/android/app/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/android/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 4afebae2..6622ba6c 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,25 +1,33 @@
plugins {
alias(libs.plugins.android.application)
- alias(libs.plugins.emerge)
alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.emerge)
}
android {
- compileSdk = 34
namespace = "com.emergetools.hackernews"
+ compileSdk = 34
defaultConfig {
applicationId = "com.emergetools.hackernews"
- minSdk = 24
+ minSdk = 30
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
}
buildTypes {
+ debug {
+ isDebuggable = true
+ applicationIdSuffix = ".debug"
+ }
release {
isMinifyEnabled = true
isShrinkResources = true
@@ -28,9 +36,6 @@ android {
)
signingConfig = signingConfigs.getByName("debug")
}
- debug {
- applicationIdSuffix = ".debug"
- }
}
buildFeatures {
compose = true
@@ -43,61 +48,55 @@ android {
jvmTarget = JavaVersion.VERSION_17.toString()
}
composeOptions {
- kotlinCompilerExtensionVersion = libs.versions.compose.compiler.extension.get()
- }
-}
-
-emerge {
- // apiToken is implicitly set from the EMERGE_API_TOKEN environment variable
-
- performance {
- projectPath.set(":performance")
+ kotlinCompilerExtensionVersion = libs.versions.composeCompilerExtension.get()
}
+ emerge {
+ snapshots {
+ tag.set("snapshot")
+ }
- snapshots {
- tag.set("snapshot")
+ vcs {
+ gitHub {
+ repoName.set("hackernews")
+ repoOwner.set("EmergeTools")
+ }
+ }
}
-
- vcs {
- gitHub {
- repoName.set("hackernews")
- repoOwner.set("EmergeTools")
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
- implementation(libs.accompanist.navigationanim)
- implementation(libs.accompanist.webview)
- implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.viewmodel)
+ implementation(libs.androidx.navigation)
implementation(libs.androidx.activity.compose)
- implementation(libs.emerge.snapshots.annotations)
- implementation(libs.androidx.datastore.preferences)
- implementation(libs.kotlinx.serialization)
- implementation(libs.material.core)
- implementation(libs.material.compose.core)
- implementation(libs.material.compose.icons)
- implementation(libs.mavericks.compose)
- implementation(libs.navigation.compose.core)
- implementation(libs.navigation.compose.ktx)
- implementation(libs.okhttp)
- implementation(libs.retrofit.core)
- implementation(libs.retrofit.serialization)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.browser)
- implementation(platform(libs.compose.bom))
- implementation(libs.compose.ui.tooling)
- implementation(libs.compose.ui.tooling.preview)
+ implementation(libs.okhttp)
+ implementation(libs.retrofit)
+ implementation(libs.retrofit.kotlinx.serialization)
+ implementation(libs.kotlinx.serialization.json)
- debugImplementation(libs.compose.ui.test.manifest)
+ implementation(libs.accompanist.webview)
testImplementation(libs.junit)
-
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
androidTestImplementation(libs.emerge.snapshots)
- androidTestImplementation(libs.junit)
- androidTestImplementation(libs.androidx.core)
- androidTestImplementation(libs.androidx.fragment)
- androidTestImplementation(libs.androidx.test.core)
- androidTestImplementation(libs.androidx.test.ext.junit)
- androidTestImplementation(libs.androidx.test.rules)
-}
+
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+}
\ No newline at end of file
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index e0fde437..df50412f 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -106,4 +106,4 @@
-keep class okio.** { *; }
--dontobfuscate
+-dontobfuscate
\ No newline at end of file
diff --git a/android/app/src/androidTest/kotlin/com/emergetools/hackernews/MainActivitySnapshotTest.kt b/android/app/src/androidTest/kotlin/com/emergetools/hackernews/MainActivitySnapshotTest.kt
deleted file mode 100644
index 725fd056..00000000
--- a/android/app/src/androidTest/kotlin/com/emergetools/hackernews/MainActivitySnapshotTest.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.emergetools.hackernews
-
-import androidx.test.ext.junit.rules.ActivityScenarioRule
-import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
-import com.emergetools.snapshots.EmergeSnapshots
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4ClassRunner::class)
-class MainActivitySnapshotTest {
-
- @get:Rule
- val activityScenarioRule = ActivityScenarioRule(MainActivity::class.java)
-
- @get:Rule
- val snapshots = EmergeSnapshots()
-
- @Test
- fun mainActivity() {
- val scenario = activityScenarioRule.scenario
- scenario.onActivity {
- snapshots.take("Main Activity", it)
- }
- }
-}
diff --git a/android/app/src/androidTest/kotlin/com/emergetools/hackernews/StoryComposableSnapshotTest.kt b/android/app/src/androidTest/kotlin/com/emergetools/hackernews/StoryComposableSnapshotTest.kt
deleted file mode 100644
index 979fe160..00000000
--- a/android/app/src/androidTest/kotlin/com/emergetools/hackernews/StoryComposableSnapshotTest.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.emergetools.hackernews
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.tooling.preview.Preview
-import com.emergetools.hackernews.network.models.Story
-import com.emergetools.hackernews.ui.items.BuildStory
-
-/**
- * Example generated snapshot test from androidTest source set.
- * To generate a snapshot test for this preview, add the androidTest source set to the debug variant.
- */
-@Preview
-@Composable
-fun StoryRow() {
- val mockStory = Story(
- id = 1,
- by = "Ryan B",
- time = 0,
- title = "Mock Story title for snapshot",
- text = "This is a mock story I wrote for the test",
- url = "https://www.example.com",
- score = 100,
- descendants = 10,
- comments = emptyList()
- )
- BuildStory(
- story = mockStory,
- onItemClick = {},
- onItemButtonClick = {}
- )
-}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index e42bc1b4..644c7088 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,42 +1,31 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/java/com/emergetools/HackerNewsApplication.kt b/android/app/src/main/java/com/emergetools/HackerNewsApplication.kt
new file mode 100644
index 00000000..c7025ee1
--- /dev/null
+++ b/android/app/src/main/java/com/emergetools/HackerNewsApplication.kt
@@ -0,0 +1,22 @@
+package com.emergetools
+
+import android.app.Application
+import android.content.Context
+import com.emergetools.hackernews.data.HackerNewsBaseClient
+import com.emergetools.hackernews.data.HackerNewsSearchClient
+import kotlinx.serialization.json.Json
+
+class HackerNewsApplication: Application() {
+ private val json = Json { ignoreUnknownKeys = true }
+
+ val baseClient = HackerNewsBaseClient(json)
+ val searchClient = HackerNewsSearchClient(json)
+}
+
+fun Context.baseClient(): HackerNewsBaseClient {
+ return (this.applicationContext as HackerNewsApplication).baseClient
+}
+
+fun Context.searchClient(): HackerNewsSearchClient {
+ return (this.applicationContext as HackerNewsApplication).searchClient
+}
diff --git a/android/app/src/main/java/com/emergetools/hackernews/MainActivity.kt b/android/app/src/main/java/com/emergetools/hackernews/MainActivity.kt
new file mode 100644
index 00000000..7664d4c3
--- /dev/null
+++ b/android/app/src/main/java/com/emergetools/hackernews/MainActivity.kt
@@ -0,0 +1,56 @@
+package com.emergetools.hackernews
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideIn
+import androidx.compose.animation.slideOut
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.IntOffset
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.rememberNavController
+import com.emergetools.hackernews.features.comments.commentsRoutes
+import com.emergetools.hackernews.features.stories.Stories
+import com.emergetools.hackernews.features.stories.storiesGraph
+import com.emergetools.hackernews.ui.theme.HackerNewsTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ HackerNewsTheme {
+ App()
+ }
+ }
+ }
+}
+
+@Composable
+fun App() {
+ val navController = rememberNavController()
+
+ Scaffold() { innerPadding ->
+ NavHost(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ navController = navController,
+ enterTransition = { slideIn { IntOffset(x = it.width, y = 0) } },
+ exitTransition = { slideOut { IntOffset(x = -it.width / 3, y = 0) } + fadeOut() },
+ popEnterTransition = { slideIn { IntOffset(x = -it.width, y = 0) } },
+ popExitTransition = { slideOut { IntOffset(x = it.width, y = 0) } },
+ startDestination = Stories
+ ) {
+ storiesGraph(navController)
+
+ commentsRoutes()
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsBaseClient.kt b/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsBaseClient.kt
new file mode 100644
index 00000000..4707e6ed
--- /dev/null
+++ b/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsBaseClient.kt
@@ -0,0 +1,44 @@
+package com.emergetools.hackernews.data
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import retrofit2.Retrofit
+import retrofit2.converter.kotlinx.serialization.asConverterFactory
+import retrofit2.http.GET
+import retrofit2.http.Path
+
+const val BASE_FIREBASE_URL = "https://hacker-news.firebaseio.com/v0/"
+
+@Serializable
+data class Item(
+ val id: Long,
+ val type: String,
+ val by: String? = null,
+ val title: String? = null,
+ val score: Int? = null,
+ val url: String? = null,
+ val descendants: Int? = null,
+ val kids: List? = null,
+ val text: String? = null
+)
+
+interface HackerNewsBaseApi {
+ @GET("topstories.json")
+ suspend fun getTopStoryIds(): List
+
+ @GET("newstories.json")
+ suspend fun getNewStoryIds(): List
+
+ @GET("item/{id}.json")
+ suspend fun getItem(@Path("id") itemId: Long): Item
+}
+
+class HackerNewsBaseClient(json: Json) {
+ private val retrofit = Retrofit.Builder()
+ .baseUrl(BASE_FIREBASE_URL)
+ .addConverterFactory(json.asConverterFactory("application/json; charset=UTF8".toMediaType()))
+ .build()
+
+ val api = retrofit.create(HackerNewsBaseApi::class.java)
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsSearchClient.kt b/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsSearchClient.kt
new file mode 100644
index 00000000..2899b189
--- /dev/null
+++ b/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsSearchClient.kt
@@ -0,0 +1,36 @@
+package com.emergetools.hackernews.data
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import retrofit2.Retrofit
+import retrofit2.converter.kotlinx.serialization.asConverterFactory
+import retrofit2.http.GET
+import retrofit2.http.Path
+
+private const val BASE_SEARCH_URL = "https://hn.algolia.com/api/v1/"
+
+@Serializable
+data class ItemResponse(
+ val id: Long,
+ val children: List,
+ val title: String? = null,
+ val author: String? = null,
+ val text: String? = null,
+ val points: Int? = null,
+)
+
+interface HackerNewsAlgoliaApi {
+
+ @GET("items/{id}")
+ suspend fun getItem(@Path("id") itemId: Long): ItemResponse
+}
+
+class HackerNewsSearchClient(json: Json) {
+ private val retrofit = Retrofit.Builder()
+ .baseUrl(BASE_SEARCH_URL)
+ .addConverterFactory(json.asConverterFactory("application/json; charset=UTF8".toMediaType()))
+ .build()
+
+ val api: HackerNewsAlgoliaApi = retrofit.create(HackerNewsAlgoliaApi::class.java)
+}
diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsDomain.kt b/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsDomain.kt
new file mode 100644
index 00000000..3d035bf5
--- /dev/null
+++ b/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsDomain.kt
@@ -0,0 +1,98 @@
+package com.emergetools.hackernews.features.comments
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.emergetools.hackernews.data.HackerNewsSearchClient
+import com.emergetools.hackernews.data.ItemResponse
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+data class CommentsState(
+ val title: String,
+ val author: String,
+ val points: Int,
+ val comments: List
+) {
+ companion object {
+ val empty = CommentsState(
+ title = "",
+ author = "",
+ points = 0,
+ comments = emptyList()
+ )
+ }
+
+ val headerState = HeaderState(title, author, points)
+}
+
+data class CommentState(
+ val id: Long,
+ val author: String,
+ val content: String,
+ val children: List,
+ val level: Int = 0,
+)
+
+data class HeaderState(
+ val title: String,
+ val author: String,
+ val points: Int
+)
+
+class CommentsViewModel(
+ private val itemId: Long,
+ private val searchClient: HackerNewsSearchClient
+) : ViewModel() {
+ private val internalState = MutableStateFlow(CommentsState.empty)
+ val state = internalState.asStateFlow()
+
+ init {
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ val response = searchClient.api.getItem(itemId)
+ val comments = response.children.map { rootComment ->
+ rootComment.createCommentState(0)
+ }
+ internalState.update {
+ CommentsState(
+ title = response.title ?: "",
+ author = response.author ?: "",
+ points = response.points ?: 0,
+ comments = comments
+ )
+ }
+ }
+ }
+ }
+
+ private fun ItemResponse.createCommentState(level: Int): CommentState {
+ Log.d("Creating CommentState()", "Level: $level, Id: $id")
+
+ return CommentState(
+ id = id,
+ author = author ?: "",
+ content = text ?: "",
+ children = children.map { child ->
+ child.createCommentState(level + 1)
+ },
+ level = level
+ )
+ }
+
+
+ @Suppress("UNCHECKED_CAST")
+ class Factory(
+ private val itemId: Long,
+ private val searchClient: HackerNewsSearchClient
+ ) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ return CommentsViewModel(itemId, searchClient) as T
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsRouting.kt b/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsRouting.kt
new file mode 100644
index 00000000..632a6646
--- /dev/null
+++ b/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsRouting.kt
@@ -0,0 +1,35 @@
+package com.emergetools.hackernews.features.comments
+
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.composable
+import androidx.navigation.toRoute
+import com.emergetools.searchClient
+import kotlinx.serialization.Serializable
+
+sealed interface CommentsDestinations {
+ @Serializable
+ data class Comments(val storyId: Long) : CommentsDestinations
+}
+
+fun NavGraphBuilder.commentsRoutes() {
+ composable { entry ->
+ val context = LocalContext.current
+ val comments: CommentsDestinations.Comments = entry.toRoute()
+ val model = viewModel(
+ factory = CommentsViewModel.Factory(
+ itemId = comments.storyId,
+ searchClient = context.searchClient()
+ )
+ )
+ val state by model.state.collectAsState()
+ CommentsScreen(state)
+ }
+}
+
+
+
+
diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsScreen.kt b/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsScreen.kt
new file mode 100644
index 00000000..d6221ee4
--- /dev/null
+++ b/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsScreen.kt
@@ -0,0 +1,251 @@
+package com.emergetools.hackernews.features.comments
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.ThumbUp
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.center
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.emergetools.hackernews.ui.theme.HNOrange
+import com.emergetools.hackernews.ui.theme.HackerNewsTheme
+
+@Composable
+fun CommentsScreen(state: CommentsState) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(color = MaterialTheme.colorScheme.background),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ item {
+ ItemHeader(
+ state = state.headerState,
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ )
+ }
+ item {
+ Box(modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .height(16.dp)
+ .drawBehind {
+ val lineStart = Offset(0f, size.center.y)
+ val lineEnd = Offset(size.width, size.center.y)
+ drawLine(
+ start = lineStart,
+ end = lineEnd,
+ color = HNOrange,
+ strokeWidth = 4f,
+ cap = StrokeCap.Round,
+ pathEffect = PathEffect.dashPathEffect(
+ intervals = floatArrayOf(20f, 20f)
+ )
+ )
+ }
+ )
+ }
+ items(items = state.comments) { comment ->
+ CommentRow(comment)
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun CommentsScreenPreview() {
+ HackerNewsTheme {
+ CommentsScreen(
+ state = CommentsState(
+ title = "Show HN: A new HN client for Android",
+ author = "rikinm",
+ points = 69,
+ comments = listOf(
+ CommentState(
+ id = 1,
+ level = 0,
+ author = "rikinm",
+ content = "Hello Child",
+ children = listOf(
+ CommentState(
+ id = 2,
+ level = 1,
+ author = "vasantm",
+ content = "Hello Parent",
+ children = listOf()
+ )
+ )
+ )
+ )
+ )
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun CommentRowPreview() {
+ HackerNewsTheme {
+ Column {
+ CommentRow(
+ state = CommentState(
+ id = 1,
+ level = 0,
+ author = "rikinm",
+ content = "Hello Parent",
+ children = listOf(
+ CommentState(
+ id = 2,
+ level = 1,
+ author = "vasantm",
+ content = "Hello Child",
+ children = listOf()
+ )
+ )
+ )
+ )
+ }
+ }
+}
+
+@Composable
+fun CommentRow(state: CommentState) {
+ val startPadding = (state.level * 16).dp
+ Column(
+ modifier = Modifier
+ .padding(start = startPadding)
+ .fillMaxWidth()
+ .heightIn(min = 80.dp)
+ .clip(RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp))
+ .background(color = MaterialTheme.colorScheme.surfaceContainer)
+ .padding(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = state.author,
+ style = MaterialTheme.typography.labelSmall,
+ color = HNOrange,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ "•",
+ style = MaterialTheme.typography.labelSmall
+ )
+ Text(
+ "1h ago",
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Medium,
+ color = Color.Gray
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Icon(
+ modifier = Modifier.size(16.dp),
+ imageVector = Icons.Default.ThumbUp,
+ contentDescription = "upvote"
+ )
+ Icon(
+ modifier = Modifier.size(16.dp),
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = "options"
+ )
+ }
+ Row {
+ Text(
+ text = state.content.parseAsHtml(),
+ style = MaterialTheme.typography.labelSmall
+ )
+ }
+ }
+ state.children.forEach { child ->
+ CommentRow(child)
+ }
+}
+
+@Preview
+@Composable
+private fun ItemHeaderPreview() {
+ HackerNewsTheme {
+ ItemHeader(
+ state = HeaderState(
+ title = "Show HN: A super neat HN client for Android",
+ author = "rikinm",
+ points = 69
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ )
+ }
+}
+
+@Composable
+fun ItemHeader(
+ state: HeaderState,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier
+ .background(color = MaterialTheme.colorScheme.background)
+ .padding(8.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = state.title,
+ style = MaterialTheme.typography.titleSmall
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "${state.points}",
+ style = MaterialTheme.typography.labelSmall
+ )
+ Text(
+ text = "•",
+ style = MaterialTheme.typography.labelSmall
+ )
+ Text(
+ text = state.author,
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/comments/HtmlToAnnotatedString.kt b/android/app/src/main/java/com/emergetools/hackernews/features/comments/HtmlToAnnotatedString.kt
new file mode 100644
index 00000000..b46616b9
--- /dev/null
+++ b/android/app/src/main/java/com/emergetools/hackernews/features/comments/HtmlToAnnotatedString.kt
@@ -0,0 +1,154 @@
+package com.emergetools.hackernews.features.comments
+
+import android.graphics.Typeface
+import android.text.Layout
+import android.text.Spanned
+import android.text.style.AbsoluteSizeSpan
+import android.text.style.AlignmentSpan
+import android.text.style.BackgroundColorSpan
+import android.text.style.ForegroundColorSpan
+import android.text.style.RelativeSizeSpan
+import android.text.style.StrikethroughSpan
+import android.text.style.StyleSpan
+import android.text.style.SubscriptSpan
+import android.text.style.SuperscriptSpan
+import android.text.style.TypefaceSpan
+import android.text.style.URLSpan
+import android.text.style.UnderlineSpan
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.LinkAnnotation
+import androidx.compose.ui.text.ParagraphStyle
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextLinkStyles
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.BaselineShift
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.em
+import androidx.core.text.HtmlCompat
+import com.emergetools.hackernews.ui.theme.HNOrange
+
+/**
+ * This code will be added to Compose 1.7, just some utilities to convert HTML Spanned to Annotated String
+ *
+ * https://android-review.googlesource.com/c/platform/frameworks/support/+/3003973/3/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/Html.android.kt#139
+ */
+
+fun String.parseAsHtml(): AnnotatedString {
+ val spanned = HtmlCompat.fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT)
+ return spanned.toAnnotatedString()
+}
+
+private fun Spanned.toAnnotatedString(): AnnotatedString {
+ return AnnotatedString.Builder(capacity = length)
+ .append(this)
+ .also { it.addSpans(this) }
+ .toAnnotatedString()
+}
+
+private fun AnnotatedString.Builder.addSpans(spanned: Spanned) {
+ spanned.getSpans(0, length, Any::class.java).forEach { span ->
+ val range = TextRange(spanned.getSpanStart(span), spanned.getSpanEnd(span))
+ addSpan(span, range.start, range.end)
+ }
+}
+
+private fun AnnotatedString.Builder.addSpan(span: Any, start: Int, end: Int) {
+ when (span) {
+ is AbsoluteSizeSpan -> {}
+ is AlignmentSpan -> {
+ addStyle(span.toParagraphStyle(), start, end)
+ }
+
+ is Annotation -> {}
+ is BackgroundColorSpan -> {
+ addStyle(SpanStyle(background = Color(span.backgroundColor)), start, end)
+ }
+
+ is ForegroundColorSpan -> {
+ addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end)
+ }
+
+ is RelativeSizeSpan -> {
+ addStyle(SpanStyle(fontSize = span.sizeChange.em), start, end)
+ }
+
+ is StrikethroughSpan -> {
+ addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), start, end)
+ }
+
+ is StyleSpan -> {
+ span.toSpanStyle()?.let { addStyle(it, start, end) }
+ }
+
+ is SubscriptSpan -> {
+ addStyle(SpanStyle(baselineShift = BaselineShift.Subscript), start, end)
+ }
+
+ is SuperscriptSpan -> {
+ addStyle(SpanStyle(baselineShift = BaselineShift.Superscript), start, end)
+ }
+
+ is TypefaceSpan -> {
+ addStyle(span.toSpanStyle(), start, end)
+ }
+
+ is UnderlineSpan -> {
+ addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end)
+ }
+
+ is URLSpan -> {
+ span.url?.let { url ->
+ addLink(
+ LinkAnnotation.Url(
+ url,
+ styles = TextLinkStyles(
+ style = SpanStyle(
+ color = HNOrange
+ )
+ )
+ ),
+ start,
+ end
+ )
+ }
+ }
+ }
+}
+
+private fun AlignmentSpan.toParagraphStyle(): ParagraphStyle {
+ val alignment = when (this.alignment) {
+ Layout.Alignment.ALIGN_NORMAL -> TextAlign.Start
+ Layout.Alignment.ALIGN_CENTER -> TextAlign.Center
+ Layout.Alignment.ALIGN_OPPOSITE -> TextAlign.End
+ else -> TextAlign.Unspecified
+ }
+ return ParagraphStyle(textAlign = alignment)
+}
+
+private fun StyleSpan.toSpanStyle(): SpanStyle? {
+ return when (style) {
+ Typeface.BOLD -> {
+ SpanStyle(fontWeight = FontWeight.Bold)
+ }
+
+ Typeface.ITALIC -> {
+ SpanStyle(fontStyle = FontStyle.Italic)
+ }
+
+ Typeface.BOLD_ITALIC -> {
+ SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)
+ }
+
+ else -> null
+ }
+}
+
+private fun TypefaceSpan.toSpanStyle(): SpanStyle {
+ val fontFamily = this.typeface?.let { FontFamily(it) }
+ return SpanStyle(fontFamily = fontFamily)
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesDomain.kt b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesDomain.kt
new file mode 100644
index 00000000..34355075
--- /dev/null
+++ b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesDomain.kt
@@ -0,0 +1,130 @@
+package com.emergetools.hackernews.features.stories
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.emergetools.hackernews.data.HackerNewsBaseClient
+import com.emergetools.hackernews.features.comments.CommentsDestinations
+import com.emergetools.hackernews.features.stories.StoriesAction.LoadStories
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+enum class FeedType(val label: String) {
+ Top("Top"),
+ New("New")
+}
+data class StoriesState(
+ val stories: List,
+ val feed: FeedType = FeedType.Top
+
+)
+
+sealed class StoryItem(open val id: Long) {
+ data class Loading(override val id: Long) : StoryItem(id)
+ data class Content(
+ override val id: Long,
+ val title: String,
+ val author: String,
+ val score: Int,
+ val commentCount: Int,
+ val url: String?
+ ) : StoryItem(id)
+}
+
+sealed class StoriesAction {
+ data object LoadStories : StoriesAction()
+ data class SelectStory(val id: Long) : StoriesAction()
+ data class SelectComments(val id: Long) : StoriesAction()
+ data class SelectFeed(val feed: FeedType) : StoriesAction()
+}
+
+// TODO(rikin): Second pass at Navigation Setup
+sealed interface StoriesNavigation {
+ data class GoToStory(val closeup: StoriesDestinations.Closeup) : StoriesNavigation
+ data class GoToComments(val comments: CommentsDestinations.Comments) : StoriesNavigation
+}
+
+class StoriesViewModel(private val baseClient: HackerNewsBaseClient) : ViewModel() {
+ private val internalState = MutableStateFlow(StoriesState(stories = emptyList()))
+ val state = internalState.asStateFlow()
+
+ init {
+ actions(LoadStories)
+ }
+
+ fun actions(action: StoriesAction) {
+ when (action) {
+ LoadStories -> {
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ val ids = when(internalState.value.feed) {
+ FeedType.Top -> {
+ baseClient.api.getTopStoryIds()
+ }
+ FeedType.New -> {
+ baseClient.api.getNewStoryIds()
+ }
+ }
+
+ // now for each ID I need to load the item.
+ internalState.update { current ->
+ current.copy(
+ stories = ids.map { StoryItem.Loading(it) }
+ )
+ }
+ ids.forEach { id ->
+ val item = baseClient.api.getItem(id)
+ internalState.update { current ->
+ current.copy(
+ stories = current.stories.map {
+ if (it.id == item.id) {
+ StoryItem.Content(
+ id = item.id,
+ title = item.title!!,
+ author = item.by!!,
+ score = item.score ?: 0,
+ commentCount = item.descendants ?: 0,
+ url = item.url
+ )
+ } else {
+ it
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ is StoriesAction.SelectStory -> {
+ // TODO
+ }
+
+ is StoriesAction.SelectComments -> {
+ // TODO
+ }
+
+ is StoriesAction.SelectFeed -> {
+ internalState.update { current ->
+ current.copy(
+ feed = action.feed,
+ stories = emptyList()
+ )
+ }
+ actions(LoadStories)
+ }
+ }
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ class Factory(private val baseClient: HackerNewsBaseClient) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ return StoriesViewModel(baseClient) as T
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesRouting.kt b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesRouting.kt
new file mode 100644
index 00000000..cac5b71d
--- /dev/null
+++ b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesRouting.kt
@@ -0,0 +1,67 @@
+package com.emergetools.hackernews.features.stories
+
+import android.net.Uri
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.composable
+import androidx.navigation.navigation
+import androidx.navigation.toRoute
+import com.emergetools.baseClient
+import com.emergetools.hackernews.features.stories.StoriesDestinations.Closeup
+import com.emergetools.hackernews.features.stories.StoriesDestinations.Feed
+import kotlinx.serialization.Serializable
+
+@Serializable
+data object Stories
+
+sealed interface StoriesDestinations {
+ @Serializable
+ data object Feed : StoriesDestinations
+
+ @Serializable
+ data class Closeup(val url: String) : StoriesDestinations
+}
+
+fun NavGraphBuilder.storiesGraph(navController: NavController) {
+ navigation(startDestination = Feed) {
+ composable {
+ val context = LocalContext.current
+ val customTabsIntent = remember {
+ CustomTabsIntent.Builder().build()
+ }
+
+ val model = viewModel(
+ factory = StoriesViewModel.Factory(
+ baseClient = context.baseClient()
+ )
+ )
+ val state by model.state.collectAsState()
+
+ StoriesScreen(
+ state = state,
+ actions = model::actions,
+ navigation = { place ->
+ when (place) {
+ is StoriesNavigation.GoToComments -> {
+ navController.navigate(place.comments)
+ }
+ is StoriesNavigation.GoToStory -> {
+// navController.navigate(place.closeup)
+ customTabsIntent.launchUrl(context, Uri.parse(place.closeup.url))
+ }
+ }
+ }
+ )
+ }
+ composable { entry ->
+ val closeup: Closeup = entry.toRoute()
+ StoryScreen(closeup.url)
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesScreen.kt b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesScreen.kt
new file mode 100644
index 00000000..53e341fb
--- /dev/null
+++ b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesScreen.kt
@@ -0,0 +1,348 @@
+package com.emergetools.hackernews.features.stories
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.TabRow
+import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.center
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.emergetools.hackernews.R
+import com.emergetools.hackernews.features.comments.CommentsDestinations
+import com.emergetools.hackernews.ui.theme.HNOrange
+import com.emergetools.hackernews.ui.theme.HackerNewsTheme
+
+@Composable
+fun StoriesScreen(
+ modifier: Modifier = Modifier,
+ state: StoriesState,
+ actions: (StoriesAction) -> Unit,
+ navigation: (StoriesNavigation) -> Unit
+) {
+ Column(
+ modifier = modifier.background(color = MaterialTheme.colorScheme.background),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ FeedSelection(
+ feedType = state.feed,
+ onSelected = { actions(StoriesAction.SelectFeed(it)) }
+ )
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ ) {
+ items(state.stories) { item ->
+ StoryRow(
+ modifier = Modifier.animateItem(),
+ item = item,
+ onClick = {
+ actions(StoriesAction.SelectStory(it.id))
+ navigation(
+ if (it.url != null) {
+ StoriesNavigation.GoToStory(
+ closeup = StoriesDestinations.Closeup(it.url)
+ )
+ } else {
+ StoriesNavigation.GoToComments(
+ comments = CommentsDestinations.Comments(it.id)
+ )
+ }
+ )
+ },
+ onCommentClicked = {
+ actions(StoriesAction.SelectComments(it.id))
+ navigation(
+ StoriesNavigation.GoToComments(
+ comments = CommentsDestinations.Comments(it.id)
+ )
+ )
+ }
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun FeedSelectionPreview() {
+ HackerNewsTheme {
+ FeedSelection(
+ feedType = FeedType.Top,
+ onSelected = {}
+ )
+ }
+}
+
+@Composable
+private fun FeedSelection(
+ feedType: FeedType,
+ onSelected: (FeedType) -> Unit,
+) {
+ val selectedTab = remember(feedType) { feedType.ordinal }
+
+ TabRow(
+ selectedTabIndex = selectedTab,
+ modifier = Modifier.wrapContentWidth(),
+ containerColor = MaterialTheme.colorScheme.background,
+ contentColor = MaterialTheme.colorScheme.onBackground,
+ indicator = { tabPositions ->
+ if (selectedTab < tabPositions.size) {
+ Box(
+ modifier = Modifier
+ .tabIndicatorOffset(tabPositions[selectedTab])
+ .height(2.dp)
+ .drawBehind {
+ val barWidth = size.width * 0.33f
+ val start = size.center.x - barWidth/2f
+ val end = size.center.x + barWidth/2f
+ val bottom = size.height - 16f
+ drawLine(
+ start = Offset(start, bottom),
+ end = Offset(end, bottom),
+ color = HNOrange,
+ strokeWidth = 4f,
+ cap = StrokeCap.Round,
+ )
+ }
+ )
+ }
+ },
+ divider = {}
+ ) {
+ FeedType.entries.forEach { feedType ->
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ .clickable {
+ onSelected(feedType)
+ }
+ ,
+ textAlign = TextAlign.Center,
+ text = feedType.label,
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Medium,
+ fontSize = 24.sp
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun StoriesScreenPreview() {
+ HackerNewsTheme {
+ StoriesScreen(
+ modifier = Modifier.fillMaxSize(),
+ state = StoriesState(
+ stories = listOf(
+ StoryItem.Content(
+ id = 1L,
+ title = "Hello There",
+ author = "heyrikin",
+ score = 10,
+ commentCount = 0,
+ url = ""
+ ),
+ StoryItem.Content(
+ id = 1L,
+ title = "Hello There",
+ author = "heyrikin",
+ score = 10,
+ commentCount = 0,
+ url = ""
+ ),
+ )
+ ),
+ actions = {},
+ navigation = {}
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun StoryRowPreview() {
+ HackerNewsTheme {
+ StoryRow(
+ item = StoryItem.Content(
+ id = 1L,
+ title = "Hello There",
+ author = "heyrikin",
+ score = 10,
+ commentCount = 0,
+ url = ""
+ ),
+ onClick = {},
+ onCommentClicked = {}
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun StoryRowLoadingPreview() {
+ HackerNewsTheme {
+ StoryRow(
+ item = StoryItem.Loading(id = 1L),
+ onClick = {},
+ onCommentClicked = {}
+ )
+ }
+}
+
+@Composable
+fun StoryRow(
+ modifier: Modifier = Modifier,
+ item: StoryItem,
+ onClick: (StoryItem.Content) -> Unit,
+ onCommentClicked: (StoryItem.Content) -> Unit
+) {
+ when (item) {
+ is StoryItem.Content -> {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .heightIn(min = 80.dp)
+ .background(color = MaterialTheme.colorScheme.background)
+ .clickable {
+ onClick(item)
+ }
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterHorizontally)
+ ) {
+ Column(
+ modifier = Modifier
+ .wrapContentHeight()
+ .weight(1f),
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = item.title,
+ style = MaterialTheme.typography.titleSmall
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(text = "${item.score}", style = MaterialTheme.typography.labelSmall)
+ Text(text = "•", style = MaterialTheme.typography.labelSmall)
+ Text(
+ text = item.author ,
+ color = HNOrange,
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .clickable {
+ onCommentClicked(item)
+ },
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ modifier = Modifier.size(20.dp),
+ painter = painterResource(R.drawable.ic_chat),
+ tint = MaterialTheme.colorScheme.onBackground,
+ contentDescription = ""
+ )
+ Text(
+ text = "${item.commentCount}",
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+ }
+ is StoryItem.Loading -> {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .heightIn(min = 80.dp)
+ .background(color = MaterialTheme.colorScheme.background)
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterHorizontally)
+ ) {
+ Column(
+ modifier = Modifier
+ .wrapContentHeight()
+ .weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(0.8f)
+ .height(20.dp)
+ .clip(CircleShape)
+ .background(color = Color.LightGray)
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(0.45f)
+ .height(20.dp)
+ .clip(CircleShape)
+ .background(color = Color.Gray)
+ )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .width(30.dp)
+ .height(14.dp)
+ .clip(CircleShape)
+ .background(Color.DarkGray)
+ )
+ Box(
+ modifier = Modifier
+ .width(40.dp)
+ .height(14.dp)
+ .clip(CircleShape)
+ .background(HNOrange)
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoryScreen.kt b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoryScreen.kt
new file mode 100644
index 00000000..451a8a5a
--- /dev/null
+++ b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoryScreen.kt
@@ -0,0 +1,40 @@
+package com.emergetools.hackernews.features.stories
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import com.google.accompanist.web.WebView
+import com.google.accompanist.web.rememberWebViewState
+import com.emergetools.hackernews.ui.theme.HackerNewsTheme
+
+@Composable
+fun StoryScreen(url: String) {
+ val webViewState = rememberWebViewState(url)
+ Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = url,
+ style = MaterialTheme.typography.labelSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ WebView(
+ modifier = Modifier.fillMaxWidth().weight(1f),
+ state = webViewState
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun StoryScreenPreview() {
+ HackerNewsTheme {
+ StoryScreen("www.google.com")
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/emergetools/hackernews/ui/theme/Color.kt b/android/app/src/main/java/com/emergetools/hackernews/ui/theme/Color.kt
new file mode 100644
index 00000000..580393da
--- /dev/null
+++ b/android/app/src/main/java/com/emergetools/hackernews/ui/theme/Color.kt
@@ -0,0 +1,14 @@
+package com.emergetools.hackernews.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
+
+val HNOrange = Color(0xFFF57C00)
+val HNOrangeLight = Color(0xFFFFCD93)
diff --git a/android/app/src/main/java/com/emergetools/hackernews/ui/theme/Theme.kt b/android/app/src/main/java/com/emergetools/hackernews/ui/theme/Theme.kt
new file mode 100644
index 00000000..d1e8f32f
--- /dev/null
+++ b/android/app/src/main/java/com/emergetools/hackernews/ui/theme/Theme.kt
@@ -0,0 +1,46 @@
+package com.emergetools.hackernews.ui.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+)
+
+@Composable
+fun HackerNewsTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/emergetools/hackernews/ui/theme/Type.kt b/android/app/src/main/java/com/emergetools/hackernews/ui/theme/Type.kt
new file mode 100644
index 00000000..a5b80158
--- /dev/null
+++ b/android/app/src/main/java/com/emergetools/hackernews/ui/theme/Type.kt
@@ -0,0 +1,49 @@
+package com.emergetools.hackernews.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import com.emergetools.hackernews.R
+
+val plex = FontFamily(
+ Font(resId = R.font.ibm_plex_sans_regular, weight = FontWeight.Normal),
+ Font(resId = R.font.ibm_plex_sans_medium, weight = FontWeight.Medium),
+ Font(resId = R.font.ibm_plex_sans_bold, weight = FontWeight.Bold),
+)
+
+val Typography = Typography(
+ titleSmall = TextStyle(
+ fontFamily = plex,
+ fontWeight = FontWeight.Bold,
+ fontSize = 18.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = plex,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp
+ ),
+ labelMedium = TextStyle(
+ fontFamily = plex,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp
+ ),
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/HNApplication.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/HNApplication.kt
deleted file mode 100644
index d4271e07..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/HNApplication.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.emergetools.hackernews
-
-import android.app.Application
-import com.airbnb.mvrx.Mavericks
-
-class HNApplication : Application() {
-
- override fun onCreate() {
- super.onCreate()
- Mavericks.initialize(this)
- }
-}
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/MainActivity.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/MainActivity.kt
deleted file mode 100644
index 1e4d7379..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/MainActivity.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.emergetools.hackernews
-
-import android.os.Bundle
-import android.os.Trace
-import androidx.activity.compose.setContent
-import androidx.appcompat.app.AppCompatActivity
-import androidx.compose.animation.ExperimentalAnimationApi
-import com.emergetools.hackernews.ui.HNNavHost
-import com.emergetools.hackernews.ui.HNTheme
-
-class MainActivity : AppCompatActivity() {
-
- @OptIn(ExperimentalAnimationApi::class)
- override fun onCreate(savedInstanceState: Bundle?) {
- Trace.beginSection("MainActivity.onCreate")
- super.onCreate(savedInstanceState)
-
- setContent {
- HNTheme {
- HNNavHost()
- }
- }
-
- Trace.endSection()
- }
-}
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/network/HNApi.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/network/HNApi.kt
deleted file mode 100644
index d399a1d3..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/network/HNApi.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.emergetools.hackernews.network
-
-import com.emergetools.hackernews.network.models.Item
-import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
-import kotlinx.serialization.json.Json
-import okhttp3.MediaType.Companion.toMediaType
-import retrofit2.Retrofit
-import retrofit2.http.GET
-import retrofit2.http.Path
-
-private const val HN_BASE_URL = "https://hacker-news.firebaseio.com/v0/"
-
-private val retrofit = Retrofit.Builder()
- .baseUrl(HN_BASE_URL)
- .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
- .build()
-
-interface HNApiService {
-
- @GET("topstories.json")
- suspend fun getTopStories(): List
-
- @GET("item/{id}.json")
- suspend fun getItem(@Path("id") id: Long): Item
-}
-
-object HNApi {
- val retrofitService: HNApiService by lazy {
- retrofit.create(HNApiService::class.java)
- }
-}
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/network/models/Item.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/network/models/Item.kt
deleted file mode 100644
index e64e9861..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/network/models/Item.kt
+++ /dev/null
@@ -1,96 +0,0 @@
-package com.emergetools.hackernews.network.models
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-import java.net.URI
-import java.net.URISyntaxException
-
-@Serializable
-sealed class Item {
- abstract val id: Long
- abstract val by: String
- abstract val time: Long
-
- val deleted: Boolean = false
- val dead: Boolean = false
-}
-
-@Serializable
-@SerialName("story")
-data class Story(
- override val id: Long,
- override val by: String,
- override val time: Long,
-
- val title: String,
- val text: String? = null,
- val url: String,
- val score: Int,
- val descendants: Int,
- @SerialName("kids") val comments: List,
-) : Item() {
-
- val commentCount: Int
- get() = comments.size
-
- val displayableUrl: String
- get() {
- return try {
- val uri = URI(url)
- // TODO: First path for github
- uri.host.removePrefix("www.")
- } catch (e: URISyntaxException) {
- url
- }
- }
-}
-
-@Serializable
-@SerialName("comment")
-data class Comment(
- override val id: Long,
- override val by: String,
- override val time: Long,
-
- val text: String,
- val parent: Long?,
- @SerialName("kids") val replies: List,
-) : Item()
-
-@Serializable
-@SerialName("job")
-data class Job(
- override val id: Long,
- override val by: String,
- override val time: Long,
-
- val title: String,
- val score: Int,
- val text: String? = null,
-) : Item()
-
-@Serializable
-@SerialName("poll")
-data class Poll(
- override val id: Long,
- override val by: String,
- override val time: Long,
-
- val title: String,
- val score: Int,
- val descendants: Int,
- @SerialName("kids") val comments: List,
- @SerialName("parts") val pollopts: List = emptyList(),
-) : Item()
-
-@Serializable
-@SerialName("pollopt")
-data class Pollopt(
- override val id: Long,
- override val by: String,
- override val time: Long,
-
- val poll: Long,
- val score: Int,
- val text: String? = null,
-) : Item()
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/HNNavHost.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/ui/HNNavHost.kt
deleted file mode 100644
index 0943793e..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/HNNavHost.kt
+++ /dev/null
@@ -1,142 +0,0 @@
-package com.emergetools.hackernews.ui
-
-import androidx.compose.animation.AnimatedContentScope
-import androidx.compose.animation.AnimatedContentTransitionScope
-import androidx.compose.animation.ExperimentalAnimationApi
-import androidx.compose.animation.core.tween
-import androidx.compose.runtime.Composable
-import androidx.navigation.NavType
-import androidx.navigation.navArgument
-import com.emergetools.hackernews.ui.comments.CommentsScreen
-import com.emergetools.hackernews.ui.stories.StoriesScreen
-import com.emergetools.hackernews.ui.story.StoryScreen
-import com.google.accompanist.navigation.animation.AnimatedNavHost
-import com.google.accompanist.navigation.animation.composable
-import com.google.accompanist.navigation.animation.rememberAnimatedNavController
-
-sealed class Screen(val route: String) {
- object Auth : Screen("auth")
- object Stories : Screen("stories")
- object Story : Screen("story/{id}") {
-
- fun getRoute(id: Long) = "story/$id"
- }
-
- object Comments : Screen("comments/{id}") {
- fun getRoute(id: Long) = "comments/$id"
- }
-}
-
-@ExperimentalAnimationApi
-@Composable
-fun HNNavHost() {
- val navController = rememberAnimatedNavController()
-
- // TODO: Deep link for handling comments
- AnimatedNavHost(
- navController = navController,
- startDestination = Screen.Stories.route,
- ) {
- composable(
- Screen.Stories.route,
- enterTransition = {
- slideIntoContainer(
- AnimatedContentTransitionScope.SlideDirection.Left,
- animationSpec = tween(300)
- )
- },
- exitTransition = {
- slideOutOfContainer(
- AnimatedContentTransitionScope.SlideDirection.Left,
- animationSpec = tween(300)
- )
- },
- popEnterTransition = {
- slideIntoContainer(
- AnimatedContentTransitionScope.SlideDirection.Right,
- animationSpec = tween(300)
- )
- },
- popExitTransition = {
- slideOutOfContainer(
- AnimatedContentTransitionScope.SlideDirection.Right,
- animationSpec = tween(300)
- )
- }) {
- StoriesScreen(navController = navController)
- }
- composable(
- Screen.Story.route,
- enterTransition = {
- slideIntoContainer(
- AnimatedContentTransitionScope.SlideDirection.Left,
- animationSpec = tween(300)
- )
- },
- exitTransition = {
- slideOutOfContainer(
- AnimatedContentTransitionScope.SlideDirection.Left,
- animationSpec = tween(300)
- )
- },
- popEnterTransition = {
- slideIntoContainer(
- AnimatedContentTransitionScope.SlideDirection.Right,
- animationSpec = tween(300)
- )
- },
- popExitTransition = {
- slideOutOfContainer(
- AnimatedContentTransitionScope.SlideDirection.Right,
- animationSpec = tween(300)
- )
- },
- arguments = listOf(
- navArgument("id") { type = NavType.LongType }
- )
- ) {
- val id = it.arguments?.getLong("id")
- requireNotNull(id) { "No argument found for id launching story screen" }
- StoryScreen(
- navController = navController,
- id = id,
- )
- }
- composable(
- Screen.Comments.route,
- enterTransition = {
- slideIntoContainer(
- AnimatedContentTransitionScope.SlideDirection.Left,
- animationSpec = tween(300)
- )
- },
- exitTransition = {
- slideOutOfContainer(
- AnimatedContentTransitionScope.SlideDirection.Left,
- animationSpec = tween(300)
- )
- },
- popEnterTransition = {
- slideIntoContainer(
- AnimatedContentTransitionScope.SlideDirection.Right,
- animationSpec = tween(300)
- )
- },
- popExitTransition = {
- slideOutOfContainer(
- AnimatedContentTransitionScope.SlideDirection.Right,
- animationSpec = tween(300)
- )
- },
- arguments = listOf(
- navArgument("id") { type = NavType.LongType }
- )) {
- val id = it.arguments?.getLong("id")
- requireNotNull(id) { "No argument found for id launching comments screen" }
- CommentsScreen(
- navController = navController,
- id = id,
- )
- }
- }
-}
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/HNTheme.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/ui/HNTheme.kt
deleted file mode 100644
index 96097276..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/HNTheme.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package com.emergetools.hackernews.ui
-
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.darkColors
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.graphics.Color
-
-val Orange = Color(0xffff6600)
-private val Purple = Color(0xff221E43)
-
-// TODO: Success
-private val Error = Color(0xffe53935)
-
-const val ALPHA_SECONDARY = .56f
-const val ALPHA_TERTIARY = .27f
-
-object Dark {
- val Background = Color(0xff1D1E21)
- val Card = Color(0xff2B2C2F)
- val Divider = Color(0xff4c5053)
- val TextPrimary = Color(0xffffffff)
- val TextSecondary = Color(0xffC4C4C4)
- val TextTertiary = Color(0xff878787)
-}
-
-object Light {
- val Background = Color(0xfff6f6ef)
- val Card = Color(0xffffffff)
- val Divider = Color(0xffdddddd)
- val TextPrimary = Color(0xff222222)
- val TextSecondary = Color(0xff717171)
- val TextTertiary = Color(0xffb0b0b0)
-}
-
-// TODO: Add textSecondary/tertiary
-private val DarkColors = darkColors(
- primary = Orange,
- secondary = Purple,
- background = Dark.Background,
- surface = Dark.Card,
- error = Error,
- onPrimary = Dark.TextPrimary,
- onSecondary = Dark.TextPrimary,
- onBackground = Dark.TextPrimary,
- onSurface = Dark.TextPrimary,
-)
-private val LightColors = darkColors(
- primary = Orange,
- secondary = Purple,
- background = Light.Background,
- surface = Light.Card,
- error = Error,
- onPrimary = Dark.TextPrimary,
- onSecondary = Dark.TextPrimary,
- onBackground = Light.TextPrimary,
- onSurface = Light.TextPrimary,
-)
-
-@Composable
-fun HNTheme(
- darkTheme: Boolean = isSystemInDarkTheme(),
- content: @Composable () -> Unit,
-) = MaterialTheme(
- colors = if (darkTheme) DarkColors else LightColors,
- content = content
-)
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/ItemBuilder.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/ui/ItemBuilder.kt
deleted file mode 100644
index 222f52a2..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/ItemBuilder.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.emergetools.hackernews.ui
-
-import android.util.Log
-import androidx.compose.runtime.Composable
-import com.emergetools.hackernews.network.models.Comment
-import com.emergetools.hackernews.network.models.Item
-import com.emergetools.hackernews.network.models.Story
-import com.emergetools.hackernews.ui.items.BuildComment
-import com.emergetools.hackernews.ui.items.BuildStory
-
-@Composable
-fun BuildItem(
- index: Int,
- item: Item,
- onItemClick: (Item) -> Unit,
- onItemPrimaryButtonClick: ((Item) -> Unit)?,
-) {
- when (item) {
- is Story -> {
- if (onItemPrimaryButtonClick == null) {
- throw IllegalArgumentException("Must provide a onItemPrimaryButtonClick for a Story")
- }
- BuildStory(item, index, onItemClick, onItemPrimaryButtonClick)
- }
-
- is Comment -> BuildComment(item)
- else -> {
- Log.d("BuildItem", "No buildItem handling for ${item.javaClass.simpleName}")
- }
- }
-}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/annotations/FontScalePreviews.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/ui/annotations/FontScalePreviews.kt
deleted file mode 100644
index 8c69955a..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/annotations/FontScalePreviews.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.emergetools.hackernews.ui.annotations
-
-import androidx.compose.ui.tooling.preview.Preview
-
-@Preview(
- name = "Small font",
- fontScale = 0.5f,
-)
-@Preview(
- name = "Large font",
- fontScale = 1.5f,
-)
-annotation class FontScalePreviews
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/annotations/LightDarkPreviews.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/ui/annotations/LightDarkPreviews.kt
deleted file mode 100644
index 48927d20..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/annotations/LightDarkPreviews.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.emergetools.hackernews.ui.annotations
-
-import android.content.res.Configuration
-import androidx.compose.ui.tooling.preview.Preview
-
-@Preview(
- name = "Light mode",
- uiMode = Configuration.UI_MODE_NIGHT_NO,
-)
-@Preview(
- name = "Dark mode",
- uiMode = Configuration.UI_MODE_NIGHT_YES,
-)
-annotation class LightDarkPreviews
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/annotations/SnapshotTestingPreviews.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/ui/annotations/SnapshotTestingPreviews.kt
deleted file mode 100644
index 136b02e4..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/annotations/SnapshotTestingPreviews.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package com.emergetools.hackernews.ui.annotations
-
-@FontScalePreviews
-@LightDarkPreviews
-annotation class SnapshotTestingPreviews
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/comments/CommentsScreen.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/ui/comments/CommentsScreen.kt
deleted file mode 100644
index eb6ec068..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/comments/CommentsScreen.kt
+++ /dev/null
@@ -1,117 +0,0 @@
-package com.emergetools.hackernews.ui.comments
-
-import android.util.Log
-import androidx.compose.foundation.layout.BoxWithConstraints
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material.CircularProgressIndicator
-import androidx.compose.material.Icon
-import androidx.compose.material.IconButton
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Scaffold
-import androidx.compose.material.Text
-import androidx.compose.material.TopAppBar
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.TextSnippet
-import androidx.compose.material.icons.filled.ArrowBack
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.navigation.NavController
-import com.airbnb.mvrx.compose.collectAsState
-import com.airbnb.mvrx.compose.mavericksActivityViewModel
-import com.emergetools.hackernews.R
-import com.emergetools.hackernews.network.models.Story
-import com.emergetools.hackernews.ui.BuildItem
-import com.emergetools.hackernews.ui.Orange
-import com.emergetools.hackernews.ui.Screen
-import com.emergetools.hackernews.ui.stories.StoriesViewModel
-
-@Composable
-fun CommentsScreen(
- navController: NavController,
- id: Long,
-) {
- val storiesViewModel: StoriesViewModel = mavericksActivityViewModel()
- val state by storiesViewModel.collectAsState()
- val story = state.stories[id]?.let {
- it as? Story
- ?: throw IllegalArgumentException("item $id not type Story, type: ${it.javaClass.simpleName}")
- } ?: throw IllegalArgumentException("item $id not found in stories map")
-
- val comments = state.comments.values.toList()
- val listState = rememberLazyListState()
- storiesViewModel.fetchComments(id);
-
- Scaffold(
- topBar = {
- TopAppBar(
- backgroundColor = Orange,
- title = {
- Text(
- text = story.title,
- color = Color.White,
- style = MaterialTheme.typography.body1,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- },
- navigationIcon = {
- IconButton(onClick = navController::popBackStack) {
- Icon(
- imageVector = Icons.Filled.ArrowBack,
- contentDescription = stringResource(R.string.content_description_back),
- )
- }
- },
- actions = {
- IconButton(onClick = { navController.navigate(Screen.Story.getRoute(id)) }) {
- Icon(
- imageVector = Icons.AutoMirrored.Default.TextSnippet,
- contentDescription = stringResource(R.string.content_description_story_button),
- tint = Color.White
- )
- }
- }
- )
- }
- ) {
- LazyColumn(state = listState) {
- itemsIndexed(comments) { index, item ->
- BuildItem(
- index = index,
- item = item,
- onItemClick = {
- Log.d("Comment onItemClick", "id: ${it.id}")
- },
- onItemPrimaryButtonClick = {
- Log.d("Comment onItemPrimaryButtonClick", "id: ${it.id}")
- }
- )
- }
-
- if (state.isLoading) {
- item {
- BoxWithConstraints(
- modifier = Modifier
- .fillMaxWidth()
- .padding(8.dp)
- ) {
- CircularProgressIndicator(
- modifier = Modifier
- .align(Alignment.Center)
- )
- }
- }
- }
- }
- }
-}
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/items/Comment.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/ui/items/Comment.kt
deleted file mode 100644
index 68eeca2a..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/items/Comment.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-package com.emergetools.hackernews.ui.items
-
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material.Divider
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import com.emergetools.hackernews.network.models.Comment
-import com.emergetools.hackernews.ui.BuildItem
-import com.emergetools.hackernews.ui.annotations.SnapshotTestingPreviews
-
-@Composable
-fun BuildComment(
- comment: Comment,
-) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(8.dp),
- ) {
- Text(
- text = comment.text,
- style = MaterialTheme.typography.body1,
- color = MaterialTheme.colors.onBackground,
- )
- }
- Divider()
-}
-
-/**
- * Example generated snapshot test from main source set.
- */
-@SnapshotTestingPreviews
-@Preview
-@Composable
-fun CommentRow() {
- val mockComment = Comment(
- id = 1,
- text = "This is a mock comment I wrote for the test",
- time = 0,
- by = "Ryan B",
- parent = null,
- replies = emptyList(),
- )
- BuildItem(
- item = mockComment,
- onItemClick = {},
- onItemPrimaryButtonClick = null,
- index = 0,
- )
-}
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/items/Story.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/ui/items/Story.kt
deleted file mode 100644
index dc1ce5c6..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/items/Story.kt
+++ /dev/null
@@ -1,154 +0,0 @@
-package com.emergetools.hackernews.ui.items
-
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material.Divider
-import androidx.compose.material.Icon
-import androidx.compose.material.IconButton
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Text
-import androidx.compose.material.TextButton
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.KeyboardArrowUp
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextDecoration
-import androidx.compose.ui.unit.dp
-import com.emergetools.hackernews.R
-import com.emergetools.hackernews.network.models.Story
-import com.emergetools.hackernews.ui.ALPHA_SECONDARY
-import com.emergetools.hackernews.utils.msToTimeAgo
-
-@Composable
-fun BuildStory(
- story: Story,
- index: Int = 0,
- onItemClick: (Story) -> Unit,
- onItemButtonClick: (Story) -> Unit,
-) {
- Row(
- modifier = Modifier
- .clickable {
- onItemClick(story)
- },
- ) {
- val storyItemModifier = Modifier.padding(top = 4.dp)
-
- IconButton(
- modifier = storyItemModifier
- .align(Alignment.Top),
- onClick = { /* TODO: Upvote */ },
- ) {
- Column {
- Icon(
- imageVector = Icons.Default.KeyboardArrowUp,
- contentDescription = stringResource(R.string.content_description_upvote),
- tint = MaterialTheme.colors.onBackground
- )
- Text(
- text = story.score.toString(),
- modifier = Modifier
- .align(Alignment.CenterHorizontally)
- .padding(top = 2.dp),
- style = MaterialTheme.typography.caption,
- color = MaterialTheme.colors.onBackground,
- textAlign = TextAlign.Center
- )
- }
- }
-
-
- Row(
- modifier = storyItemModifier
- .padding(horizontal = 2.dp)
- .weight(1f),
- ) {
-
- Column(
- modifier = Modifier
- .padding(top = 2.dp)
- ) {
- val titleText = buildAnnotatedString {
- val indexString = "${index.inc()}."
- append("${index.inc()}.")
- addStyle(
- style = SpanStyle(
- color = MaterialTheme.colors.onBackground.copy(alpha = ALPHA_SECONDARY)
- ),
- start = 0,
- end = indexString.length
- )
- append(" ")
- append(story.title)
- }
- Text(
- text = titleText,
- fontWeight = FontWeight.Medium,
- style = MaterialTheme.typography.subtitle1,
- color = MaterialTheme.colors.onBackground,
- )
-
- Text(
- text = "(${story.displayableUrl})",
- modifier = Modifier
- .alpha(ALPHA_SECONDARY)
- .padding(top = 2.dp),
- style = MaterialTheme.typography.caption,
- color = MaterialTheme.colors.onBackground,
- )
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(end = 4.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(
- R.string.story_item_date_author,
- story.time.msToTimeAgo(),
- story.by
- ),
- modifier = Modifier.alpha(ALPHA_SECONDARY),
- style = MaterialTheme.typography.caption,
- color = MaterialTheme.colors.onBackground,
- )
-
- TextButton(
- onClick = {
- if (story.commentCount > 0) {
- onItemButtonClick(story)
- }
- },
- ) {
- Text(
- text = LocalContext.current.resources.getQuantityString(
- R.plurals.comment_count,
- story.commentCount,
- story.commentCount
- ),
- style = MaterialTheme.typography.caption,
- fontWeight = FontWeight.Medium,
- color = MaterialTheme.colors.onBackground,
- textDecoration = TextDecoration.Underline
- )
- }
- }
- }
- }
- }
- Divider()
-}
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/shared/LoadingIndicator.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/ui/shared/LoadingIndicator.kt
deleted file mode 100644
index 92f349c3..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/shared/LoadingIndicator.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-package com.emergetools.hackernews.ui.shared
-
-import androidx.compose.foundation.layout.BoxWithConstraints
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material.CircularProgressIndicator
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import com.emergetools.hackernews.ui.HNTheme
-import com.emergetools.snapshots.annotations.IgnoreEmergeSnapshot
-
-@Composable
-fun LoadingIndicator() {
- BoxWithConstraints(
- modifier = Modifier
- .fillMaxWidth()
- .padding(8.dp)
- ) {
- CircularProgressIndicator(
- modifier = Modifier
- .align(Alignment.Center)
- )
- }
-}
-
-@Preview("Loading indicator")
-@Composable
-fun LoadingIndicatorPreview() {
- HNTheme {
- LoadingIndicator()
- }
-}
-
-// A sample ignored snapshot
-@Preview("Loading indicator (ignored)")
-@IgnoreEmergeSnapshot
-@Composable
-fun LoadingIndicatorPreviewIgnored() {
- HNTheme {
- LoadingIndicator()
- }
-}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/stories/StoriesScreen.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/ui/stories/StoriesScreen.kt
deleted file mode 100644
index 231a102c..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/stories/StoriesScreen.kt
+++ /dev/null
@@ -1,230 +0,0 @@
-package com.emergetools.hackernews.ui.stories
-
-import android.util.Log
-import androidx.compose.foundation.layout.BoxWithConstraints
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material.CircularProgressIndicator
-import androidx.compose.material.Icon
-import androidx.compose.material.IconButton
-import androidx.compose.material.Scaffold
-import androidx.compose.material.Text
-import androidx.compose.material.TopAppBar
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material.icons.filled.Refresh
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.navigation.NavController
-import com.airbnb.mvrx.Async
-import com.airbnb.mvrx.MavericksState
-import com.airbnb.mvrx.MavericksViewModel
-import com.airbnb.mvrx.MavericksViewModelFactory
-import com.airbnb.mvrx.Uninitialized
-import com.airbnb.mvrx.ViewModelContext
-import com.airbnb.mvrx.compose.collectAsState
-import com.airbnb.mvrx.compose.mavericksActivityViewModel
-import com.emergetools.hackernews.R
-import com.emergetools.hackernews.network.HNApi
-import com.emergetools.hackernews.network.HNApiService
-import com.emergetools.hackernews.network.models.Item
-import com.emergetools.hackernews.network.models.Story
-import com.emergetools.hackernews.ui.BuildItem
-import com.emergetools.hackernews.ui.Orange
-import com.emergetools.hackernews.ui.Screen
-import com.emergetools.hackernews.utils.forEachInParallel
-
-@Composable
-fun StoriesScreen(navController: NavController) {
- val storiesViewModel: StoriesViewModel = mavericksActivityViewModel()
-
- val state by storiesViewModel.collectAsState()
- val stories = state.stories.values.toList()
-
- val listState = rememberLazyListState()
-
- Scaffold(
- topBar = {
- StoriesToolbar(
- refreshAction = storiesViewModel::fetchTopStories,
- )
- }
- ) {
- LazyColumn(state = listState) {
- itemsIndexed(stories) { index, item ->
- Log.d("item", "item.id: ${item.id}")
- BuildItem(
- index = index,
- item = item,
- onItemClick = {
- Log.d("StoriesScreen", "id: ${it.id}")
- when (it) {
- is Story -> navController.navigate(Screen.Story.getRoute(it.id))
- else -> TODO()
- }
- },
- onItemPrimaryButtonClick = {
- when (it) {
- is Story -> navController.navigate(Screen.Comments.getRoute(it.id))
- else -> TODO()
- }
- }
- )
-
- if (index == stories.lastIndex) {
- storiesViewModel.fetchAdditionalStories()
- }
- }
-
- if (state.isLoading) {
- item {
- BoxWithConstraints(
- modifier = Modifier
- .fillMaxWidth()
- .padding(8.dp)
- ) {
- CircularProgressIndicator(
- modifier = Modifier
- .align(Alignment.Center)
- )
- }
- }
- }
- }
- }
-}
-
-@Composable
-fun StoriesToolbar(
- refreshAction: () -> Unit,
-) {
- TopAppBar(
- backgroundColor = Orange,
- title = { Text(stringResource(R.string.app_name), color = Color.White) },
- actions = {
- var expanded by remember { mutableStateOf(false) }
-
- // TODO: Set spinning if currently refreshing
- IconButton(onClick = refreshAction) {
- Icon(
- imageVector = Icons.Default.Refresh,
- contentDescription = stringResource(R.string.refresh),
- tint = Color.White
- )
- }
-
- IconButton(onClick = { expanded = !expanded }) {
- Icon(
- imageVector = Icons.Default.MoreVert,
- contentDescription = stringResource(R.string.more),
- tint = Color.White
- )
- }
- }
- )
-}
-
-data class StoriesState(
- val topStoriesResponse: Async> = Uninitialized,
- val storyResponse: Async- = Uninitialized,
- val commentResponse: Async
- = Uninitialized,
- val stories: Map = emptyMap(),
- val comments: Map = emptyMap(),
- val start: Int = 0,
-) : MavericksState {
- val isLoading = !storyResponse.complete || !topStoriesResponse.complete || !commentResponse.complete;
-}
-
-class StoriesViewModel(
- initialState: StoriesState,
-) : MavericksViewModel(initialState) {
-
- private val api: HNApiService
- get() = HNApi.retrofitService
-
- init {
- fetchTopStories()
-
- onAsync(StoriesState::topStoriesResponse) {
- setState {
- copy(stories = emptyMap())
- }
-
- withState { state ->
- it.subList(state.start, state.start + OFFSET).forEach(::fetchStory)
- }
- }
-
- onEach(StoriesState::start) { start ->
- withState { state ->
- val topStories = state.topStoriesResponse()
- val allStoriesCount = topStories?.size ?: return@withState
- // Fetch stories up to the current size + OFFSET,
- // or the total top stories count if near/past the end.
- val end = if (start + OFFSET > allStoriesCount) {
- if (start >= allStoriesCount) return@withState
- allStoriesCount
- } else start + OFFSET
-
- topStories.subList(start, end).forEachInParallel(::fetchStory)
- }
- }
- }
-
- fun fetchTopStories() {
- suspend { api.getTopStories() }.execute { response ->
- copy(topStoriesResponse = response)
- }
- }
-
- fun fetchAdditionalStories() = setState {
- copy(start = stories.size)
- }
-
- private fun fetchStory(id: Long) {
- suspend { api.getItem(id) }.execute { response ->
- val updatedStories = stories.toMutableMap()
- response()?.let { updatedStories.putIfAbsent(it.id, it) }
- copy(
- storyResponse = response,
- stories = updatedStories,
- )
- }
- }
-
- fun fetchComments(id: Long) {
- withState { state ->
- val story = state.stories[id];
- when (story) {
- is Story -> story.comments.forEach(::fetchComment)
- else -> TODO()
- }
- }
- }
-
- private fun fetchComment(id: Long) {
- suspend { api.getItem(id) }.execute { response ->
- val updatedComments = comments.toMutableMap()
- response()?.let { updatedComments.putIfAbsent(it.id, it) }
- copy(
- commentResponse = response,
- comments = updatedComments,
- )
- }
- }
- companion object {
-
- const val OFFSET = 20
- }
-}
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/story/StoryScreen.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/ui/story/StoryScreen.kt
deleted file mode 100644
index 1b6c6099..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/ui/story/StoryScreen.kt
+++ /dev/null
@@ -1,89 +0,0 @@
-package com.emergetools.hackernews.ui.story
-
-import androidx.compose.material.Icon
-import androidx.compose.material.IconButton
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Scaffold
-import androidx.compose.material.Text
-import androidx.compose.material.TopAppBar
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowBack
-import androidx.compose.material.icons.filled.Comment
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.navigation.NavController
-import com.airbnb.mvrx.compose.collectAsState
-import com.airbnb.mvrx.compose.mavericksActivityViewModel
-import com.emergetools.hackernews.R
-import com.emergetools.hackernews.network.models.Story
-import com.emergetools.hackernews.ui.Orange
-import com.emergetools.hackernews.ui.Screen
-import com.emergetools.hackernews.ui.shared.LoadingIndicator
-import com.emergetools.hackernews.ui.stories.StoriesViewModel
-import com.google.accompanist.web.WebView
-import com.google.accompanist.web.rememberWebViewState
-
-@Composable
-fun StoryScreen(
- navController: NavController,
- id: Long,
-) {
- val storiesViewModel: StoriesViewModel = mavericksActivityViewModel()
- val state by storiesViewModel.collectAsState()
- val story = state.stories[id]?.let {
- it as? Story
- ?: throw IllegalArgumentException("item $id not type Story, type: ${it.javaClass.simpleName}")
- } ?: throw IllegalArgumentException("item $id not found in stories map")
-
- Scaffold(
- topBar = {
- // TODO: Push up on scroll of webview
- TopAppBar(
- backgroundColor = Orange,
- title = {
- Text(
- text = story.title,
- color = Color.White,
- style = MaterialTheme.typography.body1,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- },
- navigationIcon = {
- IconButton(onClick = navController::popBackStack) {
- Icon(
- imageVector = Icons.Filled.ArrowBack,
- contentDescription = stringResource(R.string.content_description_back),
- )
- }
- },
- actions = {
- IconButton(onClick = { navController.navigate(Screen.Comments.getRoute(id)) }) {
- Icon(
- imageVector = Icons.Default.Comment,
- contentDescription = stringResource(R.string.content_description_comment_button),
- tint = Color.White
- )
- }
- }
- )
- }
- ) {
- val webViewState = rememberWebViewState(url = story.url)
-
- if (webViewState.isLoading) {
- LoadingIndicator()
- }
-
- // TODO: Fix issue not showing for http (non-https) sites
- WebView(
- state = webViewState,
- onCreated = { webView ->
- webView.settings.javaScriptEnabled = true
- }
- )
- }
-}
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/utils/CoroutineUtils.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/utils/CoroutineUtils.kt
deleted file mode 100644
index c1d5974e..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/utils/CoroutineUtils.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package com.emergetools.hackernews.utils
-
-import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.runBlocking
-
-fun Sequence.mapAsync(transform: suspend (T) -> R): List> {
- return map {
- GlobalScope.async { transform(it) }
- }.toList()
-}
-
-fun Iterable.mapAsync(transform: suspend (T) -> R): List> {
- return map {
- GlobalScope.async { transform(it) }
- }
-}
-
-fun Iterable.forEachInParallel(block: suspend (T) -> Unit) {
- mapAsync(block).awaitAllBlocking()
-}
-
-/**
- * Helper to synchronously wait for all deferred items to complete.
- */
-fun Sequence>.awaitAllBlocking(): List {
- return runBlocking { toList().awaitAll() }
-}
-
-/**
- * Helper to synchronously wait for all deferred items to complete.
- */
-fun List>.awaitAllBlocking(): List {
- return runBlocking { awaitAll() }
-}
diff --git a/android/app/src/main/kotlin/com/emergetools/hackernews/utils/TimeUtils.kt b/android/app/src/main/kotlin/com/emergetools/hackernews/utils/TimeUtils.kt
deleted file mode 100644
index 053acc40..00000000
--- a/android/app/src/main/kotlin/com/emergetools/hackernews/utils/TimeUtils.kt
+++ /dev/null
@@ -1,80 +0,0 @@
-package com.emergetools.hackernews.utils
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.LocalContext
-import com.emergetools.hackernews.R
-
-const val MILLIS_IN_SEC = 1000
-const val SEC_IN_MIN = 60
-const val SEC_IN_HOUR = 3600
-const val SEC_IN_DAY = 86400
-const val SEC_IN_WEEK = 604800
-const val SEC_IN_MONTH = 2_628_000
-const val SEC_IN_YEAR = 31_536_000
-
-@Composable
-fun Long.msToTimeAgo(): String {
- val secondsAgo = (System.currentTimeMillis() / MILLIS_IN_SEC) - this
-
- return when {
- secondsAgo < SEC_IN_MIN -> LocalContext.current.resources.getQuantityString(
- R.plurals.seconds_ago,
- secondsAgo.toInt(),
- secondsAgo
- )
-
- secondsAgo < SEC_IN_HOUR -> {
- val minutes = secondsAgo / SEC_IN_MIN
- LocalContext.current.resources.getQuantityString(
- R.plurals.minutes_ago,
- minutes.toInt(),
- minutes
- )
- }
-
- secondsAgo < SEC_IN_DAY -> {
- val hours = secondsAgo / SEC_IN_HOUR
- LocalContext.current.resources.getQuantityString(
- R.plurals.hours_ago,
- hours.toInt(),
- hours
- )
- }
-
- secondsAgo < SEC_IN_WEEK -> {
- val days = secondsAgo / SEC_IN_DAY
- LocalContext.current.resources.getQuantityString(
- R.plurals.days_ago,
- days.toInt(),
- days
- )
- }
-
- secondsAgo < SEC_IN_MONTH -> {
- val weeks = secondsAgo / SEC_IN_WEEK
- LocalContext.current.resources.getQuantityString(
- R.plurals.weeks_ago,
- weeks.toInt(),
- weeks
- )
- }
-
- secondsAgo < SEC_IN_YEAR -> {
- val months = secondsAgo / SEC_IN_MONTH
- LocalContext.current.resources.getQuantityString(
- R.plurals.months_ago,
- months.toInt(),
- months
- )
- }
-
- else -> {
- val years = secondsAgo / SEC_IN_YEAR
- LocalContext.current.resources.getQuantityString(
- R.plurals.years_ago,
- years.toInt(),
- years.toInt()
- )
- }
- }
-}
\ No newline at end of file
diff --git a/android/app/src/main/res/drawable/ic_chat.xml b/android/app/src/main/res/drawable/ic_chat.xml
new file mode 100644
index 00000000..8a0a234c
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_chat.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..07d5da9c
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..2b068d11
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/drawable/sample.9.png b/android/app/src/main/res/drawable/sample.9.png
deleted file mode 100644
index 3fd617bf..00000000
Binary files a/android/app/src/main/res/drawable/sample.9.png and /dev/null differ
diff --git a/android/app/src/main/res/font/ibm_plex_sans_bold.ttf b/android/app/src/main/res/font/ibm_plex_sans_bold.ttf
new file mode 100644
index 00000000..2e437e21
Binary files /dev/null and b/android/app/src/main/res/font/ibm_plex_sans_bold.ttf differ
diff --git a/android/app/src/main/res/font/ibm_plex_sans_medium.ttf b/android/app/src/main/res/font/ibm_plex_sans_medium.ttf
new file mode 100644
index 00000000..9395402b
Binary files /dev/null and b/android/app/src/main/res/font/ibm_plex_sans_medium.ttf differ
diff --git a/android/app/src/main/res/font/ibm_plex_sans_regular.ttf b/android/app/src/main/res/font/ibm_plex_sans_regular.ttf
new file mode 100644
index 00000000..81ca3dcc
Binary files /dev/null and b/android/app/src/main/res/font/ibm_plex_sans_regular.ttf differ
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
deleted file mode 100644
index 501c6926..00000000
--- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
deleted file mode 100644
index 501c6926..00000000
--- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 00000000..6f3b755b
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
new file mode 100644
index 00000000..6f3b755b
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
deleted file mode 100644
index 3ef6a29e..00000000
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 00000000..c209e78e
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
deleted file mode 100644
index 1afff257..00000000
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
deleted file mode 100644
index 18a488f6..00000000
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..b2dfe3d1
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100644
index 69a472c0..00000000
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 00000000..4f0f1d64
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
deleted file mode 100644
index 317d9fdf..00000000
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
deleted file mode 100644
index 6d39503a..00000000
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..62b611da
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100644
index 1dda24ea..00000000
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 00000000..948a3070
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
deleted file mode 100644
index 3576d0da..00000000
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
deleted file mode 100644
index 4eeceb83..00000000
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..1b9a6956
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100644
index 124427f3..00000000
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..28d4b77f
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
deleted file mode 100644
index f2a8be22..00000000
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
deleted file mode 100644
index a9415a92..00000000
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9287f508
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100644
index 9aac9ee2..00000000
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..aa7d6427
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
deleted file mode 100644
index 2452e9e6..00000000
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
deleted file mode 100644
index 762707d0..00000000
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9126ae37
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index 3045da4a..f8c6127d 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -1,7 +1,10 @@
- #ff6600
- #ff0000
- #00ff00
- #0000ff
-
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml
deleted file mode 100644
index 4394a9de..00000000
--- a/android/app/src/main/res/values/ic_launcher_background.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- #FB661E
-
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 8df34049..1e95f081 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1,61 +1,3 @@
-
- - No comments
- - %d comment
- - %d comments
-
-
-
- - %d second ago
- - %d seconds ago
-
-
- - %d minute ago
- - %d minutes ago
-
-
-
-
- - %d hour ago
- - %d hours ago
-
-
- - %d day ago
- - %d days ago
-
-
- - %d week ago
- - %d weeks ago
-
-
- - %d month ago
- - %d months ago
-
-
-
-
- - %d year ago
- - %d years ago
-
- Hacker News
- Refresh
- More
-
- Welcome to Hacker News
-
- Login to browse stories
-
- Login
-
- Logout
-
- %s by %s
-
- Upvote
-
- Navigate back
-
- View comments
-
- View story
-
+ Hacker News
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
index 093823a8..91f77dae 100644
--- a/android/app/src/main/res/values/themes.xml
+++ b/android/app/src/main/res/values/themes.xml
@@ -1,7 +1,5 @@
-
+
+
-
-
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 00000000..fa0f996d
--- /dev/null
+++ b/android/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 00000000..9ee9997b
--- /dev/null
+++ b/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index b1c9b834..2653edaf 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -1,7 +1,7 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
- alias(libs.plugins.android.test) apply false
- alias(libs.plugins.emerge) apply false
- alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.android) apply false
-}
+ alias(libs.plugins.compose.compiler) apply false
+ alias(libs.plugins.kotlin.serialization) apply false
+}
\ No newline at end of file
diff --git a/android/gradle.properties b/android/gradle.properties
index 98bed167..20e2a015 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -8,14 +8,16 @@
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
-# This option should only be used with decoupled projects. More details, visit
-# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
-# Android operating system, and which are packaged with your app"s APK
+# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
-# Automatically convert third-party libraries to use AndroidX
-android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
-kotlin.code.style=official
\ No newline at end of file
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 85503dac..ea7fa5ee 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -1,63 +1,57 @@
[versions]
-agp = "8.1.0"
-androidx-core = "1.12.0"
-androidx-test = "1.5.0"
-androidx-test-ext = "1.1.5"
-accompanist = "0.33.2-alpha"
-compose-bom = "2024.02.02"
-compose-compiler-extension = "1.5.3"
-emerge-gradle-plugin = "3.1.0"
-emerge-perf = "2.1.1"
-emerge-snapshots = "1.1.0"
-kotlin = "1.9.10"
-material-compose = "1.6.3"
-navigation-compose = "2.7.7"
-okhttp = "4.11.0"
+agp = "8.5.0"
+kotlin = "2.0.0"
+coreKtx = "1.13.1"
+junit = "4.13.2"
+junitVersion = "1.1.5"
+espressoCore = "3.5.1"
+lifecycleRuntimeKtx = "2.8.2"
+activityCompose = "1.9.0"
+retrofit = "2.11.0"
+okhttp = "4.12.0"
+kotlinx-serialization-json = "1.6.3"
+viewmodel = "2.8.2"
+navigation = "2.8.0-beta04"
+accompanist = "0.34.0"
+browser = "1.5.0"
+emergePlugin = "3.1.1"
+emergeSnapshots = "1.1.2"
+composeCompilerExtension = "1.5.3"
+composeBom = "2024.06.00"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "viewmodel" }
+androidx-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation"}
+androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
+
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+
+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
+
+accompanist-webview = { group = "com.google.accompanist", name = "accompanist-webview", version.ref = "accompanist" }
+
+emerge-snapshots = { group = "com.emergetools.snapshots", name = "snapshots", version.ref = "emergeSnapshots" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
-android-test = { id = "com.android.test", version.ref = "agp" }
-emerge = { id = "com.emergetools.android", version.ref = "emerge-gradle-plugin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
-
-[libraries]
-accompanist-navigationanim = { module = "com.google.accompanist:accompanist-navigation-animation", version.ref = "accompanist" }
-accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" }
-
-androidx-activity-compose = {module = "androidx.activity:activity-compose", version = "1.8.2" }
-androidx-appcompat = { module = "androidx.appcompat:appcompat", version = "1.6.1" }
-androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
-androidx-datastore-preferences = "androidx.datastore:datastore-preferences:1.0.0"
-androidx-fragment = { module = "androidx.fragment:fragment-ktx", version = "1.6.2" }
-androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test" }
-androidx-test-ext-junit = { module = "androidx.test.ext:junit-ktx", version.ref = "androidx-test-ext" }
-androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" }
-
-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
-
-emerge-perf = { module = "com.emergetools.test:performance", version.ref = "emerge-perf" }
-emerge-snapshots = { module = "com.emergetools.snapshots:snapshots", version.ref = "emerge-snapshots" }
-emerge-snapshots-annotations = { module = "com.emergetools.snapshots:snapshots-annotations", version.ref = "emerge-snapshots" }
-
-junit = "junit:junit:4.13.2"
-
-kotlinx-serialization = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
-
-material-core = "com.google.android.material:material:1.11.0"
-material-compose-core = { module = "androidx.compose.material:material", version.ref = "material-compose" }
-material-compose-icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "material-compose" }
-
-mavericks-compose = "com.airbnb.android:mavericks-compose:2.7.0"
-
-navigation-compose-core = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
-navigation-compose-ktx = { module = "androidx.navigation:navigation-runtime-ktx", version.ref = "navigation-compose" }
-
-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
-
-retrofit-core = "com.squareup.retrofit2:retrofit:2.9.0"
-retrofit-serialization = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0"
+emerge = { id = "com.emergetools.android", version.ref = "emergePlugin"}
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
index 249e5832..e708b1c0 100644
Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index f72df95a..d0fc2a78 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,6 @@
+#Mon Jun 24 13:05:33 PDT 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
index a69d9cb6..4f906e0c 100755
--- a/android/gradlew
+++ b/android/gradlew
@@ -1,7 +1,7 @@
-#!/bin/sh
+#!/usr/bin/env sh
#
-# Copyright © 2015-2021 the original authors.
+# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,101 +17,67 @@
#
##############################################################################
-#
-# Gradle start up script for POSIX generated by Gradle.
-#
-# Important for running:
-#
-# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
-# noncompliant, but you have some other compliant shell such as ksh or
-# bash, then to run this script, type that shell name before the whole
-# command line, like:
-#
-# ksh Gradle
-#
-# Busybox and similar reduced shells will NOT work, because this script
-# requires all of these POSIX shell features:
-# * functions;
-# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
-# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
-# * compound commands having a testable exit status, especially «case»;
-# * various built-in commands including «command», «set», and «ulimit».
-#
-# Important for patching:
-#
-# (2) This script targets any POSIX shell, so it avoids extensions provided
-# by Bash, Ksh, etc; in particular arrays are avoided.
-#
-# The "traditional" practice of packing multiple parameters into a
-# space-separated string is a well documented source of bugs and security
-# problems, so this is (mostly) avoided, by progressively accumulating
-# options in "$@", and eventually passing that to Java.
-#
-# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
-# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
-# see the in-line comments for details.
-#
-# There are tweaks for specific operating systems such as AIX, CygWin,
-# Darwin, MinGW, and NonStop.
-#
-# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
-# within the Gradle project.
-#
-# You can find Gradle at https://github.com/gradle/gradle/.
-#
+##
+## Gradle start up script for UN*X
+##
##############################################################################
# Attempt to set APP_HOME
-
# Resolve links: $0 may be a link
-app_path=$0
-
-# Need this for daisy-chained symlinks.
-while
- APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
- [ -h "$app_path" ]
-do
- ls=$( ls -ld "$app_path" )
- link=${ls#*' -> '}
- case $link in #(
- /*) app_path=$link ;; #(
- *) app_path=$APP_HOME$link ;;
- esac
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
done
-
-APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
APP_NAME="Gradle"
-APP_BASE_NAME=${0##*/}
+APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
-MAX_FD=maximum
+MAX_FD="maximum"
warn () {
echo "$*"
-} >&2
+}
die () {
echo
echo "$*"
echo
exit 1
-} >&2
+}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
-case "$( uname )" in #(
- CYGWIN* ) cygwin=true ;; #(
- Darwin* ) darwin=true ;; #(
- MSYS* | MINGW* ) msys=true ;; #(
- NONSTOP* ) nonstop=true ;;
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
@@ -121,9 +87,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
- JAVACMD=$JAVA_HOME/jre/sh/java
+ JAVACMD="$JAVA_HOME/jre/sh/java"
else
- JAVACMD=$JAVA_HOME/bin/java
+ JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@@ -132,7 +98,7 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
- JAVACMD=java
+ JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
@@ -140,101 +106,80 @@ location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
-if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
- case $MAX_FD in #(
- max*)
- MAX_FD=$( ulimit -H -n ) ||
- warn "Could not query maximum file descriptor limit"
- esac
- case $MAX_FD in #(
- '' | soft) :;; #(
- *)
- ulimit -n "$MAX_FD" ||
- warn "Could not set maximum file descriptor limit to $MAX_FD"
- esac
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
fi
-# Collect all arguments for the java command, stacking in reverse order:
-# * args from the command line
-# * the main class name
-# * -classpath
-# * -D...appname settings
-# * --module-path (only if needed)
-# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
# For Cygwin or MSYS, switch paths to Windows format before running java
-if "$cygwin" || "$msys" ; then
- APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
- CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
-
- JAVACMD=$( cygpath --unix "$JAVACMD" )
-
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
- for arg do
- if
- case $arg in #(
- -*) false ;; # don't mess with options #(
- /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
- [ -e "$t" ] ;; #(
- *) false ;;
- esac
- then
- arg=$( cygpath --path --ignore --mixed "$arg" )
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
fi
- # Roll the args list around exactly as many times as the number of
- # args, so each arg winds up back in the position where it started, but
- # possibly modified.
- #
- # NB: a `for` loop captures its iteration list before it begins, so
- # changing the positional parameters here affects neither the number of
- # iterations, nor the values presented in `arg`.
- shift # remove old arg
- set -- "$@" "$arg" # push replacement arg
+ i=`expr $i + 1`
done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
fi
-# Collect all arguments for the java command;
-# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
-# shell script including quotes and variable substitutions, so put them in
-# double quotes to make sure that they get re-expanded; and
-# * put everything else in single quotes, so that it's not re-expanded.
-
-set -- \
- "-Dorg.gradle.appname=$APP_BASE_NAME" \
- -classpath "$CLASSPATH" \
- org.gradle.wrapper.GradleWrapperMain \
- "$@"
-
-# Stop when "xargs" is not available.
-if ! command -v xargs >/dev/null 2>&1
-then
- die "xargs is not available"
-fi
-
-# Use "xargs" to parse quoted args.
-#
-# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
-#
-# In Bash we could simply go:
-#
-# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
-# set -- "${ARGS[@]}" "$@"
-#
-# but POSIX shell has neither arrays nor command substitution, so instead we
-# post-process each arg (as a line of input to sed) to backslash-escape any
-# character that might be a shell metacharacter, then use eval to reverse
-# that process (while maintaining the separation between arguments), and wrap
-# the whole thing up as a single "set" statement.
-#
-# This will of course break if any of these variables contains a newline or
-# an unmatched quote.
-#
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
-eval "set -- $(
- printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
- xargs -n1 |
- sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
- tr '\n' ' '
- )" '"$@"'
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
index 53a6b238..ac1b06f9 100644
--- a/android/gradlew.bat
+++ b/android/gradlew.bat
@@ -14,7 +14,7 @@
@rem limitations under the License.
@rem
-@if "%DEBUG%"=="" @echo off
+@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@@ -25,7 +25,7 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
-if "%DIRNAME%"=="" set DIRNAME=.
+if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
-if %ERRORLEVEL% equ 0 goto execute
+if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@@ -75,15 +75,13 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
:end
@rem End local scope for the variables with windows NT shell
-if %ERRORLEVEL% equ 0 goto mainEnd
+if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
-set EXIT_CODE=%ERRORLEVEL%
-if %EXIT_CODE% equ 0 set EXIT_CODE=1
-if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
-exit /b %EXIT_CODE%
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
diff --git a/android/performance/build.gradle.kts b/android/performance/build.gradle.kts
deleted file mode 100644
index c0993c2c..00000000
--- a/android/performance/build.gradle.kts
+++ /dev/null
@@ -1,7 +0,0 @@
-plugins {
- alias(libs.plugins.kotlin.android)
-}
-
-dependencies {
- implementation(libs.emerge.perf)
-}
diff --git a/android/performance/src/main/AndroidManifest.xml b/android/performance/src/main/AndroidManifest.xml
deleted file mode 100644
index 87e878fa..00000000
--- a/android/performance/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
\ No newline at end of file
diff --git a/android/performance/src/main/java/com/emergetools/hackernews/performance/CustomSpanTest.kt b/android/performance/src/main/java/com/emergetools/hackernews/performance/CustomSpanTest.kt
deleted file mode 100644
index 36e6833c..00000000
--- a/android/performance/src/main/java/com/emergetools/hackernews/performance/CustomSpanTest.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-package com.emergetools.hackernews.performance
-
-import android.content.Context
-import android.content.Intent
-import androidx.test.core.app.ApplicationProvider
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.uiautomator.By
-import androidx.test.uiautomator.UiDevice
-import androidx.test.uiautomator.Until
-import com.emergetools.test.annotations.EmergeTest
-import org.junit.runner.RunWith
-
-private const val LAUNCH_TIMEOUT = 5000L
-private const val APP_PACKAGE_NAME = "com.emergetools.hackernews"
-
-@RunWith(AndroidJUnit4::class)
-class StartupDeeplinkTest {
-
- @EmergeTest(spans = ["MainActivity.onCreate"])
- fun test() {
- val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
- device.pressHome()
-
- // Wait for launcher
- device.wait(Until.hasObject(By.pkg(device.launcherPackageName).depth(0)), LAUNCH_TIMEOUT)
-
- // Launch the app
- val context = InstrumentationRegistry.getInstrumentation().context
- val intent = checkNotNull(context.packageManager.getLaunchIntentForPackage(APP_PACKAGE_NAME)) {
- "Could not get launch intent for package $APP_PACKAGE_NAME"
- }
-
- // Clear out any previous instances
- intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- context.startActivity(intent)
-
- // Wait for the app to appear
- device.wait(Until.hasObject(By.pkg(APP_PACKAGE_NAME).depth(0)), LAUNCH_TIMEOUT)
- }
-}
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index beee951a..10f1afc5 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -1,22 +1,23 @@
-rootProject.name = "HackerNews"
-
pluginManagement {
repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
gradlePluginPortal()
- google()
}
}
-
dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
-enableFeaturePreview("STABLE_CONFIGURATION_CACHE")
-
-include(
- ":app",
- ":performance"
-)
+rootProject.name = "Hacker News"
+include(":app")