From d315a62d3ee54474eff43d23b6294fb09a290ec5 Mon Sep 17 00:00:00 2001 From: Rikin Marfatia Date: Tue, 23 Jul 2024 12:10:35 -0700 Subject: [PATCH] Initial Login Infrastructure (#78) This PR adds one core dependency and a bunch of infrastrucutre to support login and login-based actions. Because HN doesn't have official API support for Login, Upvotes, Commenting, etc, the only way that clients can perform those actions is by requesting various web routes and scraping the HTML for data. Added `jsoup` as a dependency to make working with HTML a lot nicer, as it also allows you to query HTML documents using CSS selectors. Also created a `WebClient` which is reponsible for all things that require scraping the Hacker News website. Lastly I `CookieJar` to our OkHttp client so that it can store the "auth cookie" when we successfully login. We need this cooking on all subsequent web client requests in order to get access to actions. --- android/app/build.gradle.kts | 4 +- .../com/emergetools/hackernews/AppActivity.kt | 4 +- .../hackernews/HackerNewsApplication.kt | 41 ++++++-- .../hackernews/data/HackerNewsWebClient.kt | 88 ++++++++++++++++++ .../hackernews/data/LocalCookieJar.kt | 33 +++++++ .../hackernews/data/UserStorage.kt | 22 +++++ .../features/comments/CommentsDomain.kt | 57 ++++++++++-- .../features/comments/CommentsRouting.kt | 9 +- .../features/comments/CommentsScreen.kt | 79 +++++++++++++--- .../hackernews/features/login/LoginDomain.kt | 84 +++++++++++++++++ .../hackernews/features/login/LoginRouting.kt | 39 ++++++++ .../hackernews/features/login/LoginScreen.kt | 93 +++++++++++++++++++ .../features/settings/SettingsDomain.kt | 61 ++++++++++++ .../features/settings/SettingsRouting.kt | 29 +++++- .../features/settings/SettingsScreen.kt | 78 +++++++++++++++- .../features/stories/StoryScreen.kt | 24 +---- android/gradle/libs.versions.toml | 14 +-- 17 files changed, 694 insertions(+), 65 deletions(-) create mode 100644 android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsWebClient.kt create mode 100644 android/app/src/main/java/com/emergetools/hackernews/data/LocalCookieJar.kt create mode 100644 android/app/src/main/java/com/emergetools/hackernews/data/UserStorage.kt create mode 100644 android/app/src/main/java/com/emergetools/hackernews/features/login/LoginDomain.kt create mode 100644 android/app/src/main/java/com/emergetools/hackernews/features/login/LoginRouting.kt create mode 100644 android/app/src/main/java/com/emergetools/hackernews/features/login/LoginScreen.kt create mode 100644 android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsDomain.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e7193115..8daf86fc 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -88,18 +88,18 @@ dependencies { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(libs.androidx.browser) + implementation(libs.androidx.datastore) implementation(libs.okhttp) implementation(libs.retrofit) implementation(libs.retrofit.kotlinx.serialization) implementation(libs.kotlinx.serialization.json) + implementation(libs.jsoup) implementation(libs.androidx.room) implementation(libs.androidx.room.ktx) ksp(libs.androidx.room.compiler) - implementation(libs.accompanist.webview) - testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/android/app/src/main/java/com/emergetools/hackernews/AppActivity.kt b/android/app/src/main/java/com/emergetools/hackernews/AppActivity.kt index a012be46..d713ecca 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/AppActivity.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/AppActivity.kt @@ -34,6 +34,7 @@ import com.emergetools.hackernews.data.LocalCustomTabsIntent import com.emergetools.hackernews.features.bookmarks.BookmarksNavigation import com.emergetools.hackernews.features.bookmarks.bookmarksRoutes import com.emergetools.hackernews.features.comments.commentsRoutes +import com.emergetools.hackernews.features.login.loginRoutes import com.emergetools.hackernews.features.settings.settingsRoutes import com.emergetools.hackernews.features.stories.Stories import com.emergetools.hackernews.features.stories.StoriesDestinations.Feed @@ -121,7 +122,8 @@ fun App() { storiesGraph(navController) commentsRoutes() bookmarksRoutes(navController) - settingsRoutes() + settingsRoutes(navController) + loginRoutes(navController) } } } diff --git a/android/app/src/main/java/com/emergetools/hackernews/HackerNewsApplication.kt b/android/app/src/main/java/com/emergetools/hackernews/HackerNewsApplication.kt index 96c8d879..0b3de615 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/HackerNewsApplication.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/HackerNewsApplication.kt @@ -2,27 +2,33 @@ package com.emergetools.hackernews import android.app.Application import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore import androidx.room.Room import com.emergetools.hackernews.data.BookmarkDao import com.emergetools.hackernews.data.HackerNewsBaseDataSource import com.emergetools.hackernews.data.HackerNewsDatabase import com.emergetools.hackernews.data.HackerNewsSearchClient +import com.emergetools.hackernews.data.HackerNewsWebClient import com.emergetools.hackernews.data.ItemRepository +import com.emergetools.hackernews.data.LocalCookieJar +import com.emergetools.hackernews.data.UserStorage import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import java.time.Duration class HackerNewsApplication: Application() { private val json = Json { ignoreUnknownKeys = true } - private val httpClient = OkHttpClient.Builder() - .readTimeout(Duration.ofSeconds(30)) - .build() - private val baseClient = HackerNewsBaseDataSource(json, httpClient) - val searchClient = HackerNewsSearchClient(json, httpClient) - val itemRepository = ItemRepository(baseClient) + private lateinit var httpClient: OkHttpClient + private lateinit var baseClient: HackerNewsBaseDataSource lateinit var bookmarkDao: BookmarkDao + lateinit var userStorage: UserStorage + lateinit var searchClient: HackerNewsSearchClient + lateinit var webClient: HackerNewsWebClient + lateinit var itemRepository: ItemRepository override fun onCreate() { super.onCreate() @@ -32,19 +38,40 @@ class HackerNewsApplication: Application() { HackerNewsDatabase::class.java, "hackernews", ).build() - bookmarkDao = db.bookmarkDao() + + userStorage = UserStorage(applicationContext) + + httpClient = OkHttpClient.Builder() + .readTimeout(Duration.ofSeconds(30)) + .cookieJar(LocalCookieJar(userStorage)) + .build() + + baseClient = HackerNewsBaseDataSource(json, httpClient) + searchClient = HackerNewsSearchClient(json, httpClient) + webClient = HackerNewsWebClient(httpClient) + itemRepository = ItemRepository(baseClient) } } +val Context.dataStore: DataStore by preferencesDataStore(name = "user") + fun Context.itemRepository(): ItemRepository { return (this.applicationContext as HackerNewsApplication).itemRepository } +fun Context.userStorage(): UserStorage { + return (this.applicationContext as HackerNewsApplication).userStorage +} + fun Context.searchClient(): HackerNewsSearchClient { return (this.applicationContext as HackerNewsApplication).searchClient } +fun Context.webClient(): HackerNewsWebClient { + return (this.applicationContext as HackerNewsApplication).webClient +} + fun Context.bookmarkDao(): BookmarkDao { return (this.applicationContext as HackerNewsApplication).bookmarkDao } diff --git a/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsWebClient.kt b/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsWebClient.kt new file mode 100644 index 00000000..d4d627df --- /dev/null +++ b/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsWebClient.kt @@ -0,0 +1,88 @@ +package com.emergetools.hackernews.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jsoup.Jsoup + +const val BASE_WEB_URL = "https://news.ycombinator.com/" +private const val LOGIN_URL = BASE_WEB_URL + "login" +private const val ITEM_URL = BASE_WEB_URL + "item" + +data class ItemPage( + val id: Long, + val upvoted: Boolean, + val upvoteUrl: String +) + +enum class LoginResponse { + Success, + Failed +} + +class HackerNewsWebClient( + private val httpClient: OkHttpClient, +) { + suspend fun login(username: String, password: String): LoginResponse { + return withContext(Dispatchers.IO) { + val response = httpClient.newCall( + Request.Builder() + .url(LOGIN_URL) + .post( + FormBody.Builder() + .add("acct", username) + .add("pw", password) + .build() + ) + .build() + ).execute() + + val document = Jsoup.parse(response.body?.string()!!) + + val body = document.body() + val firstElement = body.firstChild() + val loginFailed = firstElement?.toString()?.contains("Bad login") ?: false + + if (loginFailed) { + LoginResponse.Failed + } else { + LoginResponse.Success + } + } + } + suspend fun getItemPage(itemId: Long): ItemPage { + return withContext(Dispatchers.IO) { + // request page + val response = httpClient.newCall( + Request + .Builder() + .url("$ITEM_URL?id=$itemId") + .build() + ).execute() + + val document = Jsoup.parse(response.body?.string()!!) + val upvoteElement = document.select("#up_$itemId") + val upvoteHref = upvoteElement.attr("href") + + ItemPage( + id = itemId, + upvoted = upvoteElement.hasClass("nosee"), + upvoteUrl = BASE_WEB_URL + upvoteHref + ) + } + } + + suspend fun upvoteItem(url: String): Boolean { + return withContext(Dispatchers.IO) { + val response = httpClient.newCall( + Request.Builder() + .url(url) + .build() + ).execute() + + response.isSuccessful + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/emergetools/hackernews/data/LocalCookieJar.kt b/android/app/src/main/java/com/emergetools/hackernews/data/LocalCookieJar.kt new file mode 100644 index 00000000..d29cac2b --- /dev/null +++ b/android/app/src/main/java/com/emergetools/hackernews/data/LocalCookieJar.kt @@ -0,0 +1,33 @@ +package com.emergetools.hackernews.data + +import android.util.Log +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +class LocalCookieJar(private val userStorage: UserStorage): CookieJar { + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + Log.d("Cookie Jar", "Url: $url, cookie = ${cookies[0]}") + cookies.firstOrNull { it.name == "user" }?.let { authCookie -> + runBlocking { userStorage.saveCookie(authCookie.value) } + } + } + + override fun loadForRequest(url: HttpUrl): List { + val authCookie = runBlocking { userStorage.getCookie().first() } + Log.d("Cookie Jar", "Cookie: user=$authCookie" ) + return if (authCookie != null) { + val cookie = Cookie.Builder() + .name("user") + .value(authCookie) + .domain("news.ycombinator.com") + .build() + listOf(cookie) + } else { + emptyList() + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/emergetools/hackernews/data/UserStorage.kt b/android/app/src/main/java/com/emergetools/hackernews/data/UserStorage.kt new file mode 100644 index 00000000..0d2e03b2 --- /dev/null +++ b/android/app/src/main/java/com/emergetools/hackernews/data/UserStorage.kt @@ -0,0 +1,22 @@ +package com.emergetools.hackernews.data + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.emergetools.hackernews.dataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class UserStorage(private val appContext: Context) { + private val cookieKey = stringPreferencesKey("Cookie") + + suspend fun saveCookie(cookie: String) { + appContext.dataStore.edit { store -> + store[cookieKey] = cookie + } + } + + fun getCookie(): Flow { + return appContext.dataStore.data.map { it[cookieKey] } + } +} \ No newline at end of file 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 index d0ed6a82..5090c91c 100644 --- 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 @@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.emergetools.hackernews.data.HackerNewsSearchClient +import com.emergetools.hackernews.data.HackerNewsWebClient +import com.emergetools.hackernews.data.ItemPage import com.emergetools.hackernews.data.ItemResponse import com.emergetools.hackernews.data.relativeTimeStamp import kotlinx.coroutines.Dispatchers @@ -28,13 +30,21 @@ sealed interface CommentsState { } data class Content( + val id: Long, val title: String, val author: String, val points: Int, val text: String?, + val page: ItemPage, override val comments: List, ): CommentsState { - override val headerState = HeaderState.Content(title, author, points, text) + override val headerState = HeaderState.Content( + title, + author, + points, + page.upvoted, + text, + ) } } @@ -62,14 +72,19 @@ sealed interface HeaderState { val title: String, val author: String, val points: Int, - val body: String? + val upvoted: Boolean, + val body: String?, ): HeaderState } +sealed interface CommentsAction { + data object LikePostTapped: CommentsAction +} class CommentsViewModel( private val itemId: Long, - private val searchClient: HackerNewsSearchClient + private val searchClient: HackerNewsSearchClient, + private val webClient: HackerNewsWebClient ) : ViewModel() { private val internalState = MutableStateFlow(CommentsState.Loading) val state = internalState.asStateFlow() @@ -78,17 +93,46 @@ class CommentsViewModel( viewModelScope.launch { withContext(Dispatchers.IO) { val response = searchClient.api.getItem(itemId) + val page = webClient.getItemPage(itemId) + Log.d("CommentsViewModel", "Item Page: $page") val comments = response.children.map { rootComment -> rootComment.createCommentState(0) } internalState.update { CommentsState.Content( + id = itemId, title = response.title ?: "", author = response.author ?: "", points = response.points ?: 0, text = response.text, - comments = comments + page = page, + comments = comments, + ) + } + } + } + } + + fun actions(action: CommentsAction) { + when (action) { + CommentsAction.LikePostTapped -> { + Log.d("CommentsViewModel", "Post Liked: $itemId") + val current = internalState.value + if (current is CommentsState.Content && !current.page.upvoted && current.page.upvoteUrl.isNotEmpty()) { + // eager ui update + internalState.value = current.copy( + points = current.points + 1, + page = current.page.copy( + upvoted = true + ) ) + viewModelScope.launch { + val success = webClient.upvoteItem(current.page.upvoteUrl) + if (success) { + val refreshedPage = webClient.getItemPage(itemId) + Log.d("CommentsViewModel", "Refreshed Item Page: $refreshedPage") + } + } } } } @@ -115,10 +159,11 @@ class CommentsViewModel( @Suppress("UNCHECKED_CAST") class Factory( private val itemId: Long, - private val searchClient: HackerNewsSearchClient + private val searchClient: HackerNewsSearchClient, + private val webClient: HackerNewsWebClient, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return CommentsViewModel(itemId, searchClient) as T + return CommentsViewModel(itemId, searchClient, webClient) 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 index 1079b213..c6de0bac 100644 --- 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 @@ -8,6 +8,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.toRoute import com.emergetools.hackernews.searchClient +import com.emergetools.hackernews.webClient import kotlinx.serialization.Serializable sealed interface CommentsDestinations { @@ -22,11 +23,15 @@ fun NavGraphBuilder.commentsRoutes() { val model = viewModel( factory = CommentsViewModel.Factory( itemId = comments.storyId, - searchClient = context.searchClient() + searchClient = context.searchClient(), + webClient = context.webClient() ) ) val state by model.state.collectAsState() - CommentsScreen(state) + CommentsScreen( + state = state, + actions = model::actions + ) } } 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 index 039f6add..11028ae6 100644 --- 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 @@ -1,6 +1,7 @@ package com.emergetools.hackernews.features.comments 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 @@ -14,11 +15,14 @@ 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.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material.icons.rounded.ThumbUp import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -35,11 +39,16 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import com.emergetools.hackernews.data.ItemPage +import com.emergetools.hackernews.ui.theme.HackerGreen import com.emergetools.hackernews.ui.theme.HackerNewsTheme import com.emergetools.hackernews.ui.theme.HackerOrange @Composable -fun CommentsScreen(state: CommentsState) { +fun CommentsScreen( + state: CommentsState, + actions: (CommentsAction) -> Unit +) { LazyColumn( modifier = Modifier .fillMaxSize() @@ -51,7 +60,10 @@ fun CommentsScreen(state: CommentsState) { state = state.headerState, modifier = Modifier .fillMaxWidth() - .wrapContentHeight() + .wrapContentHeight(), + onLikeTapped = { + actions(CommentsAction.LikePostTapped) + } ) } item { @@ -88,10 +100,16 @@ private fun CommentsScreenPreview() { HackerNewsTheme { CommentsScreen( state = CommentsState.Content( + id = 0, title = "Show HN: A new HN client for Android", author = "rikinm", points = 69, text = null, + page = ItemPage( + id = 0, + upvoted = false, + upvoteUrl = "upvote.com" + ), comments = listOf( CommentState.Content( id = 1, @@ -111,7 +129,8 @@ private fun CommentsScreenPreview() { ) ) ) - ) + ), + actions = {} ) } } @@ -121,7 +140,8 @@ private fun CommentsScreenPreview() { private fun CommentsScreenLoadingPreview() { HackerNewsTheme { CommentsScreen( - state = CommentsState.Loading + state = CommentsState.Loading, + actions = {} ) } } @@ -275,7 +295,8 @@ fun CommentRowLoadingPreview() { @Composable fun ItemHeader( state: HeaderState, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onLikeTapped: () -> Unit, ) { Column( modifier = modifier @@ -302,11 +323,42 @@ fun ItemHeader( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "${state.points}", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelSmall - ) + Row( + modifier = Modifier + .wrapContentSize() + .clip(CircleShape) + .background( + color = if (state.upvoted) { + HackerGreen.copy(alpha = 0.2f) + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + } + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + .clickable { onLikeTapped() }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + modifier = Modifier.size(12.dp), + imageVector = Icons.Rounded.ThumbUp, + tint = if (state.upvoted) { + HackerGreen + } else { + MaterialTheme.colorScheme.onSurface + }, + contentDescription = "Upvote" + ) + Text( + text = "${state.points}", + color = if (state.upvoted) { + HackerGreen + } else { + MaterialTheme.colorScheme.onSurface + }, + style = MaterialTheme.typography.labelSmall + ) + } Text( text = "•", color = MaterialTheme.colorScheme.onSurface, @@ -392,11 +444,13 @@ private fun ItemHeaderPreview() { title = "Show HN: A super neat HN client for Android", author = "rikinm", points = 69, + upvoted = false, body = "Hi there" ), modifier = Modifier .fillMaxWidth() - .wrapContentHeight() + .wrapContentHeight(), + onLikeTapped = {} ) } } @@ -409,7 +463,8 @@ private fun ItemHeaderLoadingPreview() { state = HeaderState.Loading, modifier = Modifier .fillMaxWidth() - .wrapContentHeight() + .wrapContentHeight(), + onLikeTapped = {} ) } } diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/login/LoginDomain.kt b/android/app/src/main/java/com/emergetools/hackernews/features/login/LoginDomain.kt new file mode 100644 index 00000000..8e52f718 --- /dev/null +++ b/android/app/src/main/java/com/emergetools/hackernews/features/login/LoginDomain.kt @@ -0,0 +1,84 @@ +package com.emergetools.hackernews.features.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.emergetools.hackernews.data.HackerNewsWebClient +import com.emergetools.hackernews.data.LoginResponse +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +enum class LoginStatus { + Idle, + Success, + Failed +} +data class LoginState( + val username: String = "", + val password: String = "", + val status: LoginStatus = LoginStatus.Idle +) + +sealed interface LoginAction { + data class UsernameUpdated(val input: String): LoginAction + data class PasswordUpdated(val input: String): LoginAction + data object LoginSubmit: LoginAction +} + +sealed interface LoginNavigation { + data object Dismiss: LoginNavigation +} + +class LoginViewModel(private val webClient: HackerNewsWebClient): ViewModel() { + private val internalState = MutableStateFlow(LoginState()) + val state = internalState.asStateFlow() + + fun actions(action: LoginAction) { + when(action) { + LoginAction.LoginSubmit -> { + viewModelScope.launch { + val response = webClient.login( + username = internalState.value.username, + password = internalState.value.password + ) + + internalState.update { current -> + current.copy( + status = when (response) { + LoginResponse.Success -> { + LoginStatus.Success + } + LoginResponse.Failed -> { + LoginStatus.Failed + } + } + ) + } + } + } + is LoginAction.PasswordUpdated -> { + internalState.update { current -> + current.copy( + password = action.input + ) + } + } + is LoginAction.UsernameUpdated -> { + internalState.update { current -> + current.copy( + username = action.input + ) + } + } + } + } + + @Suppress("UNCHECKED_CAST") + class Factory(private val webClient: HackerNewsWebClient): ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return LoginViewModel(webClient) as T + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/login/LoginRouting.kt b/android/app/src/main/java/com/emergetools/hackernews/features/login/LoginRouting.kt new file mode 100644 index 00000000..822246d2 --- /dev/null +++ b/android/app/src/main/java/com/emergetools/hackernews/features/login/LoginRouting.kt @@ -0,0 +1,39 @@ +package com.emergetools.hackernews.features.login + +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.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.emergetools.hackernews.webClient +import kotlinx.serialization.Serializable + +sealed interface LoginDestinations { + @Serializable + data object Login : LoginDestinations +} + +fun NavGraphBuilder.loginRoutes(navController: NavController) { + composable { + val context = LocalContext.current + val model = viewModel( + factory = LoginViewModel.Factory( + webClient = context.webClient() + ) + ) + val state by model.state.collectAsState() + LoginScreen( + state = state, + actions = model::actions, + navigation = { place -> + when (place) { + is LoginNavigation.Dismiss -> { + navController.popBackStack() + } + } + } + ) + } +} diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/login/LoginScreen.kt b/android/app/src/main/java/com/emergetools/hackernews/features/login/LoginScreen.kt new file mode 100644 index 00000000..f2573981 --- /dev/null +++ b/android/app/src/main/java/com/emergetools/hackernews/features/login/LoginScreen.kt @@ -0,0 +1,93 @@ +package com.emergetools.hackernews.features.login + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.emergetools.hackernews.ui.theme.HackerNewsTheme +import com.emergetools.hackernews.ui.theme.HackerRed + +@Composable +fun LoginScreen( + state: LoginState, + actions: (LoginAction) -> Unit, + navigation: (LoginNavigation) -> Unit +) { + + LaunchedEffect(state.status) { + if (state.status == LoginStatus.Success) { + navigation(LoginNavigation.Dismiss) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.background), + verticalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Login", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + TextField( + value = state.username, + placeholder = { Text("Username") }, + trailingIcon = { + if (state.status == LoginStatus.Failed) { + Icon( + imageVector = Icons.Rounded.Warning, + tint = HackerRed, + contentDescription = "Failed" + ) + } + }, + onValueChange = { actions(LoginAction.UsernameUpdated(it)) } + ) + TextField( + value = state.password, + placeholder = { Text("Password") }, + trailingIcon = { + if (state.status == LoginStatus.Failed) { + Icon( + imageVector = Icons.Rounded.Warning, + tint = HackerRed, + contentDescription = "Failed" + ) + } + }, + onValueChange = { actions(LoginAction.PasswordUpdated(it)) } + ) + Button(onClick = { actions(LoginAction.LoginSubmit) }) { + Text(text = "Submit", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + } + } +} + +@PreviewLightDark +@Composable +private fun LoginScreenPreview() { + HackerNewsTheme { + LoginScreen( + state = LoginState(), + actions = {}, + navigation = {} + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsDomain.kt b/android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsDomain.kt new file mode 100644 index 00000000..592eb440 --- /dev/null +++ b/android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsDomain.kt @@ -0,0 +1,61 @@ +package com.emergetools.hackernews.features.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.emergetools.hackernews.data.UserStorage +import com.emergetools.hackernews.features.login.LoginDestinations +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update + +data class SettingsState( + val loggedIn: Boolean +) + +sealed interface SettingsAction { + data object LoginPressed : SettingsAction +} + +sealed interface SettingsNavigation { + data object GoToLogin : SettingsNavigation { + val login = LoginDestinations.Login + } +} + +class SettingsViewModel(userStorage: UserStorage) : ViewModel() { + private val internalState = MutableStateFlow(SettingsState(false)) + + val state = combine( + userStorage.getCookie(), + internalState.asStateFlow() + ) { cookie, state -> + if (!cookie.isNullOrEmpty()) { + state.copy(loggedIn = true) + } else { + state + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = SettingsState(false) + ) + + fun actions(action: SettingsAction) { + when (action) { + SettingsAction.LoginPressed -> { + // TODO + } + } + } + + @Suppress("UNCHECKED_CAST") + class Factory(private val userStorage: UserStorage): ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return SettingsViewModel(userStorage) as T + } + } +} diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsRouting.kt b/android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsRouting.kt index da8804d8..38e42cf1 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsRouting.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsRouting.kt @@ -1,16 +1,39 @@ package com.emergetools.hackernews.features.settings +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.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable +import com.emergetools.hackernews.userStorage import kotlinx.serialization.Serializable sealed interface SettingsDestinations { @Serializable - data object Settings: SettingsDestinations + data object Settings : SettingsDestinations } -fun NavGraphBuilder.settingsRoutes() { +fun NavGraphBuilder.settingsRoutes(navController: NavController) { composable { - SettingsScreen() + val context = LocalContext.current + val model = viewModel( + factory = SettingsViewModel.Factory( + userStorage = context.userStorage() + ) + ) + val state by model.state.collectAsState() + SettingsScreen( + state = state, + actions = model::actions, + navigation = { place -> + when (place) { + is SettingsNavigation.GoToLogin -> { + navController.navigate(place.login) + } + } + } + ) } } \ No newline at end of file diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsScreen.kt b/android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsScreen.kt index 0be3d197..46c4733c 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsScreen.kt @@ -1,30 +1,98 @@ package com.emergetools.hackernews.features.settings 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button 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.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.emergetools.hackernews.ui.theme.HackerGreen import com.emergetools.hackernews.ui.theme.HackerNewsTheme @Composable -fun SettingsScreen() { +fun SettingsScreen( + state: SettingsState, + actions: (SettingsAction) -> Unit, + navigation: (SettingsNavigation) -> Unit, +) { Column( modifier = Modifier .fillMaxSize() .background(color = MaterialTheme.colorScheme.background) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( text = "Settings", - modifier = Modifier.fillMaxWidth().padding(8.dp), + modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.titleMedium ) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background( + color = MaterialTheme.colorScheme.surfaceContainer + ) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background( + color = if (state.loggedIn) { + HackerGreen + } else { + MaterialTheme.colorScheme.surfaceDim + } + ), + contentAlignment = Alignment.Center + ) { + Text( + text = if (!state.loggedIn) { + "🤔" + } else { + "😎" + }, + fontSize = 24.sp + ) + } + + Button( + onClick = { + navigation(SettingsNavigation.GoToLogin) + } + ) { + Text( + text = if (!state.loggedIn) { + "Login" + } else { + "Logout" + }, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium + ) + } + } } } @@ -32,6 +100,10 @@ fun SettingsScreen() { @Composable private fun SettingsScreenPreview() { HackerNewsTheme { - SettingsScreen() + SettingsScreen( + state = SettingsState(false), + actions = {}, + navigation = {} + ) } } \ No newline at end of file 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 index d279719d..b7feb188 100644 --- 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 @@ -1,30 +1,8 @@ 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 com.google.accompanist.web.WebView -import com.google.accompanist.web.rememberWebViewState @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 - ) - } + // TODO: See if we want to implement our own web experience } \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a94a0328..c5feafa2 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -19,21 +19,20 @@ emergePlugin = "3.1.1" emergeSnapshots = "1.1.2" composeCompilerExtension = "1.5.3" material3 = "1.3.0-beta04" +datastore = "1.1.1" room = "2.6.1" +jsoup = "1.17.2" composeBom = "2024.06.00" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "viewmodel" } androidx-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation"} androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } - +androidx-datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-room = { group = "androidx.room", name = "room-runtime", version.ref = "room"} androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } @@ -50,11 +49,14 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } - -accompanist-webview = { group = "com.google.accompanist", name = "accompanist-webview", version.ref = "accompanist" } +jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup"} emerge-snapshots = { group = "com.emergetools.snapshots", name = "snapshots", version.ref = "emergeSnapshots" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }