diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2b2dde9..eae278a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + id("com.google.gms.google-services") } android { @@ -48,6 +49,15 @@ android { dependencies { + //DataStore + implementation(libs.data.store) + + //Firebase + implementation(platform("com.google.firebase:firebase-bom:34.6.0")) + implementation("com.google.firebase:firebase-analytics") + implementation("com.google.firebase:firebase-firestore") + implementation("com.google.firebase:firebase-auth") + //Koil implementation("io.insert-koin:koin-android:3.5.6") implementation("io.insert-koin:koin-androidx-compose:3.5.6") diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000..a590690 --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "1059643006132", + "project_id": "devhub-4912b", + "storage_bucket": "devhub-4912b.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:1059643006132:android:459aaa892b0f25fdf6cbe1", + "android_client_info": { + "package_name": "com.delecrode.devhub" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyCclTzmPM0pdKLYQQJESqx0ZHdXJ7-vxdM" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/src/main/java/com/delecrode/devhub/MainActivity.kt b/app/src/main/java/com/delecrode/devhub/MainActivity.kt index 3a2e6d1..71fec80 100644 --- a/app/src/main/java/com/delecrode/devhub/MainActivity.kt +++ b/app/src/main/java/com/delecrode/devhub/MainActivity.kt @@ -4,16 +4,20 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import com.delecrode.devhub.domain.session.SessionViewModel import com.delecrode.devhub.navigation.AppNavHost import com.delecrode.devhub.ui.theme.DevHubTheme +import org.koin.androidx.compose.koinViewModel class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { DevHubTheme { - AppNavHost() + val sessionViewModel : SessionViewModel = koinViewModel() + AppNavHost(sessionViewModel) } } } diff --git a/app/src/main/java/com/delecrode/devhub/data/local/dataStore/AuthLocalDataSource.kt b/app/src/main/java/com/delecrode/devhub/data/local/dataStore/AuthLocalDataSource.kt new file mode 100644 index 0000000..405f969 --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/data/local/dataStore/AuthLocalDataSource.kt @@ -0,0 +1,10 @@ +package com.delecrode.devhub.data.local.dataStore + +import kotlinx.coroutines.flow.Flow + +interface AuthLocalDataSource { + fun getUID(): Flow + suspend fun saveUID(uid: String) + + suspend fun clearUID() +} \ No newline at end of file diff --git a/app/src/main/java/com/delecrode/devhub/data/local/dataStore/AuthLocalDataSourceImpl.kt b/app/src/main/java/com/delecrode/devhub/data/local/dataStore/AuthLocalDataSourceImpl.kt new file mode 100644 index 0000000..730dedf --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/data/local/dataStore/AuthLocalDataSourceImpl.kt @@ -0,0 +1,37 @@ +package com.delecrode.devhub.data.local.dataStore + +import android.content.Context +import android.util.Log +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore by preferencesDataStore("auth_prefs") + +class AuthLocalDataSourceImpl(private val context: Context) : AuthLocalDataSource { + + private object PreferencesKeys { + val UID_KEY = stringPreferencesKey("uid") + } + + override fun getUID(): Flow = + context.dataStore.data.map { prefs -> + prefs[PreferencesKeys.UID_KEY] + } + + + override suspend fun saveUID(uid: String) { + Log.i("AuthLocalDataSourceImpl", "saveUser: $uid") + context.dataStore.edit { prefs -> + prefs[PreferencesKeys.UID_KEY] = uid + } + } + + override suspend fun clearUID() { + context.dataStore.edit { prefs -> + prefs.remove(PreferencesKeys.UID_KEY) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/delecrode/devhub/data/mapper/UserMapper.kt b/app/src/main/java/com/delecrode/devhub/data/mapper/UserMapper.kt index 05f6685..b0647e6 100644 --- a/app/src/main/java/com/delecrode/devhub/data/mapper/UserMapper.kt +++ b/app/src/main/java/com/delecrode/devhub/data/mapper/UserMapper.kt @@ -1,10 +1,12 @@ package com.delecrode.devhub.data.mapper -import com.delecrode.devhub.data.model.UserDto -import com.delecrode.devhub.domain.model.User +import com.delecrode.devhub.data.model.UserForFirebaseDto +import com.delecrode.devhub.data.model.UserForGitDto +import com.delecrode.devhub.domain.model.UserForFirebase +import com.delecrode.devhub.domain.model.UserForGit -fun UserDto.toUserDomain(): User { - return User( +fun UserForGitDto.toUserDomain(): UserForGit { + return UserForGit( login = login, avatar_url = avatar_url, url = url , @@ -13,3 +15,12 @@ fun UserDto.toUserDomain(): User { repos_url = repos_url ) } + + +fun UserForFirebaseDto.toUserDomain(): UserForFirebase{ + return UserForFirebase( + fullName = fullName, + username = username, + email = email + ) +} diff --git a/app/src/main/java/com/delecrode/devhub/data/model/UserDto.kt b/app/src/main/java/com/delecrode/devhub/data/model/User.kt similarity index 86% rename from app/src/main/java/com/delecrode/devhub/data/model/UserDto.kt rename to app/src/main/java/com/delecrode/devhub/data/model/User.kt index b8500d5..b3628e1 100644 --- a/app/src/main/java/com/delecrode/devhub/data/model/UserDto.kt +++ b/app/src/main/java/com/delecrode/devhub/data/model/User.kt @@ -1,6 +1,6 @@ package com.delecrode.devhub.data.model -data class UserDto( +data class UserForGitDto( val login: String ?, val id: Int?, val node_id: String?, @@ -35,3 +35,9 @@ data class UserDto( val created_at: String?, val updated_at: String? ) + +data class UserForFirebaseDto( + val fullName: String = "", + val username: String = "", + val email: String = "" +) diff --git a/app/src/main/java/com/delecrode/devhub/data/remote/firebase/FirebaseAuth.kt b/app/src/main/java/com/delecrode/devhub/data/remote/firebase/FirebaseAuth.kt new file mode 100644 index 0000000..e634003 --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/data/remote/firebase/FirebaseAuth.kt @@ -0,0 +1,46 @@ +package com.delecrode.devhub.data.remote.firebase + +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +class FirebaseAuth( + private val auth: FirebaseAuth +) { + + suspend fun signIn(email: String, password: String): FirebaseUser? { + return suspendCoroutine { cont -> + auth.signInWithEmailAndPassword(email, password) + .addOnCompleteListener { task -> + if (task.isSuccessful) { + cont.resume(task.result?.user) + } else { + cont.resumeWithException( + task.exception ?: Exception("Erro desconhecido ao fazer login") + ) + } + } + } + } + + suspend fun signUp(email: String, password: String): String? { + return suspendCoroutine { cont -> + auth.createUserWithEmailAndPassword(email, password) + .addOnCompleteListener { task -> + if (task.isSuccessful) { + cont.resume(task.result?.user?.uid) + } else { + cont.resumeWithException( + task.exception ?: Exception("Erro desconhecido ao cadastrar") + ) + } + } + } + } + + fun signOut() { + auth.signOut() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/delecrode/devhub/data/remote/firebase/UserExtraData.kt b/app/src/main/java/com/delecrode/devhub/data/remote/firebase/UserExtraData.kt new file mode 100644 index 0000000..639fca7 --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/data/remote/firebase/UserExtraData.kt @@ -0,0 +1,24 @@ +package com.delecrode.devhub.data.remote.firebase + +import com.delecrode.devhub.domain.model.RegisterUser +import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FirebaseFirestore +import kotlinx.coroutines.tasks.await + +class UserExtraData( + private val firestore: FirebaseFirestore +) { + + fun saveUserData(uid: String, user: RegisterUser) { + firestore.collection("users") + .document(uid) + .set(user) + } + + suspend fun getUser(uid: String): DocumentSnapshot { + return firestore.collection("users") + .document(uid) + .get() + .await() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/delecrode/devhub/data/remote/RetrofitInstance.kt b/app/src/main/java/com/delecrode/devhub/data/remote/webApi/instance/RetrofitInstance.kt similarity index 84% rename from app/src/main/java/com/delecrode/devhub/data/remote/RetrofitInstance.kt rename to app/src/main/java/com/delecrode/devhub/data/remote/webApi/instance/RetrofitInstance.kt index 306ff15..de3ebb7 100644 --- a/app/src/main/java/com/delecrode/devhub/data/remote/RetrofitInstance.kt +++ b/app/src/main/java/com/delecrode/devhub/data/remote/webApi/instance/RetrofitInstance.kt @@ -1,8 +1,8 @@ -package com.delecrode.devhub.data.remote +package com.delecrode.devhub.data.remote.webApi.instance import com.delecrode.devhub.BuildConfig -import com.delecrode.devhub.data.remote.service.RepoApiService -import com.delecrode.devhub.data.remote.service.UserApiService +import com.delecrode.devhub.data.remote.webApi.service.RepoApiService +import com.delecrode.devhub.data.remote.webApi.service.UserApiService import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -36,4 +36,4 @@ object RetrofitInstance { val repoApi: RepoApiService by lazy { retrofit.create(RepoApiService::class.java) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/delecrode/devhub/data/remote/service/RepoApiService.kt b/app/src/main/java/com/delecrode/devhub/data/remote/webApi/service/RepoApiService.kt similarity index 90% rename from app/src/main/java/com/delecrode/devhub/data/remote/service/RepoApiService.kt rename to app/src/main/java/com/delecrode/devhub/data/remote/webApi/service/RepoApiService.kt index a104c94..dcfee62 100644 --- a/app/src/main/java/com/delecrode/devhub/data/remote/service/RepoApiService.kt +++ b/app/src/main/java/com/delecrode/devhub/data/remote/webApi/service/RepoApiService.kt @@ -1,4 +1,4 @@ -package com.delecrode.devhub.data.remote.service +package com.delecrode.devhub.data.remote.webApi.service import com.delecrode.devhub.data.model.LanguagesDto import com.delecrode.devhub.data.model.RepoDetailDto diff --git a/app/src/main/java/com/delecrode/devhub/data/remote/service/UserApiService.kt b/app/src/main/java/com/delecrode/devhub/data/remote/webApi/service/UserApiService.kt similarity index 74% rename from app/src/main/java/com/delecrode/devhub/data/remote/service/UserApiService.kt rename to app/src/main/java/com/delecrode/devhub/data/remote/webApi/service/UserApiService.kt index 8e9e145..213e2ca 100644 --- a/app/src/main/java/com/delecrode/devhub/data/remote/service/UserApiService.kt +++ b/app/src/main/java/com/delecrode/devhub/data/remote/webApi/service/UserApiService.kt @@ -1,7 +1,7 @@ -package com.delecrode.devhub.data.remote.service +package com.delecrode.devhub.data.remote.webApi.service import com.delecrode.devhub.data.model.ReposDto -import com.delecrode.devhub.data.model.UserDto +import com.delecrode.devhub.data.model.UserForGitDto import retrofit2.Response import retrofit2.http.GET import retrofit2.http.Path @@ -9,7 +9,7 @@ import retrofit2.http.Path interface UserApiService { @GET("users/{userName}") - suspend fun getUser(@Path("userName") userName: String): Response + suspend fun getUser(@Path("userName") userName: String): Response @GET("users/{userName}/repos") suspend fun getReposForUser(@Path("userName") userName: String) : Response> diff --git a/app/src/main/java/com/delecrode/devhub/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/delecrode/devhub/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..4fe8709 --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,70 @@ +package com.delecrode.devhub.data.repository + +import com.delecrode.devhub.data.local.dataStore.AuthLocalDataSource +import com.delecrode.devhub.data.remote.firebase.FirebaseAuth +import com.delecrode.devhub.data.remote.firebase.UserExtraData +import com.delecrode.devhub.domain.model.RegisterUser +import com.delecrode.devhub.domain.repository.AuthRepository +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import com.google.firebase.auth.FirebaseAuthInvalidUserException +import com.google.firebase.auth.FirebaseAuthUserCollisionException +import com.google.firebase.auth.FirebaseAuthWeakPasswordException +import com.google.firebase.auth.FirebaseUser + +class AuthRepositoryImpl( + private val authDataSource: FirebaseAuth, + private val userExtraDataSource: UserExtraData, + private val authLocalDataSource: AuthLocalDataSource +) : AuthRepository { + + override suspend fun signIn(email: String, password: String): FirebaseUser { + try { + val response = authDataSource.signIn(email, password) + authLocalDataSource.saveUID(response?.uid ?: "") + return response ?: throw Exception("Erro ao recuperar usuário após login") + } catch (e: Exception) { + val errorMessage = when (e) { + is FirebaseAuthInvalidUserException -> "Usuário não encontrado." + is FirebaseAuthInvalidCredentialsException -> "Senha incorreta ou e-mail inválido." + is FirebaseAuthUserCollisionException -> "Este e-mail já está em uso." + is FirebaseAuthWeakPasswordException -> "A senha deve ter pelo menos 6 caracteres." + else -> e.message ?: "Erro desconhecido ao fazer login" + } + throw Exception(errorMessage) + } + } + + override suspend fun signUp( + name: String, + username: String, + email: String, + password: String + ): Boolean { + try { + val uid = authDataSource.signUp(email, password) ?: return false + + val userData = RegisterUser( + fullName = name, + username = username, + email = email + ) + + userExtraDataSource.saveUserData(uid, userData) + + return true + } catch (e: Exception) { + val errorMessage = when (e) { + is FirebaseAuthUserCollisionException -> "Este e-mail já está em uso." + is FirebaseAuthWeakPasswordException -> "A senha deve ter pelo menos 6 caracteres." + is FirebaseAuthInvalidCredentialsException -> "E-mail inválido." + else -> e.message ?: "Erro desconhecido ao cadastrar" + } + throw Exception(errorMessage) + } + } + + override suspend fun signOut() { + authDataSource.signOut() + authLocalDataSource.clearUID() + } +} diff --git a/app/src/main/java/com/delecrode/devhub/data/repository/RepoRepositoryImpl.kt b/app/src/main/java/com/delecrode/devhub/data/repository/RepoRepositoryImpl.kt index b81e083..d19b975 100644 --- a/app/src/main/java/com/delecrode/devhub/data/repository/RepoRepositoryImpl.kt +++ b/app/src/main/java/com/delecrode/devhub/data/repository/RepoRepositoryImpl.kt @@ -2,7 +2,7 @@ package com.delecrode.devhub.data.repository import com.delecrode.devhub.data.mapper.toLanguagesDomain import com.delecrode.devhub.data.mapper.toRepoDetailDomain -import com.delecrode.devhub.data.remote.service.RepoApiService +import com.delecrode.devhub.data.remote.webApi.service.RepoApiService import com.delecrode.devhub.domain.model.Languages import com.delecrode.devhub.domain.model.RepoDetail import com.delecrode.devhub.domain.repository.RepoRepository diff --git a/app/src/main/java/com/delecrode/devhub/data/repository/UserRepositoryImpl.kt b/app/src/main/java/com/delecrode/devhub/data/repository/UserRepositoryImpl.kt index 5bc4ad8..88abdfa 100644 --- a/app/src/main/java/com/delecrode/devhub/data/repository/UserRepositoryImpl.kt +++ b/app/src/main/java/com/delecrode/devhub/data/repository/UserRepositoryImpl.kt @@ -1,16 +1,21 @@ package com.delecrode.devhub.data.repository import android.util.Log +import com.delecrode.devhub.data.local.dataStore.AuthLocalDataSource import com.delecrode.devhub.data.mapper.toReposDomain import com.delecrode.devhub.data.mapper.toUserDomain -import com.delecrode.devhub.data.remote.service.UserApiService +import com.delecrode.devhub.data.model.UserForFirebaseDto +import com.delecrode.devhub.data.remote.firebase.UserExtraData +import com.delecrode.devhub.data.remote.webApi.service.UserApiService import com.delecrode.devhub.domain.model.Repos -import com.delecrode.devhub.domain.model.User +import com.delecrode.devhub.domain.model.UserForFirebase +import com.delecrode.devhub.domain.model.UserForGit import com.delecrode.devhub.domain.repository.UserRepository +import kotlinx.coroutines.flow.first -class UserRepositoryImpl(private val userApi: UserApiService) : UserRepository { +class UserRepositoryImpl(private val userApi: UserApiService, private val userExtraData: UserExtraData, private val authLocalDataSource: AuthLocalDataSource) : UserRepository { - override suspend fun getUser(userName: String): User { + override suspend fun getUserForGitHub(userName: String): UserForGit { try { val response = userApi.getUser(userName) if (response.isSuccessful) { @@ -29,6 +34,31 @@ class UserRepositoryImpl(private val userApi: UserApiService) : UserRepository { } } + override suspend fun getUserForFirebase(): UserForFirebase { + try { + val uid = authLocalDataSource.getUID().first() + Log.i("UserRepositoryImpl", "getUserForFirebase (UID Real): $uid") + if(uid != null){ + val response = userExtraData.getUser(uid) + if (response.exists()) { + val body = response.toObject(UserForFirebaseDto::class.java)?.toUserDomain() + if (body != null) { + return body + } else { + throw Exception("Resposta vazia do servidor") + } + }else{ + throw Exception("Usuário não encontrado") + } + } + else{ + return UserForFirebase() + } + }catch (e: Exception){ + throw e + } + } + override suspend fun getRepos(userName: String): List { try { val response = userApi.getReposForUser(userName) @@ -47,4 +77,3 @@ class UserRepositoryImpl(private val userApi: UserApiService) : UserRepository { } } } - diff --git a/app/src/main/java/com/delecrode/devhub/di/AppModule.kt b/app/src/main/java/com/delecrode/devhub/di/AppModule.kt index f734805..2801b91 100644 --- a/app/src/main/java/com/delecrode/devhub/di/AppModule.kt +++ b/app/src/main/java/com/delecrode/devhub/di/AppModule.kt @@ -1,23 +1,47 @@ package com.delecrode.devhub.di -import com.delecrode.devhub.data.remote.RetrofitInstance +import com.delecrode.devhub.data.local.dataStore.AuthLocalDataSource +import com.delecrode.devhub.data.local.dataStore.AuthLocalDataSourceImpl +import com.delecrode.devhub.data.remote.firebase.UserExtraData +import com.delecrode.devhub.data.remote.webApi.instance.RetrofitInstance +import com.delecrode.devhub.data.repository.AuthRepositoryImpl import com.delecrode.devhub.data.repository.RepoRepositoryImpl import com.delecrode.devhub.data.repository.UserRepositoryImpl +import com.delecrode.devhub.domain.repository.AuthRepository import com.delecrode.devhub.domain.repository.RepoRepository import com.delecrode.devhub.domain.repository.UserRepository +import com.delecrode.devhub.domain.session.SessionViewModel import com.delecrode.devhub.ui.home.HomeViewModel +import com.delecrode.devhub.ui.login.AuthViewModel +import com.delecrode.devhub.ui.register.RegisterViewModel import com.delecrode.devhub.ui.repo.RepoDetailViewModel +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.FirebaseFirestore +import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module + val appModule = module { + single { FirebaseAuth.getInstance() } + single { FirebaseFirestore.getInstance() } + single { RetrofitInstance.userApi } single { RetrofitInstance.repoApi } - single { UserRepositoryImpl(get()) } + single { AuthLocalDataSourceImpl(get()) } + + single { com.delecrode.devhub.data.remote.firebase.FirebaseAuth(get()) } + single { UserExtraData(get()) } + + single { UserRepositoryImpl(get(), get(), get()) } single { RepoRepositoryImpl(get()) } + single { AuthRepositoryImpl(get(), get(), get()) } - single { HomeViewModel(get()) } - single { RepoDetailViewModel(get()) } + viewModel{ HomeViewModel(get(), get()) } + viewModel { RepoDetailViewModel(get()) } + viewModel { AuthViewModel(get()) } + viewModel { SessionViewModel(get()) } + viewModel { RegisterViewModel(get()) } } \ No newline at end of file diff --git a/app/src/main/java/com/delecrode/devhub/domain/model/User.kt b/app/src/main/java/com/delecrode/devhub/domain/model/User.kt index 28e3da4..9aac089 100644 --- a/app/src/main/java/com/delecrode/devhub/domain/model/User.kt +++ b/app/src/main/java/com/delecrode/devhub/domain/model/User.kt @@ -1,6 +1,8 @@ package com.delecrode.devhub.domain.model -data class User( + +//User for GitHub +data class UserForGit( val login: String?, val avatar_url : String?, val url : String?, @@ -8,3 +10,18 @@ data class User( val bio: String?, val repos_url : String? ) + + +//User For Firebase +data class RegisterUser( + val fullName: String = "", + val username: String = "", + val email: String = "" +) + +data class UserForFirebase( + val fullName: String = "", + val username: String = "", + val email: String = "" +) + diff --git a/app/src/main/java/com/delecrode/devhub/domain/repository/AuthRepository.kt b/app/src/main/java/com/delecrode/devhub/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..5113038 --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/domain/repository/AuthRepository.kt @@ -0,0 +1,18 @@ +package com.delecrode.devhub.domain.repository + +import com.google.firebase.auth.FirebaseUser + +interface AuthRepository { + + suspend fun signIn(email: String, password: String): FirebaseUser + + suspend fun signUp( + name: String, + username: String, + email: String, + password: String + ): Boolean + suspend fun signOut() + + +} \ No newline at end of file diff --git a/app/src/main/java/com/delecrode/devhub/domain/repository/UserRepository.kt b/app/src/main/java/com/delecrode/devhub/domain/repository/UserRepository.kt index 03eeaaf..a773d55 100644 --- a/app/src/main/java/com/delecrode/devhub/domain/repository/UserRepository.kt +++ b/app/src/main/java/com/delecrode/devhub/domain/repository/UserRepository.kt @@ -1,11 +1,14 @@ package com.delecrode.devhub.domain.repository import com.delecrode.devhub.domain.model.Repos -import com.delecrode.devhub.domain.model.User +import com.delecrode.devhub.domain.model.UserForFirebase +import com.delecrode.devhub.domain.model.UserForGit interface UserRepository { - suspend fun getUser(userName: String): User + suspend fun getUserForGitHub(userName: String): UserForGit suspend fun getRepos(userName: String): List + suspend fun getUserForFirebase(): UserForFirebase + } diff --git a/app/src/main/java/com/delecrode/devhub/domain/session/SessionViewModel.kt b/app/src/main/java/com/delecrode/devhub/domain/session/SessionViewModel.kt new file mode 100644 index 0000000..148c540 --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/domain/session/SessionViewModel.kt @@ -0,0 +1,24 @@ +package com.delecrode.devhub.domain.session + +import androidx.lifecycle.ViewModel +import com.google.firebase.auth.FirebaseAuth +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class SessionViewModel( + private val auth: FirebaseAuth +) : ViewModel() { + + private val _isLoggedIn = MutableStateFlow(auth.currentUser != null) + val isLoggedIn: StateFlow = _isLoggedIn + + init { + auth.addAuthStateListener { + _isLoggedIn.value = it.currentUser != null + } + } + + fun signOut() { + auth.signOut() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/delecrode/devhub/navigation/AppDestinations.kt b/app/src/main/java/com/delecrode/devhub/navigation/AppDestinations.kt index 6d54d65..127b1ba 100644 --- a/app/src/main/java/com/delecrode/devhub/navigation/AppDestinations.kt +++ b/app/src/main/java/com/delecrode/devhub/navigation/AppDestinations.kt @@ -2,9 +2,14 @@ package com.delecrode.devhub.navigation sealed class AppDestinations(val route: String) { - object Home : AppDestinations("home") + object Home : AppDestinations("home/{uid}"){ + fun createRoute(uid: String) = "home/$uid" + } object RepoDetail : AppDestinations("repoDetail/{owner}/{repo}"){ fun createRoute(owner: String, repo: String) = "repoDetail/$owner/$repo" } + object Register: AppDestinations("register") + object Login: AppDestinations("login") + object ForgotPassword: AppDestinations("forgotPassword") } \ No newline at end of file diff --git a/app/src/main/java/com/delecrode/devhub/navigation/AppNavHost.kt b/app/src/main/java/com/delecrode/devhub/navigation/AppNavHost.kt index 5a144fa..6dad5f0 100644 --- a/app/src/main/java/com/delecrode/devhub/navigation/AppNavHost.kt +++ b/app/src/main/java/com/delecrode/devhub/navigation/AppNavHost.kt @@ -1,42 +1,74 @@ package com.delecrode.devhub.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.delecrode.devhub.domain.session.SessionViewModel +import com.delecrode.devhub.ui.forgot.ForgotPasswordScreen import com.delecrode.devhub.ui.home.HomeScreen import com.delecrode.devhub.ui.home.HomeViewModel +import com.delecrode.devhub.ui.login.AuthViewModel +import com.delecrode.devhub.ui.login.LoginScreen +import com.delecrode.devhub.ui.register.RegisterScreen +import com.delecrode.devhub.ui.register.RegisterViewModel import com.delecrode.devhub.ui.repo.RepoDetailScreen import com.delecrode.devhub.ui.repo.RepoDetailViewModel import org.koin.androidx.compose.koinViewModel @Composable -fun AppNavHost() { +fun AppNavHost(sessionViewModel: SessionViewModel) { val navController = rememberNavController() val profileViewModel: HomeViewModel = koinViewModel() val repoViewModel: RepoDetailViewModel = koinViewModel() + val authViewModel: AuthViewModel = koinViewModel() + val registerViewModel: RegisterViewModel = koinViewModel() - NavHost(navController = navController, startDestination = AppDestinations.Home.route) { - //Profile Flow - composable(AppDestinations.Home.route) { + val logged = sessionViewModel.isLoggedIn.collectAsState() + + + NavHost( + navController = navController, + startDestination = if (logged.value) AppDestinations.Home.route else AppDestinations.Login.route + ) { + //Home Flow + composable(AppDestinations.Home.route){ HomeScreen(navController, profileViewModel) } + //Repositorio Flow composable( AppDestinations.RepoDetail.route, arguments = listOf( - navArgument("owner") { type = NavType.StringType }, - navArgument("repo"){ type = NavType.StringType} - )) { + navArgument("owner") { type = NavType.StringType }, + navArgument("repo") { type = NavType.StringType } + )) { val owner = it.arguments?.getString("owner") ?: "" val repo = it.arguments?.getString("repo") ?: "" RepoDetailScreen(navController, repoViewModel, owner, repo) } + + //Register Flow + composable(AppDestinations.Register.route) { + RegisterScreen(navController, registerViewModel) + } + + //Login Flow + composable(AppDestinations.Login.route) { + LoginScreen(navController, authViewModel) + } + + //Forgot Password Flow + composable(AppDestinations.ForgotPassword.route) { + ForgotPasswordScreen(navController) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/delecrode/devhub/ui/components/Button.kt b/app/src/main/java/com/delecrode/devhub/ui/components/Button.kt new file mode 100644 index 0000000..a6e25d5 --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/ui/components/Button.kt @@ -0,0 +1,53 @@ +package com.delecrode.devhub.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +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.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.delecrode.devhub.ui.theme.DevHubTheme +import com.delecrode.devhub.ui.theme.PrimaryBlue + + +@Composable +fun PrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier + .fillMaxWidth() + .height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = PrimaryBlue + ), + shape = RoundedCornerShape(8.dp), + ) { + Text( + text = text, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } +} + +@Preview +@Composable +private fun PrimaryButtonPreview() { + DevHubTheme() { + PrimaryButton(text = "CONFIRMAR", onClick = {}) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/delecrode/devhub/ui/components/OutlineTextField.kt b/app/src/main/java/com/delecrode/devhub/ui/components/OutlineTextField.kt new file mode 100644 index 0000000..08f124c --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/ui/components/OutlineTextField.kt @@ -0,0 +1,295 @@ +package com.delecrode.devhub.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +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.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +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.focus.FocusDirection +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.delecrode.devhub.R +import com.delecrode.devhub.ui.theme.DevHubTheme +import com.delecrode.devhub.ui.theme.PrimaryBlue + +@Composable +fun EmailTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String = "Email", + isError: Boolean = false, + errorMessage: String = "", + imeAction: ImeAction = ImeAction.Next +) { + val focusManager = LocalFocusManager.current + + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.background, + RoundedCornerShape(8.dp) + ), + placeholder = { Text(label) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = PrimaryBlue + ) + }, + isError = isError, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = imeAction + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + onDone = { focusManager.clearFocus() } + ), + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.background, + unfocusedContainerColor = MaterialTheme.colorScheme.background, + disabledContainerColor = MaterialTheme.colorScheme.background, + focusedIndicatorColor = MaterialTheme.colorScheme.onBackground, + unfocusedIndicatorColor = MaterialTheme.colorScheme.onBackground, + errorIndicatorColor = MaterialTheme.colorScheme.error + ), + shape = RoundedCornerShape(8.dp) + ) + + if (isError && errorMessage.isNotEmpty()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 16.dp, top = 4.dp) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun EmailTextFieldPreview() { + DevHubTheme() { + EmailTextField( + value = "user@example.com", + onValueChange = {}, + modifier = Modifier.padding(16.dp) + ) + } +} + + +@Composable +fun PasswordTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String = "Senha", + isError: Boolean = false, + errorMessage: String = "", + imeAction: ImeAction = ImeAction.Done, + isPasswordVisible: Boolean = false, + onVisibilityChange: (() -> Unit)? = null +) { + val focusManager = LocalFocusManager.current + var passwordVisible by remember { mutableStateOf(isPasswordVisible) } + + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background, RoundedCornerShape(8.dp)), + placeholder = { Text(label) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + tint = PrimaryBlue + ) + }, + trailingIcon = { + IconButton(onClick = { + if (onVisibilityChange != null) { + onVisibilityChange() + } else { + passwordVisible = !passwordVisible + } + }) { + Icon( + painter = if (onVisibilityChange != null) { + if (isPasswordVisible) painterResource(R.drawable.ic_visibility_off_24) else painterResource( + R.drawable.ic_visibility_on_24 + ) + } else { + if (passwordVisible) painterResource(R.drawable.ic_visibility_off_24) else painterResource( + R.drawable.ic_visibility_on_24 + ) + }, + contentDescription = if (onVisibilityChange != null) { + if (isPasswordVisible) "Hide password" else "Show password" + } else { + if (passwordVisible) "Hide password" else "Show password" + }, + tint = PrimaryBlue + ) + } + }, + isError = isError, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = imeAction + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + onDone = { focusManager.clearFocus() } + ), + singleLine = true, + visualTransformation = if (onVisibilityChange != null) { + if (isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation() + } else { + if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation() + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.background, + unfocusedContainerColor = MaterialTheme.colorScheme.background, + disabledContainerColor = MaterialTheme.colorScheme.background, + focusedIndicatorColor = MaterialTheme.colorScheme.onBackground, + unfocusedIndicatorColor = MaterialTheme.colorScheme.onBackground, + errorIndicatorColor = MaterialTheme.colorScheme.error + ), + shape = RoundedCornerShape(8.dp) + ) + + if (isError && errorMessage.isNotEmpty()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 16.dp, top = 4.dp) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun PasswordTextFieldPreview() { + DevHubTheme() { + PasswordTextField( + value = "password123", + onValueChange = {}, + modifier = Modifier.padding(16.dp) + ) + } +} + +@Composable +fun GenericOutlinedTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String, + leadingIcon: ImageVector? = null, + isError: Boolean = false, + errorMessage: String = "", + keyboardType: KeyboardType = KeyboardType.Text, + imeAction: ImeAction = ImeAction.Next, +) { + val focusManager = LocalFocusManager.current + + Column(modifier = Modifier.fillMaxWidth()) { + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier.fillMaxWidth(), + placeholder = { Text(label) }, + leadingIcon = { + leadingIcon?.let { + Icon( + imageVector = it, + contentDescription = null, + tint = PrimaryBlue + ) + } + }, + isError = isError, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = imeAction + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + onDone = { focusManager.clearFocus() } + ), + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.background, + unfocusedContainerColor = MaterialTheme.colorScheme.background, + disabledContainerColor = MaterialTheme.colorScheme.background, + focusedIndicatorColor = MaterialTheme.colorScheme.onBackground, + unfocusedIndicatorColor = MaterialTheme.colorScheme.onBackground, + errorIndicatorColor = MaterialTheme.colorScheme.error + ), + shape = RoundedCornerShape(8.dp) + ) + + if (isError && errorMessage.isNotEmpty()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 16.dp, top = 4.dp) + ) + } + } +} + + +@Preview(showBackground = true) +@Composable +fun GenericOutlinedTextFieldPreview() { + DevHubTheme() { + GenericOutlinedTextField( + value = "password123", + onValueChange = {}, + label = "Usuario" + ) + } +} + + diff --git a/app/src/main/java/com/delecrode/devhub/ui/forgot/ForgotPasswordScreen.kt b/app/src/main/java/com/delecrode/devhub/ui/forgot/ForgotPasswordScreen.kt new file mode 100644 index 0000000..5d790a7 --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/ui/forgot/ForgotPasswordScreen.kt @@ -0,0 +1,105 @@ +package com.delecrode.devhub.ui.forgot + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +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.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import com.delecrode.devhub.navigation.AppDestinations +import com.delecrode.devhub.ui.components.EmailTextField +import com.delecrode.devhub.ui.components.PrimaryButton + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ForgotPasswordScreen(navController: NavController) { + + var email by remember { mutableStateOf("") } + + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = "Recuperar Senha", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ), + navigationIcon = { + IconButton(onClick = { + navController.popBackStack() + }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Voltar", + tint = MaterialTheme.colorScheme.onBackground + ) + } + } + ) + } + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + Column(modifier = Modifier.padding(8.dp)) { + Text( + "Digite seu e-mail cadastrado " + + "\npara que possamos enviar um link " + + "para que você possa criar uma nova senha", + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + EmailTextField( + value = email, + onValueChange = { email = it }, + label = "Email", + imeAction = ImeAction.Done + ) + + Spacer(modifier = Modifier.height(16.dp)) + + PrimaryButton( + text = "Enviar e-mail", + onClick = { navController.navigate(AppDestinations.Login.route) }, + enabled = email.isNotBlank() + ) + } + } + } +} + +@Preview +@Composable +fun ForgotPasswordScreenPreview() { + ForgotPasswordScreen(rememberNavController()) +} \ No newline at end of file diff --git a/app/src/main/java/com/delecrode/devhub/ui/home/HomeScreen.kt b/app/src/main/java/com/delecrode/devhub/ui/home/HomeScreen.kt index 1df4796..ee2ad5e 100644 --- a/app/src/main/java/com/delecrode/devhub/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/delecrode/devhub/ui/home/HomeScreen.kt @@ -11,14 +11,19 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -61,18 +66,22 @@ import com.delecrode.devhub.ui.theme.PrimaryBlue fun HomeScreen(navController: NavController, homeViewModel: HomeViewModel) { val uiState = homeViewModel.uiState.collectAsState() - val user = uiState.value.user + val userForSearchGit = uiState.value.userForSearchGit + val userForGit = uiState.value.userForGit + val userForFirebase = uiState.value.userForFirebase + val repos = uiState.value.repos var searchText by remember { mutableStateOf("") } var search by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } val context = LocalContext.current LaunchedEffect(search) { if (search && searchText.isNotBlank()) { homeViewModel.getRepos(searchText) - homeViewModel.getUser(searchText) + homeViewModel.getUserForSearchGit(searchText) search = false } } @@ -83,16 +92,89 @@ fun HomeScreen(navController: NavController, homeViewModel: HomeViewModel) { homeViewModel.clearStates() } } + LaunchedEffect(Unit) { + homeViewModel.getUserForFirebase() + } + + LaunchedEffect(userForFirebase) { + val firebaseUser = userForFirebase + if (firebaseUser != null) { + homeViewModel.getUserForGit(firebaseUser.username) + } + } + Scaffold( topBar = { CenterAlignedTopAppBar( - title = { Text("DevHub") }, + title = { + Row { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(Color.White) + ) { + AsyncImage( + model = userForGit?.avatar_url ?: R.drawable.git_logo, + contentDescription = "Foto de Perfil", + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + + Column { + Text( + text = userForFirebase?.fullName ?: "", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = userForFirebase?.username ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + actions = { + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = "Menu" + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { Text("Meu Perfil") }, + onClick = { + expanded = false + navController.navigate("profile") + } + ) + DropdownMenuItem( + text = { Text("Sair") }, + onClick = { + expanded = false + homeViewModel.signOut() + navController.navigate("login") { + popUpTo(0) + } + } + ) + } + }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.background ) ) } + ) { padding -> @@ -101,9 +183,11 @@ fun HomeScreen(navController: NavController, homeViewModel: HomeViewModel) { .fillMaxSize() .padding(padding) ) { - Row(modifier = Modifier - .fillMaxWidth() - .padding(8.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { OutlinedTextField( value = searchText, onValueChange = { searchText = it }, @@ -131,7 +215,7 @@ fun HomeScreen(navController: NavController, homeViewModel: HomeViewModel) { } - user.let { user -> + userForSearchGit.let { user -> Column( modifier = Modifier .padding(8.dp), @@ -209,7 +293,14 @@ fun HomeScreen(navController: NavController, homeViewModel: HomeViewModel) { elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), colors = CardDefaults.cardColors(containerColor = Color.White), shape = RoundedCornerShape(8.dp), - onClick = {navController.navigate(AppDestinations.RepoDetail.createRoute(user?.login ?: "", repo.name))} + onClick = { + navController.navigate( + AppDestinations.RepoDetail.createRoute( + user?.login ?: "", + repo.name + ) + ) + } ) { Row( modifier = Modifier diff --git a/app/src/main/java/com/delecrode/devhub/ui/home/HomeState.kt b/app/src/main/java/com/delecrode/devhub/ui/home/HomeState.kt index 0325a53..e262c63 100644 --- a/app/src/main/java/com/delecrode/devhub/ui/home/HomeState.kt +++ b/app/src/main/java/com/delecrode/devhub/ui/home/HomeState.kt @@ -1,11 +1,14 @@ package com.delecrode.devhub.ui.home import com.delecrode.devhub.domain.model.Repos -import com.delecrode.devhub.domain.model.User +import com.delecrode.devhub.domain.model.UserForFirebase +import com.delecrode.devhub.domain.model.UserForGit data class HomeState( val isLoading: Boolean = false, - val user: User? = null, + val userForSearchGit: UserForGit? = null, + val userForGit: UserForGit? = null, + val userForFirebase: UserForFirebase? = null, val repos: List = emptyList(), val error: String? = null, ) \ No newline at end of file diff --git a/app/src/main/java/com/delecrode/devhub/ui/home/HomeViewModel.kt b/app/src/main/java/com/delecrode/devhub/ui/home/HomeViewModel.kt index 54e246c..382010a 100644 --- a/app/src/main/java/com/delecrode/devhub/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/delecrode/devhub/ui/home/HomeViewModel.kt @@ -4,26 +4,31 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.delecrode.devhub.domain.model.Repos +import com.delecrode.devhub.domain.repository.AuthRepository import com.delecrode.devhub.domain.repository.UserRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -class HomeViewModel(private val repository: UserRepository) : ViewModel() { +class HomeViewModel( + private val userRepository: UserRepository, + private val authRepository: AuthRepository +) : + ViewModel() { private val _uiState = MutableStateFlow(HomeState()) val uiState: StateFlow = _uiState - fun getUser(userName: String) { + fun getUserForSearchGit(userName: String) { viewModelScope.launch { _uiState.value = _uiState.value.copy( isLoading = true, error = null ) try { - val user = repository.getUser(userName) + val user = userRepository.getUserForGitHub(userName) _uiState.value = _uiState.value.copy( - user = user, + userForSearchGit = user, isLoading = false ) } catch (e: Exception) { @@ -36,6 +41,47 @@ class HomeViewModel(private val repository: UserRepository) : ViewModel() { } } + fun getUserForGit(userName: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isLoading = true, + error = null + ) + try { + val user = userRepository.getUserForGitHub(userName) + _uiState.value = _uiState.value.copy( + userForGit = user, + isLoading = false + ) + } catch (e: Exception) { + Log.e("HomeViewModel", "Erro ao buscar usuário", e) + _uiState.value = _uiState.value.copy( + error = e.message, + isLoading = false + ) + } + } + } + + fun getUserForFirebase() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isLoading = true, + error = null + ) + try { + val user = userRepository.getUserForFirebase() + _uiState.value = _uiState.value.copy( + userForFirebase = user, + isLoading = false + ) + Log.i("HomeViewModel", "getUserForFirebase: $user") + } catch (e: Exception) { + throw e + } + } + } + fun getRepos(userName: String) { viewModelScope.launch { _uiState.value = _uiState.value.copy( @@ -43,7 +89,7 @@ class HomeViewModel(private val repository: UserRepository) : ViewModel() { error = null ) try { - val repos: List = repository.getRepos(userName) + val repos: List = userRepository.getRepos(userName) _uiState.value = _uiState.value.copy( repos = repos, isLoading = false @@ -58,7 +104,21 @@ class HomeViewModel(private val repository: UserRepository) : ViewModel() { } } - fun clearStates(){ + fun signOut() { + viewModelScope.launch { + try { + authRepository.signOut() + } catch (e: Exception) { + Log.e("HomeViewModel", "Erro ao fazer logout", e) + _uiState.value = _uiState.value.copy( + error = e.message, + isLoading = false + ) + } + } + } + + fun clearStates() { _uiState.value = _uiState.value.copy( isLoading = false, error = null diff --git a/app/src/main/java/com/delecrode/devhub/ui/login/AuthState.kt b/app/src/main/java/com/delecrode/devhub/ui/login/AuthState.kt new file mode 100644 index 0000000..8c67f4d --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/ui/login/AuthState.kt @@ -0,0 +1,8 @@ +package com.delecrode.devhub.ui.login + +data class AuthState( + val isLoading: Boolean = false, + val isSuccess: Boolean = false, + val error: String? = null, + val userUid: String? = null +) diff --git a/app/src/main/java/com/delecrode/devhub/ui/login/AuthViewModel.kt b/app/src/main/java/com/delecrode/devhub/ui/login/AuthViewModel.kt new file mode 100644 index 0000000..694d0c2 --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/ui/login/AuthViewModel.kt @@ -0,0 +1,44 @@ +package com.delecrode.devhub.ui.login + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.delecrode.devhub.domain.repository.AuthRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class AuthViewModel( + private val repository: AuthRepository +) : ViewModel() { + + private val _state = MutableStateFlow(AuthState()) + val state = _state.asStateFlow() + + fun signIn(email: String, password: String) { + _state.value = AuthState(isLoading = true) + viewModelScope.launch { + _state.value = AuthState(isLoading = true) + + try { + val user = repository.signIn(email, password) + _state.value = AuthState( + isSuccess = true, + userUid = user.uid, + isLoading = false + ) + Log.i("AuthViewModel", "signIn: Usuario Logado ${user.uid}") + } catch (e: Exception) { + _state.value = AuthState( + error = e.message, + isLoading = false + ) + } + } + } + + + fun clearState(){ + _state.value = AuthState() + } +} diff --git a/app/src/main/java/com/delecrode/devhub/ui/login/LoginScreen.kt b/app/src/main/java/com/delecrode/devhub/ui/login/LoginScreen.kt new file mode 100644 index 0000000..236184d --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/ui/login/LoginScreen.kt @@ -0,0 +1,176 @@ +package com.delecrode.devhub.ui.login + +import android.widget.Toast +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import com.delecrode.devhub.navigation.AppDestinations +import com.delecrode.devhub.ui.components.EmailTextField +import com.delecrode.devhub.ui.components.PasswordTextField +import com.delecrode.devhub.ui.components.PrimaryButton +import org.koin.androidx.compose.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginScreen(navController: NavController, viewModel: AuthViewModel) { + + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + + val state by viewModel.state.collectAsState() + val context = LocalContext.current + + + LaunchedEffect(state.error) { + if (state.error != null) { + Toast.makeText(context, state.error, Toast.LENGTH_SHORT).show() + viewModel.clearState() + } + } + + LaunchedEffect(state.isSuccess) { + if (state.isSuccess) { + navController.navigate(AppDestinations.Home.createRoute(state.userUid ?: "")) { + popUpTo(AppDestinations.Login.route) { inclusive = true } + } + } + } + + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = "Login", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) + } + ) { padding -> + + Column(modifier = Modifier.padding(padding)) { + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.Center + ) { + Text( + text = "E-mail", + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + fontSize = 14.sp + ) + + EmailTextField( + value = email, + onValueChange = { email = it }, + imeAction = ImeAction.Next, + //isError = uiState.emailError != null, + //errorMessage = uiState.emailError ?: "" + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Senha", + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + fontSize = 14.sp + ) + + PasswordTextField( + value = password, + onValueChange = { password = it }, + imeAction = ImeAction.Done, + isPasswordVisible = passwordVisible, + onVisibilityChange = { passwordVisible = !passwordVisible }, + //isError = uiState.passwordError != null, + //errorMessage = uiState.passwordError ?: "" + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TextButton(onClick = { navController.navigate(AppDestinations.ForgotPassword.route) }) { + Text("Esqueceu a senha?") + } + + TextButton(onClick = { navController.navigate(AppDestinations.Register.route) }) { + Text("Não tem conta? Cadastre-se") + } + } + + PrimaryButton( + text = "ENTRAR", + onClick = { + viewModel.signIn(email, password) + }, + enabled = email.isNotBlank() && password.isNotBlank() + ) + } + } + if (state.isLoading) { + Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(0.5f)), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } +} + + +@Preview +@Composable +fun LoginScreenPreview() { + LoginScreen(rememberNavController(), koinViewModel()) + +} diff --git a/app/src/main/java/com/delecrode/devhub/ui/register/RegisterScreen.kt b/app/src/main/java/com/delecrode/devhub/ui/register/RegisterScreen.kt new file mode 100644 index 0000000..a3f4521 --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/ui/register/RegisterScreen.kt @@ -0,0 +1,255 @@ +package com.delecrode.devhub.ui.register + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import com.delecrode.devhub.navigation.AppDestinations +import com.delecrode.devhub.ui.components.EmailTextField +import com.delecrode.devhub.ui.components.GenericOutlinedTextField +import com.delecrode.devhub.ui.components.PasswordTextField +import com.delecrode.devhub.ui.components.PrimaryButton +import org.koin.androidx.compose.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RegisterScreen(navController: NavController, viewModel: RegisterViewModel) { + + var userName by remember { mutableStateOf("") } + var name by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + + var passwordVisible by remember { mutableStateOf(false) } + var passwordConfirmVisible by remember { mutableStateOf(false) } + + val state by viewModel.state.collectAsState() + val context = LocalContext.current + + + LaunchedEffect(state.error) { + if (state.error != null) { + Toast.makeText(context, state.error, Toast.LENGTH_SHORT).show() + viewModel.clearState() + } + } + + LaunchedEffect(state.isSuccess) { + if(state.isSuccess){ + navController.navigate(AppDestinations.Login.route) + } + } + + + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = "Cadastro", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ), + navigationIcon = { + IconButton(onClick = { + navController.popBackStack() + }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Voltar", + tint = MaterialTheme.colorScheme.onBackground + ) + } + } + ) + } + ) { padding -> + + Column(modifier = Modifier.padding(padding)) { + + LazyColumn(modifier = Modifier.fillMaxWidth()) { + item { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.Center + ) { + + Text( + "Preencha os campos abaixo para criar sua conta no DevHub " + + "\nO seu nome de usuario deve ser o mesmo do seu usuario do GitHub", + modifier = Modifier.padding(8.dp), + textAlign = TextAlign.Center, + fontSize = 14.sp + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Nome Completo", + color = Color.Black, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + fontSize = 14.sp + ) + + GenericOutlinedTextField( + value = name, + onValueChange = { name = it }, + label = "Nome Completo", + leadingIcon = Icons.Default.Person, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Nome de Usuario", + color = Color.Black, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + fontSize = 14.sp + ) + + GenericOutlinedTextField( + value = userName, + onValueChange = { userName = it }, + label = "Nome de Usuario", + leadingIcon = Icons.Default.Person, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "E-mail", + color = Color.Black, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + fontSize = 14.sp + ) + + EmailTextField( + value = email, + onValueChange = { email = it }, + imeAction = ImeAction.Next, + //isError = uiState.emailError != null, + //errorMessage = uiState.emailError ?: "" + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Senha", + color = Color.Black, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + fontSize = 14.sp + ) + + PasswordTextField( + value = password, + onValueChange = { password = it }, + imeAction = ImeAction.Next, + isPasswordVisible = passwordVisible, + onVisibilityChange = { passwordVisible = !passwordVisible }, + //isError = uiState.passwordError != null, + //errorMessage = uiState.passwordError ?: "" + ) + + Spacer(modifier = Modifier.height(16.dp)) + + + Text( + text = "Confirmar Senha", + color = Color.Black, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + fontSize = 14.sp + ) + + PasswordTextField( + value = confirmPassword, + onValueChange = { confirmPassword = it }, + imeAction = ImeAction.Done, + isPasswordVisible = passwordConfirmVisible, + onVisibilityChange = { + passwordConfirmVisible = !passwordConfirmVisible + }, + label = "Confirmar Senha" + //isError = uiState.passwordError != null, + //errorMessage = uiState.passwordError ?: "" + ) + + Spacer(modifier = Modifier.height(16.dp)) + + PrimaryButton( + text = "Cadastrar", + onClick = { + viewModel.signUp( + name = name, + username = userName, + email = email, + password = password + ) + }, + enabled = email.isNotBlank() && password.isNotBlank() && password == confirmPassword + ) + } + } + } + } + } +} + +@Preview +@Composable +fun RegisterScreenPreview() { + RegisterScreen(rememberNavController(), koinViewModel()) +} \ No newline at end of file diff --git a/app/src/main/java/com/delecrode/devhub/ui/register/RegisterState.kt b/app/src/main/java/com/delecrode/devhub/ui/register/RegisterState.kt new file mode 100644 index 0000000..c241785 --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/ui/register/RegisterState.kt @@ -0,0 +1,7 @@ +package com.delecrode.devhub.ui.register + +data class RegisterState( + val isLoading: Boolean = false, + val isSuccess: Boolean = false, + val error: String? = null, +) diff --git a/app/src/main/java/com/delecrode/devhub/ui/register/RegisterViewModel.kt b/app/src/main/java/com/delecrode/devhub/ui/register/RegisterViewModel.kt new file mode 100644 index 0000000..2d696c2 --- /dev/null +++ b/app/src/main/java/com/delecrode/devhub/ui/register/RegisterViewModel.kt @@ -0,0 +1,40 @@ +package com.delecrode.devhub.ui.register + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.delecrode.devhub.domain.repository.AuthRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class RegisterViewModel( + private val repository: AuthRepository +) : ViewModel() { + + private val _state = MutableStateFlow(RegisterState()) + val state = _state.asStateFlow() + + fun signUp(name: String, username: String, email: String, password: String) { + _state.value = RegisterState(isLoading = true) + viewModelScope.launch { + try{ + val response = repository.signUp(name, username, email, password) + _state.value = RegisterState( + isSuccess = response, + isLoading = false + ) + }catch (e: Exception){ + _state.value = RegisterState( + error = e.message, + isLoading = false + ) + } + } + + } + + fun clearState() { + _state.value = RegisterState() + + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_visibility_off_24.xml b/app/src/main/res/drawable/ic_visibility_off_24.xml new file mode 100644 index 0000000..5993ca3 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_off_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_visibility_on_24.xml b/app/src/main/res/drawable/ic_visibility_on_24.xml new file mode 100644 index 0000000..f843e29 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_on_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/build.gradle.kts b/build.gradle.kts index 952b930..f7b5371 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + id("com.google.gms.google-services") version "4.4.4" apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cafe560..dc03f11 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ activityCompose = "1.12.0" composeBom = "2024.09.00" navigationCompose = "2.9.6" coilCompose = "2.7.0" +dataStore = "1.2.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -28,6 +29,7 @@ androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-te androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" } +data-store = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "dataStore"} [plugins] android-application = { id = "com.android.application", version.ref = "agp" }