diff --git a/.gitignore b/.gitignore index aa724b7..86f1fbc 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ .externalNativeBuild .cxx local.properties +/app/src/main/java/com/example/listifyjetapp/utils/constants/Constants.kt \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..f0c6ad0 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,50 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 74dd639..b2c751a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1689416..f0bebce 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,6 +5,9 @@ plugins { alias(libs.plugins.hilt) alias(libs.plugins.ksp) + + // Kotlin serialization plugin for type safe routes and navigation arguments + kotlin("plugin.serialization") version "2.0.21" } android { @@ -105,4 +108,26 @@ dependencies { // google font implementation(libs.androidx.ui.text.google.fonts) + // material icons + implementation (libs.androidx.material.icons.extended) + + // ================= Kotlin serialization ======================= + // Jetpack Compose integration + implementation(libs.androidx.navigation.compose) + + // Views/Fragments integration + implementation(libs.androidx.navigation.fragment) + implementation(libs.androidx.navigation.ui) + + // Feature module support for Fragments + implementation(libs.androidx.navigation.dynamic.features.fragment) + + // Testing Navigation + androidTestImplementation(libs.androidx.navigation.testing) + + // JSON serialization library, works with the Kotlin serialization plugin + implementation(libs.kotlinx.serialization.json) + + // Typed DataStore (Typed API surface, such as Proto) + implementation(libs.androidx.datastore.preferences) } \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/components/authButtons/AuthButtons.kt b/app/src/main/java/com/example/listifyjetapp/components/authButtons/AuthButtons.kt new file mode 100644 index 0000000..01c8b0f --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/components/authButtons/AuthButtons.kt @@ -0,0 +1,100 @@ +package com.example.listifyjetapp.components.authButtons + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +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.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.filled.MailOutline +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.example.listifyjetapp.ui.navigation.ListifyScreens +import com.example.listifyjetapp.ui.theme.ListifyColor +import com.example.listifyjetapp.widgets.FilledButton + +@Composable +fun AuthButtons(navController: NavController) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedButton( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + shape = RoundedCornerShape(10.dp), + border = BorderStroke(2.dp, color = Color.White), + + onClick = { } + ) { + Icon( + imageVector = Icons.Default.MailOutline, + modifier = Modifier.size(20.dp), + contentDescription = "Sign In Email", + ) + Text( + text = "SIGN UP WITH EMAIL", + modifier = Modifier.padding(8.dp), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + + ) + } + + FilledButton( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + shape = RoundedCornerShape(10.dp), + containerColor = Color.White, + contentColor = ListifyColor.TextDark, + text = "LOG IN", + buttonIcon = Icons.AutoMirrored.Filled.ExitToApp, + iconDescription = "Log In", + onClick = { navController.navigate(ListifyScreens.LoginScreen) } + ) +// Button( +// modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), +// shape = RoundedCornerShape(10.dp), +// colors = ButtonDefaults.buttonColors( +// containerColor = Color.White, +// contentColor = ListifyColor.TextDark +// ), +// onClick = { +// navController.navigate(ListifyScreens.LoginScreen.route) +// } +// ) { +// Icon( +// imageVector = Icons.AutoMirrored.Filled.ExitToApp, +// modifier = Modifier.size(20.dp), +// contentDescription = "Log In" +// ) +// +// Text( +// text = "LOG IN", +// fontWeight = FontWeight.ExtraBold, +// fontSize = 18.sp, +// modifier = Modifier.padding(8.dp) +// ) +// } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/components/inputFields/PasswordTextField.kt b/app/src/main/java/com/example/listifyjetapp/components/inputFields/PasswordTextField.kt new file mode 100644 index 0000000..ec5a4ac --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/components/inputFields/PasswordTextField.kt @@ -0,0 +1,70 @@ +package com.example.listifyjetapp.components.inputFields + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +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.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + +@Composable +fun PasswordTextField( + password: String, + onPasswordChange: (String) -> Unit +) { + var isShowPassword by remember { mutableStateOf(false) } + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + value = password, + onValueChange = onPasswordChange, + label = { Text("Password") }, + visualTransformation = if (isShowPassword) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { + //TODO: Login request - submit login form + }), + trailingIcon = { + if (isShowPassword) { + IconButton(onClick = { isShowPassword = false }) { + Icon( + imageVector = Icons.Filled.Visibility, + contentDescription = "show password" + ) + } + } else { + IconButton(onClick = { isShowPassword = true }) { + Icon( + imageVector = Icons.Filled.VisibilityOff, + contentDescription = "hide password" + ) + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/components/inputFields/ValidatingInputTextField.kt b/app/src/main/java/com/example/listifyjetapp/components/inputFields/ValidatingInputTextField.kt new file mode 100644 index 0000000..26cfa9f --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/components/inputFields/ValidatingInputTextField.kt @@ -0,0 +1,37 @@ +package com.example.listifyjetapp.components.inputFields + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp + +@Composable +fun ValidatingInputTextField( + email: String, + onValueChange: (String) -> Unit, + validatorHasError: Boolean +) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .padding(start = 16.dp, end=16.dp, top = 16.dp), + value = email, + onValueChange = onValueChange, + label = { Text("Email") }, + isError = validatorHasError, + supportingText = { + if (validatorHasError) { + Text("Incorrect email format.") + } + }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, // ** Go to next ** + keyboardType = KeyboardType.Email + ), + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/data/ListifyStorage.kt b/app/src/main/java/com/example/listifyjetapp/data/ListifyStorage.kt new file mode 100644 index 0000000..bac3dbe --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/data/ListifyStorage.kt @@ -0,0 +1,77 @@ +package com.example.listifyjetapp.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.example.listifyjetapp.model.UserDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +// Create a Preferences DataStore +val USER_DATASTORE = "user_data" +val Context.dataStore: DataStore by preferencesDataStore(name = USER_DATASTORE) + +class ListifyStorageManager(val context: Context) { + + private companion object { + val USER_ID = intPreferencesKey("userId") + val EMAIL = stringPreferencesKey("email") + val ACCESS_TOKEN = stringPreferencesKey("accessToken") + val REFRESH_TOKEN = stringPreferencesKey("refreshToken") + val IS_LOGIN = booleanPreferencesKey("isLogin") + } + + // write a preference dataStorage - after login + suspend fun saveToDataStore(userDataStore: UserDataStore) { + context.dataStore.edit { + it[USER_ID] = userDataStore.userId + it[EMAIL] = userDataStore.email + it[ACCESS_TOKEN] = userDataStore.accessToken + it[REFRESH_TOKEN] = userDataStore.refreshToken + it[IS_LOGIN] = userDataStore.isLogin + } + } + + // retrieve data from dataStore + fun getUser(): Flow = context.dataStore.data.map { + UserDataStore( + userId= it[USER_ID] ?: 0, + email = it[EMAIL] ?: "", + accessToken =it[ACCESS_TOKEN] ?: "", + refreshToken =it[REFRESH_TOKEN] ?: "", + isLogin = it[IS_LOGIN] ?: false, + ) + } + + val userIdFlow: Flow = context.dataStore.data.map { it[USER_ID] ?: 0 } + val emailFlow: Flow = context.dataStore.data.map { it[EMAIL] ?: "" } + val isLoggedInFlow: Flow = context.dataStore.data.map { it[IS_LOGIN] ?: false } + + val accessTokenFlow: Flow = context.dataStore.data.map { it[ACCESS_TOKEN] ?: "" } + val refreshTokenFlow: Flow = context.dataStore.data.map { it[REFRESH_TOKEN] ?: "" } + + suspend fun updateIsLoggedIn(isLoggedIn: Boolean) { + context.dataStore.edit { it[IS_LOGIN] = isLoggedIn } + } + + suspend fun updateEmail(email: String) { + context.dataStore.edit { it[EMAIL] = email } + } + + suspend fun updateTokens(accessToken: String, refreshToken: String) { + context.dataStore.edit { + it[ACCESS_TOKEN] = accessToken + it[REFRESH_TOKEN] = refreshToken + } + } + + // clear data from dataStore - logout + suspend fun clearDataStore() = context.dataStore.edit { + it.clear() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/data/LoginState.kt b/app/src/main/java/com/example/listifyjetapp/data/LoginState.kt new file mode 100644 index 0000000..5ff6e08 --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/data/LoginState.kt @@ -0,0 +1,10 @@ +package com.example.listifyjetapp.data + +import com.example.listifyjetapp.model.LoginSuccess + +sealed class LoginState { + object Idle : LoginState() + object Loading : LoginState() + data class Success(val data: LoginSuccess) : LoginState() + data class Error(val message: String) : LoginState() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/di/AppModule.kt b/app/src/main/java/com/example/listifyjetapp/di/AppModule.kt index 569c61b..54d643f 100644 --- a/app/src/main/java/com/example/listifyjetapp/di/AppModule.kt +++ b/app/src/main/java/com/example/listifyjetapp/di/AppModule.kt @@ -1,11 +1,20 @@ package com.example.listifyjetapp.di +import android.content.Context +import com.example.listifyjetapp.data.ListifyStorageManager import com.example.listifyjetapp.network.ListifyAPI import com.example.listifyjetapp.utils.constants.Constants +import dagger.Lazy import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Interceptor +import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import javax.inject.Singleton @@ -14,14 +23,92 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) class AppModule { - // TODO: create a provider to build a Retrofit instance + // TODO: OkHttp with: + // - Interceptor: adds Authorization: Bearer + // - Authenticator: on 401 -> POST /auth/refresh with x-Refresh-Token -> save new access -> retry @Provides @Singleton - fun provideListifyAPI():ListifyAPI { + fun provideOkHttpClient( + storageManager: ListifyStorageManager, + apiLazy: Lazy + ): OkHttpClient { + val authInterceptor = Interceptor {chain -> + // Interceptor is not suspend; grab the latest value synchronously: + val accessToken = runBlocking { storageManager.accessTokenFlow.first() } + val request = chain.request().newBuilder().apply { + if (accessToken.isNotBlank()) { + addHeader("Authorization", "Bearer $accessToken") + } + }.build() + chain.proceed(request) + } + + val authenticator = Authenticator { route, response -> + // prevent infinite loops + if (response.request.header("Authenticator") == null) return@Authenticator null + // Do not try to refresh while calling refresh + if (response.request.url.encodedPath.endsWith("/refresh")) return@Authenticator null + + try { + val refreshToken = runBlocking { storageManager.refreshTokenFlow.first() } + if (refreshToken.isBlank()) return@Authenticator null + + //call refresh synchronously + val login = runBlocking { apiLazy.get().refresh(refreshToken) } + + // save new token + runBlocking { storageManager.updateTokens(login.accessToken, login.refreshToken) } + + // rebuild the original request with new access token + val newAccessToken = login.accessToken + return@Authenticator response.request.newBuilder() + .header("Authorization", "Bearer $newAccessToken") + .build() + + } catch (_: Exception) { + // refresh failed -> no retry + null + } + } + + return OkHttpClient + .Builder() + .addInterceptor(authInterceptor) + .authenticator(authenticator) + .build() + } + + + // TODO: Retrofit with OkHttpClient + @Provides + @Singleton + fun provideRetrofit(client: OkHttpClient): Retrofit { return Retrofit.Builder() .baseUrl(Constants.BASE_URL) + .client(client) .addConverterFactory(GsonConverterFactory.create()) .build() - .create(ListifyAPI::class.java) } + + // TODO: dataStore to read / write tokens + @Provides + @Singleton + fun provideDataStorageAPI(@ApplicationContext context: Context + ): ListifyStorageManager = ListifyStorageManager(context) + + @Provides + @Singleton + fun provideListifyAPI(retrofit: Retrofit): ListifyAPI = + retrofit.create(ListifyAPI::class.java) + +// // TODO: create a provider to build a Retrofit instance +// @Provides +// @Singleton +// fun provideListifyAPI():ListifyAPI { +// return Retrofit.Builder() +// .baseUrl(Constants.BASE_URL) +// .addConverterFactory(GsonConverterFactory.create()) +// .build() +// .create(ListifyAPI::class.java) +// } } \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/model/AuthUser.kt b/app/src/main/java/com/example/listifyjetapp/model/AuthUser.kt new file mode 100644 index 0000000..95c39c0 --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/model/AuthUser.kt @@ -0,0 +1,31 @@ +package com.example.listifyjetapp.model + +import com.google.gson.annotations.SerializedName + +data class UserToken( + val id: Int, + @SerializedName("user_id") val userId: Int, + val token: String, + @SerializedName("expires_at") val expiresAt: String, + @SerializedName("created_at") val createdAt: String +) + +data class UserDataStore( + val userId: Int, + val email: String, + val accessToken: String, + val refreshToken: String, + val isLogin: Boolean = false, +) + +data class LoginInfo( + val email: String, + val password: String, +) + +data class LoginSuccess( + val message: String, + val accessToken: String, + val refreshToken: String, + val user: UserWithoutPassword +) \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/model/ListModel.kt b/app/src/main/java/com/example/listifyjetapp/model/ListModel.kt index 09016c6..d539814 100644 --- a/app/src/main/java/com/example/listifyjetapp/model/ListModel.kt +++ b/app/src/main/java/com/example/listifyjetapp/model/ListModel.kt @@ -1,11 +1,27 @@ package com.example.listifyjetapp.model -import java.time.LocalDateTime +import com.google.gson.annotations.SerializedName + +data class BaseList( + val id: Int, + val name: String, + val share: Boolean, + //val sharedCode: String, // can be UUID or String + //val createdAt: LocalDateTime + @SerializedName("shared_code") val sharedCode: String, + @SerializedName("created_at") val createdAt: String, +) + +data class ListName ( + val name: String +) data class ListModel( val id: Int, val name: String, val share: Boolean, - val shareCode: String, // can be UUID or String - val createdAt: LocalDateTime + @SerializedName("shared_code") val sharedCode: String, + @SerializedName("created_at") val createdAt: String, + @SerializedName("item_count") val itemCount: Int, + @SerializedName("shared_with") val sharedWith: List ) diff --git a/app/src/main/java/com/example/listifyjetapp/model/User.kt b/app/src/main/java/com/example/listifyjetapp/model/User.kt new file mode 100644 index 0000000..369769e --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/model/User.kt @@ -0,0 +1,24 @@ +package com.example.listifyjetapp.model + +import com.google.gson.annotations.SerializedName + +data class User( + val id: Int, + val username: String, + val email: String, + val password: String, + @SerializedName("created_at") val createdAt: String +) + +data class UserWithoutPassword( + val id: Int, + val username: String, + val email: String, + @SerializedName("created_at") val createdAt: String +) + +data class SharedUsers( + @SerializedName("user_id") val userId: Int, + val username: String, +) + diff --git a/app/src/main/java/com/example/listifyjetapp/network/ListsAPI.kt b/app/src/main/java/com/example/listifyjetapp/network/ListsAPI.kt index c408725..b8b18af 100644 --- a/app/src/main/java/com/example/listifyjetapp/network/ListsAPI.kt +++ b/app/src/main/java/com/example/listifyjetapp/network/ListsAPI.kt @@ -1,36 +1,50 @@ package com.example.listifyjetapp.network import com.example.listifyjetapp.model.ListModel +import com.example.listifyjetapp.model.ListName +import com.example.listifyjetapp.model.LoginInfo +import com.example.listifyjetapp.model.LoginSuccess +import com.example.listifyjetapp.model.User +import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path import javax.inject.Singleton @Singleton interface ListifyAPI { + // =============================================== Auth ======================================== + @POST("auth/login") + suspend fun login(@Body request: LoginInfo): LoginSuccess + + @POST("refresh") + suspend fun refresh(@Header("x-refresh-token") refreshToken: String): LoginSuccess + // =============================================== Users ======================================= -// @PATCH("users/{user_id}") -// suspend fun patchUserById(@Path("user_id") userId: Int): User -// -// @POST("users") -// suspend fun createUser(): User -// -// @GET("users/check_username/{username}") -// suspend fun getUserByUsername(@Path("/username") username: String ): User -// -// @GET("users/email/{email}") -// suspend fun getUserByUserEmail(@Path("/email") email: String ): User -// -// @GET("users/users") -// suspend fun getAllUsers(): List + @PATCH("users/{user_id}") + suspend fun patchUserById(@Path("user_id") userId: Int): User + + @POST("users") + suspend fun createUser(): User + + @GET("users/check_username/{username}") + suspend fun getUserByUsername(@Path("/username") username: String ): User + + @GET("users/email/{email}") + suspend fun getUserByUserEmail(@Path("/email") email: String ): User + + @GET("users/users") + suspend fun getAllUsers(): List // =============================================== User Lists ================================== -// @GET("ul/shared-user/{list_id}/{user_id}") -// suspend fun getSharedUsers( -// @Path("user_id") userId: Int, -// @Path("list_id") listId: Int -// ): List + @GET("ul/shared-user/{list_id}/{user_id}") + suspend fun getSharedUsers( + @Path("user_id") userId: Int, + @Path("list_id") listId: Int + ): List @GET("ul/{user_id}") suspend fun getListsByUser(@Path("user_id") userId: Int): List @@ -56,7 +70,10 @@ interface ListifyAPI { // Post new list @POST("lists/{user_id}") - suspend fun createList(@Path("user_id") userId: Int) {} + suspend fun insertList( + @Path("user_id") userId: Int, + @Body request: ListName + ): ListModel // Get items by list id @GET("lists/{list_id}") diff --git a/app/src/main/java/com/example/listifyjetapp/repository/AuthUserRepository.kt b/app/src/main/java/com/example/listifyjetapp/repository/AuthUserRepository.kt new file mode 100644 index 0000000..64741a7 --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/repository/AuthUserRepository.kt @@ -0,0 +1,18 @@ +package com.example.listifyjetapp.repository + +import com.example.listifyjetapp.data.ListifyResult +import com.example.listifyjetapp.model.LoginInfo +import com.example.listifyjetapp.model.LoginSuccess +import com.example.listifyjetapp.network.ListifyAPI +import javax.inject.Inject + +class AuthUserRepository @Inject constructor(private val api: ListifyAPI){ + suspend fun login(loginInfo: LoginInfo): ListifyResult { + try { + val response = api.login(loginInfo) + return ListifyResult.Success(data = response) + } catch (e: Exception) { + return ListifyResult.Failure(e.message ?: "Error logging in") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/repository/ListsRepository.kt b/app/src/main/java/com/example/listifyjetapp/repository/ListsRepository.kt index 0130db4..f54e1e2 100644 --- a/app/src/main/java/com/example/listifyjetapp/repository/ListsRepository.kt +++ b/app/src/main/java/com/example/listifyjetapp/repository/ListsRepository.kt @@ -2,6 +2,7 @@ package com.example.listifyjetapp.repository import com.example.listifyjetapp.data.ListifyResult import com.example.listifyjetapp.model.ListModel +import com.example.listifyjetapp.model.ListName import com.example.listifyjetapp.network.ListifyAPI import javax.inject.Inject @@ -15,4 +16,22 @@ class ListsRepository @Inject constructor(private val api: ListifyAPI) { return ListifyResult.Failure(e.message ?: "Error fetching lists") } } + + suspend fun insertListByUser(userId: Int, newListData: ListName): ListifyResult { + try { + val response = api.insertList(userId, newListData) + val formatedResponse = ListModel ( + id = response.id, + name = response.name, + share = response.share, + sharedCode = response.sharedCode, + createdAt = response.createdAt, + itemCount = 0, + sharedWith= emptyList() + ) + return ListifyResult.Success(data = formatedResponse) + } catch (e:Exception) { + return ListifyResult.Failure(e.message ?: "Error creating new list") + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/ui/navigation/ListifyNavigation.kt b/app/src/main/java/com/example/listifyjetapp/ui/navigation/ListifyNavigation.kt index 039c9fa..801d87d 100644 --- a/app/src/main/java/com/example/listifyjetapp/ui/navigation/ListifyNavigation.kt +++ b/app/src/main/java/com/example/listifyjetapp/ui/navigation/ListifyNavigation.kt @@ -4,7 +4,9 @@ import androidx.compose.runtime.Composable import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.example.listifyjetapp.ui.screens.auth.ListifyLoginScreen import com.example.listifyjetapp.ui.screens.lists.ListifyListsScreen +import com.example.listifyjetapp.ui.screens.newList.ListifyNewListScreen import com.example.listifyjetapp.ui.screens.splash.ListifySplashScreen @@ -16,36 +18,41 @@ fun ListifyNavigation() { // Build the navigation graph NavHost( navController = navController, - startDestination = ListifyScreens.SplashScreen.route + startDestination = ListifyScreens.SplashScreen ) { // TODO: Define a navigation route for SplashScreen - composable(ListifyScreens.SplashScreen.route) { + composable() { ListifySplashScreen(navController = navController) } // TODO: Define a navigation route for ListsScreen - composable(ListifyScreens.ListsScreen.route) { - ListifyListsScreen() + composable() { + ListifyListsScreen(navController = navController) + } + + // TODO: Define a navigation route for NewListScreen + composable() { + ListifyNewListScreen(navController = navController) } // TODO: Define a navigation route for DetailScreen - composable(ListifyScreens.DetailScreen.route) { + composable() { //ListifyDetailScreen(navController = navController) } // TODO: Define a navigation route for ProfileScreen - composable(ListifyScreens.ProfileScreen.route) { + composable() { //ListifyProfileScreen(navController = navController) } // TODO: Define a navigation route for LoginScreen - composable(ListifyScreens.LoginScreen.route) { - //ListifyLoginScreen(navController = navController) + composable() { + ListifyLoginScreen(navController = navController) } // TODO: Define a navigation route for SignupScreen - composable(ListifyScreens.SignupScreen.route) { + composable() { //ListifySignupScreen(navController = navController) } } diff --git a/app/src/main/java/com/example/listifyjetapp/ui/navigation/ListifyScreens.kt b/app/src/main/java/com/example/listifyjetapp/ui/navigation/ListifyScreens.kt index b1914cb..700dda2 100644 --- a/app/src/main/java/com/example/listifyjetapp/ui/navigation/ListifyScreens.kt +++ b/app/src/main/java/com/example/listifyjetapp/ui/navigation/ListifyScreens.kt @@ -1,12 +1,14 @@ package com.example.listifyjetapp.ui.navigation -enum class ListifyScreens(val route: String) { - SplashScreen("splash"), - ListsScreen("lists"), - DetailScreen("detail"), - ProfileScreen("profile"), - LoginScreen("login"), - SignupScreen("signup") -} +import kotlinx.serialization.Serializable -// navController.navigate(ListifyScreens.LoginScreen.route) \ No newline at end of file + +object ListifyScreens { + @Serializable object SplashScreen + @Serializable data class ListsScreen(val userId: Int) + @Serializable object DetailScreen + @Serializable object ProfileScreen + @Serializable object LoginScreen + @Serializable object SignupScreen + @Serializable object NewListScreen +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/ui/screens/auth/EmailViewModel.kt b/app/src/main/java/com/example/listifyjetapp/ui/screens/auth/EmailViewModel.kt new file mode 100644 index 0000000..f5c1fe7 --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/ui/screens/auth/EmailViewModel.kt @@ -0,0 +1,25 @@ +package com.example.listifyjetapp.ui.screens.auth + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel + +class EmailViewModel: ViewModel() { + var email by mutableStateOf("") + private set // private set ensures that the email can only be modified via updateEmail() + + val emailHasErrors by derivedStateOf { + if (email.isNotEmpty()) { + // Email is considered erroneous until it completely matches EMAIL_ADDRESS. + !android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches() + } else { + false + } + } + + fun updateEmail(input: String) { + email = input + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/ui/screens/auth/ListifyLoginScreen.kt b/app/src/main/java/com/example/listifyjetapp/ui/screens/auth/ListifyLoginScreen.kt new file mode 100644 index 0000000..9a982c3 --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/ui/screens/auth/ListifyLoginScreen.kt @@ -0,0 +1,105 @@ +package com.example.listifyjetapp.ui.screens.auth + +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import com.example.listifyjetapp.components.inputFields.PasswordTextField +import com.example.listifyjetapp.widgets.ListifyTopBar +import com.example.listifyjetapp.components.inputFields.ValidatingInputTextField +import com.example.listifyjetapp.data.LoginState +import com.example.listifyjetapp.ui.navigation.ListifyScreens +import com.example.listifyjetapp.ui.theme.ListifyColor +import com.example.listifyjetapp.widgets.FilledButton + +@Composable +fun ListifyLoginScreen( + navController: NavHostController, + loginViewModel: LoginViewModel = hiltViewModel() +) { + + val loginState = loginViewModel.loginState + + fun onLoginClick() { + if (loginViewModel.email.isNotBlank() && loginViewModel.password.isNotBlank()) { + loginViewModel.login() + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { ListifyTopBar( + title = "LOG IN", + isListsScreen = false, + onGoBackButtonClicked = {navController.popBackStack()}, + leftText = "CANCEL", + ) } + ) { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + + Column( + modifier = Modifier.fillMaxWidth() + ) { + ValidatingInputTextField( + email = loginViewModel.email, + onValueChange = { input -> loginViewModel.updateEmail(input) }, + validatorHasError = loginViewModel.emailHasErrors + ) + + PasswordTextField( + password = loginViewModel.password, + onPasswordChange = loginViewModel::updatePassword + ) + FilledButton( + modifier=Modifier.fillMaxWidth().padding(16.dp), + shape=RoundedCornerShape(3.dp), + containerColor=ListifyColor.SplashYellow, + contentColor = ListifyColor.TextDark, + text="LOG IN ", + buttonIcon=Icons.AutoMirrored.Filled.ExitToApp, + iconDescription="Log In", + onClick={ onLoginClick() } + ) + + // handle login state + when (loginState) { + is LoginState.Loading -> { CircularProgressIndicator() } + is LoginState.Success -> { + LaunchedEffect(Unit) { + navController.navigate( + ListifyScreens.ListsScreen(userId = loginState.data.user.id) + ) + } + } + is LoginState.Error -> { + Text( + text = loginState.message, + color = Color.Red, + modifier = Modifier.padding(16.dp) + ) + } + LoginState.Idle -> {} // Explicit idle state + } + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/ui/screens/auth/LoginViewModel.kt b/app/src/main/java/com/example/listifyjetapp/ui/screens/auth/LoginViewModel.kt new file mode 100644 index 0000000..53a0699 --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/ui/screens/auth/LoginViewModel.kt @@ -0,0 +1,71 @@ +package com.example.listifyjetapp.ui.screens.auth + +import android.util.Patterns +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.listifyjetapp.data.ListifyResult +import com.example.listifyjetapp.data.ListifyStorageManager +import com.example.listifyjetapp.data.LoginState +import com.example.listifyjetapp.model.LoginInfo +import com.example.listifyjetapp.model.UserDataStore +import com.example.listifyjetapp.repository.AuthUserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val repository: AuthUserRepository, + private val storageManager: ListifyStorageManager +): ViewModel() { + + var email by mutableStateOf("") + private set + var password by mutableStateOf("") + private set + + var loginState by mutableStateOf(LoginState.Idle) + + + val emailHasErrors by derivedStateOf { + if (email.isNotEmpty()) { + // Email is considered erroneous until it completely matches EMAIL_ADDRESS. + !Patterns.EMAIL_ADDRESS.matcher(email).matches() + } else { + false + } + } + + fun updateEmail(newEmail:String) { email = newEmail } + fun updatePassword(newPassword: String) { password = newPassword } + + fun login() + = viewModelScope.launch { + loginState = LoginState.Loading + val result = repository.login( LoginInfo(email = email.trim(), password = password) ) + + loginState = when (result) { + is ListifyResult.Success -> { + storageManager.saveToDataStore( + UserDataStore( + userId = result.data.user.id, + email = result.data.user.email, + accessToken = result.data.accessToken, + refreshToken = result.data.refreshToken, + isLogin = true, + ) + ) + // Save token securely + LoginState.Success(result.data) + } + + is ListifyResult.Failure -> { + LoginState.Error(result.errorMessage) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/ui/screens/lists/ListRow.kt b/app/src/main/java/com/example/listifyjetapp/ui/screens/lists/ListRow.kt new file mode 100644 index 0000000..9fd6113 --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/ui/screens/lists/ListRow.kt @@ -0,0 +1,102 @@ +package com.example.listifyjetapp.ui.screens.lists + +import androidx.compose.foundation.background +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.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.listifyjetapp.model.ListModel +import com.example.listifyjetapp.ui.theme.ListifyColor + +@Composable +fun ListRow( + list: ListModel, + //viewModel: User +) { + Row( + modifier = Modifier + .clickable { } + .padding(vertical = 16.dp) + .fillMaxWidth() + .background(Color.Transparent) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + // List info + Column( + modifier = Modifier.padding(horizontal = 8.dp), + + ) { + Text( + text = list.name, + color = ListifyColor.TextBlack, + fontSize = 20.sp, + //fontWeight = FontWeight.SemiBold + ) + + if (list.share) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.size(16.dp), + tint = ListifyColor.IconGreen, + imageVector = Icons.Default.Share, + contentDescription = "Shared with" + ) + Text( + text = list.sharedWith.take(3).joinToString(", ") { it.username }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = ListifyColor.TextGrey, + fontSize = 16.sp + ) + } + } + + Text( + text = list.createdAt, + color = ListifyColor.TextGrey, + fontSize = 16.sp + ) + } + // List setting menu + + Row() { + Text( + text = list.itemCount.toString(), + color = ListifyColor.TextGrey, + fontSize = 16.sp + ) + + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = "Forward icon" + ) + + } + + } + + } + HorizontalDivider() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/ui/screens/lists/ListifyListsScreen.kt b/app/src/main/java/com/example/listifyjetapp/ui/screens/lists/ListifyListsScreen.kt index 0bff383..5c5a2bb 100644 --- a/app/src/main/java/com/example/listifyjetapp/ui/screens/lists/ListifyListsScreen.kt +++ b/app/src/main/java/com/example/listifyjetapp/ui/screens/lists/ListifyListsScreen.kt @@ -1,41 +1,109 @@ package com.example.listifyjetapp.ui.screens.lists +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.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController import com.example.listifyjetapp.R +import com.example.listifyjetapp.ui.navigation.ListifyScreens +import com.example.listifyjetapp.utils.filterListItems +import com.example.listifyjetapp.widgets.ListifySearchBar +import com.example.listifyjetapp.widgets.ListifyTopBar @Composable fun ListifyListsScreen( - viewModel: ListsViewModel = hiltViewModel() + navController: NavController, + viewModel: ListsViewModel = hiltViewModel(), ) { - LaunchedEffect(Unit) { viewModel.getUserLists(4) } + LaunchedEffect(Unit) { viewModel.getUserLists() } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { ListifyTopBar( + title = "Lists", + isListsScreen = true, + rightIcon = Icons.Default.Add, + onRightButtonClick = { + navController.navigate(ListifyScreens.NewListScreen) + } + ) } + ) { innerPadding -> - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Surface( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) + modifier = Modifier.fillMaxSize().padding(innerPadding) ) { - if (viewModel.lists.isEmpty()) { - Text(text = stringResource(R.string.no_lists)) - } else { - LazyColumn { - items(viewModel.lists) { list -> - Text(text = list.name) + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally + ) { + + val searchTextState = remember { mutableStateOf("") } + val keyboardController = LocalSoftwareKeyboardController.current + + ListifySearchBar( + searchTextValue = searchTextState, + onValueChange = {searchTextState.value = it}, + keyboardAction = KeyboardActions{ + // Trigger search logic or hide keyboard + searchTextState.value.trim() // perform the search + keyboardController?.hide() // hide keyboard + } + ) + + viewModel.errorMessage?.let { msg -> + Text(msg, + Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.error + ) + } + + if (viewModel.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (viewModel.lists.isEmpty()) { + Text(text = stringResource(R.string.no_lists)) + } else { + LazyColumn(modifier = Modifier.padding( + vertical = 16.dp, + horizontal = 8.dp + )){ + // Filter lists by search input + val results = filterListItems(searchTextState.value, viewModel.lists) + + items(results) {list -> + ListRow(list) + } } } } + } } - } \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/ui/screens/lists/ListsViewModel.kt b/app/src/main/java/com/example/listifyjetapp/ui/screens/lists/ListsViewModel.kt index 894316b..44c84ce 100644 --- a/app/src/main/java/com/example/listifyjetapp/ui/screens/lists/ListsViewModel.kt +++ b/app/src/main/java/com/example/listifyjetapp/ui/screens/lists/ListsViewModel.kt @@ -1,29 +1,75 @@ package com.example.listifyjetapp.ui.screens.lists +import android.util.Log +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.listifyjetapp.data.ListifyResult +import com.example.listifyjetapp.data.ListifyStorageManager import com.example.listifyjetapp.model.ListModel +import com.example.listifyjetapp.model.ListName import com.example.listifyjetapp.repository.ListsRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class ListsViewModel @Inject constructor(private val repository: ListsRepository): ViewModel() { +class ListsViewModel @Inject constructor( + private val repository: ListsRepository, + private val storageManager: ListifyStorageManager +): ViewModel() { val lists = mutableStateListOf() + var isLoading by mutableStateOf(false) + var errorMessage by mutableStateOf(null) - fun getUserLists(userId: Int) { + private val _navigateBack = MutableStateFlow(false) + val navigateBack = _navigateBack.asStateFlow() + + fun getUserLists() { viewModelScope.launch { - val result = repository.getUserLists(userId = userId) - when(result) { - is ListifyResult.Success -> { - lists.clear() - lists.addAll(result.data) + val userId = storageManager.getUser().first().userId + isLoading = true + errorMessage = null + + try { + val result = repository.getUserLists(userId = userId) + when (result) { + is ListifyResult.Success -> { + lists.clear() + lists.addAll(result.data) + } + + is ListifyResult.Failure -> { + errorMessage = result.errorMessage + Log.d("Fail to fetch lists by user id", result.toString()) + } } - is ListifyResult.Failure -> Unit + } finally { + isLoading = false } } } + + fun insertListByUser(userId: Int, newListName: ListName) + = viewModelScope.launch { + isLoading = true + val result = repository.insertListByUser(userId, newListName) + when (result) { + is ListifyResult.Success -> { + _navigateBack.value = true + } + is ListifyResult.Failure -> Unit + } + isLoading = false + } + + fun navigationComplete() { + _navigateBack.value = false + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/ui/screens/newList/ListifyNewListScreen.kt b/app/src/main/java/com/example/listifyjetapp/ui/screens/newList/ListifyNewListScreen.kt new file mode 100644 index 0000000..d71b47b --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/ui/screens/newList/ListifyNewListScreen.kt @@ -0,0 +1,84 @@ +package com.example.listifyjetapp.ui.screens.newList + +import androidx.compose.foundation.layout.Arrangement +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.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.example.listifyjetapp.model.ListName +import com.example.listifyjetapp.ui.screens.lists.ListsViewModel +import com.example.listifyjetapp.widgets.FormInputField +import com.example.listifyjetapp.widgets.ListifyTopBar + +@Composable +fun ListifyNewListScreen( + navController: NavController, + viewModel: ListsViewModel = hiltViewModel() +) { + + val formTextState = remember { mutableStateOf("") } + + LaunchedEffect(viewModel.navigateBack) { + viewModel.navigateBack.collect { navigateBack -> + if (navigateBack) { + navController.popBackStack() + viewModel.navigationComplete() + } + } + } + + fun onSaveClick() { + val listName = ListName(name = formTextState.value) + viewModel.insertListByUser(4, listName) + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { ListifyTopBar( + title = "New List", + isListsScreen = false, + //goBackIcon = Icons.AutoMirrored.Filled.ArrowBack, + onGoBackButtonClicked = {navController.popBackStack()}, + leftText = "Cancel", + rightText = "Save", + onRightButtonClick = { + // TODO: save new list + onSaveClick() + } + ) } + ) { innerPadding -> + + Surface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(25.dp) + ) { + + Column() { + FormInputField( + textState=formTextState, + onValueChange={ formTextState.value = it } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/ui/screens/splash/ListifySplashScreen.kt b/app/src/main/java/com/example/listifyjetapp/ui/screens/splash/ListifySplashScreen.kt index 928bbf2..7d4f377 100644 --- a/app/src/main/java/com/example/listifyjetapp/ui/screens/splash/ListifySplashScreen.kt +++ b/app/src/main/java/com/example/listifyjetapp/ui/screens/splash/ListifySplashScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -17,17 +18,23 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import com.example.listifyjetapp.R +import com.example.listifyjetapp.components.authButtons.AuthButtons import com.example.listifyjetapp.ui.navigation.ListifyScreens import com.example.listifyjetapp.ui.theme.ListifyColor import com.example.listifyjetapp.ui.theme.barriecitoFont import kotlinx.coroutines.delay @Composable -fun ListifySplashScreen(navController: NavHostController) { +fun ListifySplashScreen( + navController: NavHostController, + splashViewModel: SplashViewModel = hiltViewModel() +) { // TODO: Create an Animated object that holds a Float value starting at 0f val scale = remember { Animatable(initialValue = 0f) } + val isShowButtons = remember { mutableStateOf(false) } LaunchedEffect(key1 = true, block = { // key1 = true ensure it runs once only scale.animateTo( @@ -40,8 +47,19 @@ fun ListifySplashScreen(navController: NavHostController) { // when the animation is over, delay 2s before going to next screen delay(2000L) - // TODO: Navigate to MainScreen - navController.navigate(ListifyScreens.ListsScreen.route) + + val isLoggedIn = splashViewModel.isLoggedIn() + if (isLoggedIn && splashViewModel.checkAccessToken()) { + // TODO: Navigate to MainScreen + navController.navigate(ListifyScreens.ListsScreen(splashViewModel.getUserId())) { + // Remove Splash from the back stack when go to Lists Screen + popUpTo(ListifyScreens.SplashScreen) { inclusive = true } + launchSingleTop = true + } + } else { + // TODO: Show login + SignIn button at the bottom + isShowButtons.value = true + } }) Surface( @@ -65,5 +83,9 @@ fun ListifySplashScreen(navController: NavHostController) { color = ListifyColor.TextGrey ) } + + if (isShowButtons.value) { + AuthButtons(navController) + } } } diff --git a/app/src/main/java/com/example/listifyjetapp/ui/screens/splash/SplashViewModel.kt b/app/src/main/java/com/example/listifyjetapp/ui/screens/splash/SplashViewModel.kt new file mode 100644 index 0000000..80fbbcf --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/ui/screens/splash/SplashViewModel.kt @@ -0,0 +1,56 @@ +package com.example.listifyjetapp.ui.screens.splash + +import androidx.lifecycle.ViewModel +import com.example.listifyjetapp.data.ListifyStorageManager +import com.example.listifyjetapp.network.ListifyAPI +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +@HiltViewModel +class SplashViewModel @Inject constructor( + private val storageManager: ListifyStorageManager, + private val api: ListifyAPI +) : ViewModel() { + + suspend fun isLoggedIn(): Boolean = storageManager.isLoggedInFlow.first() + suspend fun getUserId(): Int = storageManager.userIdFlow.first() + + // Optional: decode JWT exp; if you don't want to parse JWT, just try refresh and ignore errors. + private fun isExpired(jwt: String): Boolean { + return try { + val token = jwt.split(".") + if (token.size < 2) return true // treat as expired/invalid + val payload = android.util.Base64.decode( + token[1], + android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP + ) + val json = org.json.JSONObject(String(payload)) + val exp = json.optLong("exp", 0L) + val nowSec = System.currentTimeMillis() / 1000 + exp != 0L && exp <= nowSec + } catch (_: Exception) { false } + } + + suspend fun accessToken(): String { + return storageManager.accessTokenFlow.first() + } + // Make sure access toke nis valid before navigating + // returns true if we are good to proceed, false if we must show login + suspend fun checkAccessToken(): Boolean { + val accessToken = storageManager.accessTokenFlow.first() + if (accessToken.isNotBlank() && !isExpired(accessToken)) return true + + // try refresh + return try { + val refreshed = api.refresh(storageManager.refreshTokenFlow.first()) + storageManager.updateTokens( + refreshed.accessToken, refreshed.refreshToken + ) + true + } catch (_: Exception) { + storageManager.clearDataStore() + false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/ui/theme/Color.kt b/app/src/main/java/com/example/listifyjetapp/ui/theme/Color.kt index 6b65d4a..d525909 100644 --- a/app/src/main/java/com/example/listifyjetapp/ui/theme/Color.kt +++ b/app/src/main/java/com/example/listifyjetapp/ui/theme/Color.kt @@ -12,6 +12,8 @@ val Pink40 = Color(0xFF7D5260) object ListifyColor { val SplashYellow = Color(0xFFFFCA3A) + val TextBlack = Color(0xff000000) val TextDark = Color(0xff3e4e50) val TextGrey = Color(0xff858585) + val IconGreen = Color(0xff0cc25f) } diff --git a/app/src/main/java/com/example/listifyjetapp/utils/filterListItems.kt b/app/src/main/java/com/example/listifyjetapp/utils/filterListItems.kt new file mode 100644 index 0000000..e1c4f94 --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/utils/filterListItems.kt @@ -0,0 +1,14 @@ +package com.example.listifyjetapp.utils + +import android.util.Log +import com.example.listifyjetapp.model.ListModel + +fun filterListItems(input: String, listItems: List): List { + + return if (input.isEmpty()) { + listItems + } else { + listItems.filter { it.name.contains(input, ignoreCase = true) } + } + +} diff --git a/app/src/main/java/com/example/listifyjetapp/widgets/FilledButton.kt b/app/src/main/java/com/example/listifyjetapp/widgets/FilledButton.kt new file mode 100644 index 0000000..ee2af3e --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/widgets/FilledButton.kt @@ -0,0 +1,53 @@ +package com.example.listifyjetapp.widgets + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun FilledButton( + modifier: Modifier, + shape: Shape, + containerColor: Color, + contentColor: Color, + text: String, + buttonIcon: ImageVector? = null, + iconDescription: String?, + onClick: () -> Unit, +) { + Button( + modifier = modifier, + shape = shape, + colors = ButtonDefaults.buttonColors( + containerColor = containerColor, + contentColor = contentColor + ), + onClick = { onClick() } + ) { + if (buttonIcon != null) { + Icon( + imageVector = buttonIcon, + modifier = Modifier.size(20.dp), + contentDescription = iconDescription + ) + } + + Text( + text = text, + fontWeight = FontWeight.ExtraBold, + fontSize = 18.sp, + modifier = Modifier.padding(8.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/widgets/FormInputField.kt b/app/src/main/java/com/example/listifyjetapp/widgets/FormInputField.kt new file mode 100644 index 0000000..d74a32d --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/widgets/FormInputField.kt @@ -0,0 +1,46 @@ +package com.example.listifyjetapp.widgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.example.listifyjetapp.ui.theme.ListifyColor + +@Composable +fun FormInputField( + textState: MutableState, + onValueChange: (String) -> Unit, +) { + + TextField( + modifier = Modifier.fillMaxWidth(), + + shape = RoundedCornerShape(12.dp), + colors = TextFieldDefaults.colors( + unfocusedIndicatorColor = Color.White, + focusedIndicatorColor = Color.White, + disabledIndicatorColor = Color.Transparent, + + unfocusedLabelColor = ListifyColor.TextDark, + unfocusedContainerColor = ListifyColor.TextDark.copy(0.1f), + focusedContainerColor = ListifyColor.TextDark.copy(0.1f), + cursorColor = ListifyColor.TextDark, + ), + singleLine = true, + maxLines = 1, + + value = textState.value, + onValueChange = onValueChange, + placeholder = { Text(text="e.g., grocery list") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), // Sets the keyboard to normal text input. + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/widgets/ListifySearchBar.kt b/app/src/main/java/com/example/listifyjetapp/widgets/ListifySearchBar.kt new file mode 100644 index 0000000..f10d513 --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/widgets/ListifySearchBar.kt @@ -0,0 +1,62 @@ +package com.example.listifyjetapp.widgets + + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation.Companion.keyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.example.listifyjetapp.ui.theme.ListifyColor + + +@Composable +fun ListifySearchBar( + searchTextValue: MutableState, + onValueChange: (String) -> Unit, + imeAction: ImeAction = ImeAction.Next, // what happen when you press "Next" + keyboardAction: KeyboardActions = KeyboardActions.Default // what to do when an action is triggered +) { + TextField( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + shape = RoundedCornerShape(12.dp), + colors = TextFieldDefaults.colors( + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + + unfocusedLabelColor = ListifyColor.TextDark.copy(0.1f), + focusedContainerColor = ListifyColor.TextDark.copy(0.1f), + cursorColor = ListifyColor.TextDark, + ), + singleLine = true, + maxLines = 1, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search icon", + ) + }, + + value = searchTextValue.value, + onValueChange = onValueChange, + placeholder = { Text(text="Search") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), // Sets the keyboard to normal text input. + keyboardActions = keyboardAction, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/listifyjetapp/widgets/ListifyTopBar.kt b/app/src/main/java/com/example/listifyjetapp/widgets/ListifyTopBar.kt new file mode 100644 index 0000000..ae71ebe --- /dev/null +++ b/app/src/main/java/com/example/listifyjetapp/widgets/ListifyTopBar.kt @@ -0,0 +1,87 @@ +package com.example.listifyjetapp.widgets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults.topAppBarColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +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.example.listifyjetapp.ui.theme.ListifyColor + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun ListifyTopBar( + title: String = "Screen Tittle", + isListsScreen: Boolean = true, + goBackIcon: ImageVector? = null, + leftText: String? = "", + rightIcon: ImageVector? = null, + rightText: String? = "", + onGoBackButtonClicked: () -> Unit = {}, + onRightButtonClick: () -> Unit = {} +) { + + CenterAlignedTopAppBar( + //modifier = Modifier.shadow(elevation = 5.dp), + colors = topAppBarColors(containerColor = ListifyColor.SplashYellow), + title = { + Text(text = title, fontWeight = FontWeight.ExtraBold, fontSize = 24.sp) + }, + + actions = { + if (rightIcon != null) { + IconButton(onClick = { onRightButtonClick() }) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = rightIcon, //Icons.Default.Add, + contentDescription = "Add icon" + ) + } + } + + if (rightText.toString().isNotEmpty() && rightText != null) { + Text( + text = rightText, + modifier = Modifier.padding(horizontal = 16.dp).clickable { onRightButtonClick() }, + fontSize = 20.sp, + color = ListifyColor.TextDark + ) + } + }, + + navigationIcon = { + if (goBackIcon != null) { + Icon( + imageVector = goBackIcon, + contentDescription = "GO back icon", + modifier = Modifier.size(24.dp).clickable { onGoBackButtonClicked.invoke() } + ) + } + if (leftText.toString().isNotEmpty() && leftText != null) { + Text( + text = leftText, + modifier = Modifier + .padding(horizontal = 16.dp) + .clickable { onGoBackButtonClicked.invoke() }, + fontSize = 20.sp, + color = ListifyColor.TextDark + ) + } + + } + ) +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 96b3b79..5561f9b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] agp = "8.10.1" +datastore = "1.1.7" kotlin = "2.1.20" coreKtx = "1.16.0" junit = "4.13.2" @@ -7,10 +8,12 @@ junitVersion = "1.2.1" espressoCore = "3.6.1" lifecycleRuntimeKtx = "2.9.1" activityCompose = "1.10.1" -composeBom = "2024.09.00" +composeBom = "2025.07.00" coilCompose = "1.4.0" converterGson = "2.9.0" +materialIconsExtended = "1.7.8" +navigationCompose = "2.9.3" roomKtx = "2.7.2" roomRuntime = "2.7.2" hiltCompiler = "2.56.2" @@ -23,10 +26,13 @@ kotlinxCoroutinesCore = "1.10.1" kotlinxCoroutinesPlayServices = "1.10.1" lifecycleRuntimeKtxVersion = "2.9.1" okhttp = "5.0.0-alpha.2" +kotlinxSerializationJson = "1.7.3" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } +androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } 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" } @@ -59,6 +65,13 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesPlayServices" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-navigation-dynamic-features-fragment = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigationCompose" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment", version.ref = "navigationCompose" } +androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "navigationCompose" } +androidx-navigation-ui = { module = "androidx.navigation:navigation-ui", version.ref = "navigationCompose" } + #font androidx-ui-text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts", version = "1.8.3" } # or match your Compose BOM