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 @@ - + + - - +