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" }