diff --git a/app/src/main/res/drawable/launch_transparent.xml b/app/src/main/res/drawable/launch_transparent.xml new file mode 100644 index 00000000..eadde48f --- /dev/null +++ b/app/src/main/res/drawable/launch_transparent.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index a3f4f56b..e36ebade 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -5,5 +5,8 @@ #0f1011 false #0f1011 + true + + \ No newline at end of file diff --git a/data/src/main/java/com/kusitms/data/local/AuthDataStore.kt b/data/src/main/java/com/kusitms/data/local/AuthDataStore.kt index 3f101fb7..52a866cb 100644 --- a/data/src/main/java/com/kusitms/data/local/AuthDataStore.kt +++ b/data/src/main/java/com/kusitms/data/local/AuthDataStore.kt @@ -1,42 +1,79 @@ package com.kusitms.data.local +import android.content.Context import android.util.Log +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.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore import com.google.gson.Gson import com.kusitms.domain.model.login.LoginMemberProfile +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton -@Singleton -class AuthDataStore { - private val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN" - private val KEY_REFRESH_TOKEN = "KEY_REFRESH_TOKEN" - private val KEY_LOGIN_MEMBER_PROFILE = "KEY_LOGIN_MEMBER_PROFILE" +class AuthDataStore @Inject constructor(private val context: Context) { + private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + companion object { + val AUTH_TOKEN_KEY = stringPreferencesKey("auth_token") + val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token") + val IS_LOGIN = booleanPreferencesKey("is_login") + val KEY_LOGIN_MEMBER_PROFILE = stringPreferencesKey("login_member_profile") + } - var authToken: String - get() = DataStoreUtils.getSyncData(KEY_AUTH_TOKEN, "") - set(value) { - DataStoreUtils.saveSyncStringData(KEY_AUTH_TOKEN, value) + suspend fun saveAuthToken(token: String) { + context.dataStore.edit { preferences -> + preferences[AUTH_TOKEN_KEY] = token } + } - var refreshToken: String - get() = DataStoreUtils.getSyncData(KEY_REFRESH_TOKEN, "") - set(value) { - DataStoreUtils.saveSyncStringData(KEY_REFRESH_TOKEN, value) + suspend fun saveLoginMemberProfile(profile: LoginMemberProfile?) { + val json = Gson().toJson(profile) + context.dataStore.edit { preferences -> + preferences[KEY_LOGIN_MEMBER_PROFILE] = json } + } - var loginMemberProfile: LoginMemberProfile? - get() { - val json = DataStoreUtils.getSyncData(KEY_LOGIN_MEMBER_PROFILE, "") - return if (json.isNotEmpty()) { - Gson().fromJson(json, LoginMemberProfile::class.java) - } else { - null - } + val loginMemberProfile: Flow = context.dataStore.data + .map { preferences -> + val json = preferences[KEY_LOGIN_MEMBER_PROFILE] ?: return@map null + return@map Gson().fromJson(json, LoginMemberProfile::class.java) } - set(value) { - val json = Gson().toJson(value) - DataStoreUtils.saveSyncStringData(KEY_LOGIN_MEMBER_PROFILE, json) - Log.d("AuthDataStore_loginMemberProfile", "Saving LoginMemberProfile: $json") + + suspend fun updateLogin(isLogin: Boolean) { + context.dataStore.edit { preferences -> + preferences[IS_LOGIN] = isLogin + } + } + + suspend fun saveRefreshToken(token: String) { + context.dataStore.edit { preferences -> + preferences[REFRESH_TOKEN_KEY] = token + } + } + + suspend fun clearAllData() { + context.dataStore.edit { preferences -> + preferences.clear() + } + } + + val authToken: Flow = context.dataStore.data + .map { preferences -> + preferences[AUTH_TOKEN_KEY] + } + + val refreshToken: Flow = context.dataStore.data + .map { preferences -> + preferences[REFRESH_TOKEN_KEY] + } + + val isLogin: Flow = context.dataStore.data + .map { preferences -> + preferences[IS_LOGIN] } -} \ No newline at end of file +} diff --git a/data/src/main/java/com/kusitms/data/remote/api/KusitmsTokenApi.kt b/data/src/main/java/com/kusitms/data/remote/api/KusitmsTokenApi.kt index 4d3829e8..b18fb807 100644 --- a/data/src/main/java/com/kusitms/data/remote/api/KusitmsTokenApi.kt +++ b/data/src/main/java/com/kusitms/data/remote/api/KusitmsTokenApi.kt @@ -1,9 +1,14 @@ package com.kusitms.data.remote.api +import com.kusitms.data.remote.entity.BaseResponse import com.kusitms.data.remote.entity.response.LoginResponse +import com.kusitms.domain.model.login.TokenModel +import retrofit2.Call import retrofit2.http.GET +import retrofit2.http.Header interface KusitmsTokenApi { @GET("v1/auth/reissue") - suspend fun RefreshAccessToken() : LoginResponse + suspend fun RefreshAccessToken(@Header("RefreshToken") refreshToken: String) : LoginResponse + } \ No newline at end of file diff --git a/data/src/main/java/com/kusitms/data/remote/di/AuthAuthenticator.kt b/data/src/main/java/com/kusitms/data/remote/di/AuthAuthenticator.kt new file mode 100644 index 00000000..e2eb9635 --- /dev/null +++ b/data/src/main/java/com/kusitms/data/remote/di/AuthAuthenticator.kt @@ -0,0 +1,33 @@ +package com.kusitms.data.remote.di + +import com.kusitms.data.local.AuthDataStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import javax.inject.Inject + +class AuthAuthenticator@Inject constructor( + private val authDataStore: AuthDataStore +):Authenticator { + override fun authenticate(route: Route?, response: Response): Request? { + val refreshToken = runBlocking { + authDataStore.refreshToken.first() + } + + if (refreshToken == null || refreshToken == "LOGIN") { + response.close() + return null + } + return newRequestWithToken(refreshToken, response.request) + } + + private fun newRequestWithToken(token: String, request: Request): Request = + request.newBuilder() + .header("Authorization", token) + .build() + + +} \ No newline at end of file diff --git a/data/src/main/java/com/kusitms/data/remote/di/AuthTokenInterceptor.kt b/data/src/main/java/com/kusitms/data/remote/di/AuthTokenInterceptor.kt index cd021c33..5a668287 100644 --- a/data/src/main/java/com/kusitms/data/remote/di/AuthTokenInterceptor.kt +++ b/data/src/main/java/com/kusitms/data/remote/di/AuthTokenInterceptor.kt @@ -1,51 +1,68 @@ package com.kusitms.data.remote.di + import android.util.Log import com.kusitms.data.local.AuthDataStore +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import okhttp3.Interceptor -import okhttp3.Response +import okhttp3.* +import timber.log.Timber import javax.inject.Inject class AuthTokenInterceptor @Inject constructor( private val authDataStore: AuthDataStore, - private val tokenManager: TokenManager ) : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() val requestBuilder = originalRequest.newBuilder() + val token: String? = runBlocking { + authDataStore.authToken.first() + } ?: "" + // "auth/logout" 엔드포인트 확인 if (originalRequest.url.encodedPath.endsWith("auth/logout")) { // logout 엔드포인트의 경우 refreshToken 사용 requestBuilder.addHeader("Authorization", "${authDataStore.refreshToken}") - } else { + } else if(originalRequest.url.encodedPath.endsWith("auth/logout")) + else { // 다른 엔드포인트의 경우 authToken 사용 - requestBuilder.addHeader("Authorization", "${authDataStore.authToken}") + requestBuilder.addHeader("Authorization", "$token") } + Log.d("Token is","${authDataStore.authToken}") + Log.d("Token is","${authDataStore.refreshToken}") var request = requestBuilder.build() var response = chain.proceed(request) - // 토큰 만료 감지 및 처리 - if (response.code == 500 || response.code == 401) { - // 토큰 매니저 인스턴스를 가져오고 토큰 갱신을 시도 - runBlocking { - launch(Dispatchers.IO) { - tokenManager.refreshAccessToken() - }.join() // 갱신 완료 대기 + if (response.code == 200) { + val newAccessToken: String = response.header("Authorization", null) ?: return response + Timber.d("new Access Token = ${newAccessToken}") + + CoroutineScope(Dispatchers.IO).launch { + val existedAccessToken = authDataStore.authToken.first() + if (existedAccessToken != newAccessToken) { + authDataStore.saveAuthToken(newAccessToken) + Timber.d("newAccessToken = ${newAccessToken}\nExistedAccessToken = ${existedAccessToken}") + } } - // 갱신된 토큰으로 요청 재시도 - request = chain.request().newBuilder() - .addHeader("Authorization", "${authDataStore.authToken}") - .build() - response.close() // 이전 응답 닫기 - return chain.proceed(request) + } else { + Timber.e("${response.code} : ${response.request} \n ${response.message}") } + return response } + + private fun errorResponse(request: Request): Response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_2) + .message("") + .code(401) + .body(ResponseBody.create(null, "")) + .build() } + diff --git a/data/src/main/java/com/kusitms/data/remote/di/NetworkModule.kt b/data/src/main/java/com/kusitms/data/remote/di/NetworkModule.kt index 90b467ef..6472e86f 100644 --- a/data/src/main/java/com/kusitms/data/remote/di/NetworkModule.kt +++ b/data/src/main/java/com/kusitms/data/remote/di/NetworkModule.kt @@ -1,6 +1,7 @@ package com.kusitms.data.remote.di +import android.content.Context import com.kusitms.data.BuildConfig import com.kusitms.data.local.AuthDataStore import com.kusitms.data.remote.api.KusitmsApi @@ -9,6 +10,7 @@ import com.kusitms.data.remote.util.NullOnEmptyConverterFactory import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -54,19 +56,11 @@ class NetworkModule { .create(KusitmsTokenApi::class.java) } - @Provides - @Singleton - fun provideAuthDataStore(): AuthDataStore { - return AuthDataStore() - } @Provides @Singleton - fun provideTokenManager( - kusitmsTokenApi: KusitmsTokenApi, - authDataStore: AuthDataStore - ): TokenManager { - return TokenManager(kusitmsTokenApi, authDataStore) + fun provideAuthDataStore(@ApplicationContext context: Context): AuthDataStore { + return AuthDataStore(context) } @@ -74,9 +68,8 @@ class NetworkModule { @Singleton fun provideAuthTokenInterceptor( authDataStore: AuthDataStore, - tokenManager: TokenManager ): AuthTokenInterceptor { - return AuthTokenInterceptor(authDataStore,tokenManager) + return AuthTokenInterceptor(authDataStore) } diff --git a/data/src/main/java/com/kusitms/data/remote/di/TokenManager.kt b/data/src/main/java/com/kusitms/data/remote/di/TokenManager.kt deleted file mode 100644 index 7ef1336b..00000000 --- a/data/src/main/java/com/kusitms/data/remote/di/TokenManager.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.kusitms.data.remote.di - -import com.kusitms.data.local.AuthDataStore -import com.kusitms.data.remote.api.KusitmsTokenApi - -class TokenManager( - private val kusitmsApi: KusitmsTokenApi, - private val authDataStore: AuthDataStore -) { - suspend fun refreshAccessToken(): Boolean { - return try { - val response = kusitmsApi.RefreshAccessToken() - if (response.result.code == 200 && response.payload != null) { - authDataStore.authToken = response.payload.accessToken - authDataStore.refreshToken = response.payload.refreshToken - true - } else { - false - } - - } catch (e: Exception) { - false - } - } -} diff --git a/data/src/main/java/com/kusitms/data/repository/AuthRepositoryImpl.kt b/data/src/main/java/com/kusitms/data/repository/AuthRepositoryImpl.kt index 3cf6a8a2..561afe2e 100644 --- a/data/src/main/java/com/kusitms/data/repository/AuthRepositoryImpl.kt +++ b/data/src/main/java/com/kusitms/data/repository/AuthRepositoryImpl.kt @@ -5,12 +5,14 @@ import android.net.ConnectivityManager import com.kusitms.data.local.AuthDataStore import com.kusitms.data.local.DataStoreUtils import com.kusitms.data.remote.api.KusitmsApi +import com.kusitms.data.remote.api.KusitmsTokenApi import com.kusitms.domain.model.login.LoginMemberProfile import com.kusitms.domain.model.login.TokenModel import com.kusitms.domain.repository.AuthRepository import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import javax.inject.Inject @@ -22,7 +24,17 @@ class AuthRepositoryImpl @Inject constructor( ): AuthRepository { override suspend fun getLoginMemberProfile(): Result { return try { - Result.success(authDataStore.loginMemberProfile) + val profile = authDataStore.loginMemberProfile.first() + Result.success(profile) + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun getIsLogin() :Result { + return try { + val login = authDataStore.isLogin.first() + Result.success(login) } catch (e: Exception) { Result.failure(e) } @@ -32,7 +44,7 @@ class AuthRepositoryImpl @Inject constructor( return try { val response = kusitmsApi.logOutMember() if (response.result.code == 200) { - DataStoreUtils.clear() + authDataStore.clearAllData() Result.success(Unit) } else { Result.failure(RuntimeException("올바른 데이터를 받지 못했습니다.")) @@ -46,7 +58,7 @@ class AuthRepositoryImpl @Inject constructor( return try { val response = kusitmsApi.signOutMember() if (response.result.code == 200) { - DataStoreUtils.clear() + authDataStore.clearAllData() Result.success(Unit) } else { Result.failure(RuntimeException("올바른 데이터를 받지 못했습니다.")) @@ -58,8 +70,8 @@ class AuthRepositoryImpl @Inject constructor( override suspend fun getAuthToken(): Result { return try { - val authToken = authDataStore.authToken - val refreshToken = authDataStore.refreshToken + val authToken = authDataStore.authToken.first() + val refreshToken = authDataStore.refreshToken.first() if (!authToken.isNullOrEmpty() && !refreshToken.isNullOrEmpty()) { Result.success(TokenModel(authToken, refreshToken)) } else { @@ -75,4 +87,5 @@ class AuthRepositoryImpl @Inject constructor( val activeNetwork = connectivity.activeNetworkInfo return activeNetwork != null && activeNetwork.isConnected } + } diff --git a/data/src/main/java/com/kusitms/data/repository/LoginRepositoryImpl.kt b/data/src/main/java/com/kusitms/data/repository/LoginRepositoryImpl.kt index 52b3110f..db39cf49 100644 --- a/data/src/main/java/com/kusitms/data/repository/LoginRepositoryImpl.kt +++ b/data/src/main/java/com/kusitms/data/repository/LoginRepositoryImpl.kt @@ -11,7 +11,8 @@ import javax.inject.Inject class LoginRepositoryImpl @Inject constructor( - private val kusitmsApi: KusitmsApi + private val kusitmsApi: KusitmsApi, + private val authDataStore: AuthDataStore ): LoginRepository { override suspend fun LoginMember( email: String, @@ -23,8 +24,11 @@ class LoginRepositoryImpl @Inject constructor( val response = kusitmsApi.loginMember(request) if (response.result.code == 200 && response.payload != null) { Log.d("auth", response.payload.accessToken.toString()) - AuthDataStore().authToken = response.payload.accessToken - AuthDataStore().refreshToken = response.payload.refreshToken + + authDataStore.saveAuthToken(response.payload.accessToken) + authDataStore.saveRefreshToken(response.payload.refreshToken) + authDataStore.updateLogin(true) + Result.success(Unit) } else { Result.failure(RuntimeException("로그인 실패: ${response.result.message}")) diff --git a/data/src/main/java/com/kusitms/data/repository/SignInRepositoryImpl.kt b/data/src/main/java/com/kusitms/data/repository/SignInRepositoryImpl.kt index 47bb5542..f46c4581 100644 --- a/data/src/main/java/com/kusitms/data/repository/SignInRepositoryImpl.kt +++ b/data/src/main/java/com/kusitms/data/repository/SignInRepositoryImpl.kt @@ -21,7 +21,8 @@ import java.io.File import javax.inject.Inject class SignInRepositoryImpl @Inject constructor( - private val kusitmsApi: KusitmsApi + private val kusitmsApi: KusitmsApi, + private val authDataStore: AuthDataStore ): SignInRepository { override suspend fun getLoginMemberProfile(): Result { return try { @@ -36,7 +37,7 @@ class SignInRepositoryImpl @Inject constructor( phoneNumber = response.payload.phoneNumber, memberDetailExist = response.payload.memberDetailExist ) - AuthDataStore().loginMemberProfile = profile + authDataStore.saveLoginMemberProfile(profile) Result.success(profile) } } catch (e: Exception) { diff --git a/domain/src/main/java/com/kusitms/domain/repository/AuthRepository.kt b/domain/src/main/java/com/kusitms/domain/repository/AuthRepository.kt index df8cff85..07c22c2e 100644 --- a/domain/src/main/java/com/kusitms/domain/repository/AuthRepository.kt +++ b/domain/src/main/java/com/kusitms/domain/repository/AuthRepository.kt @@ -15,4 +15,6 @@ interface AuthRepository { suspend fun getAuthToken() :Result fun checkInternetConnection(): Boolean + + suspend fun getIsLogin(): Result } \ No newline at end of file diff --git a/domain/src/main/java/com/kusitms/domain/usecase/signin/GetIsLoginUseCase.kt b/domain/src/main/java/com/kusitms/domain/usecase/signin/GetIsLoginUseCase.kt new file mode 100644 index 00000000..ee83b185 --- /dev/null +++ b/domain/src/main/java/com/kusitms/domain/usecase/signin/GetIsLoginUseCase.kt @@ -0,0 +1,13 @@ +package com.kusitms.domain.usecase.signin + +import com.kusitms.domain.model.login.TokenModel +import com.kusitms.domain.repository.AuthRepository +import javax.inject.Inject + +class GetIsLoginUseCase @Inject constructor( + private val authRepository: AuthRepository +) { + suspend operator fun invoke(): Result { + return authRepository.getIsLogin() + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d97be99..e2e8f361 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,9 @@ hilt-navigation= "1.0.0" # compose material3 material3="1.1.1" +#zxing +zxing = "4.2.0" + # navigation navigation-compose ="2.7.0-alpha01" @@ -70,6 +73,9 @@ test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "a espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } junit = { group = "junit", name = "junit", version.ref = "junit" } +#zxing +zxing = {group = "com.journeyapps", name="zxing-android-embedded", version.ref="zxing"} + # timber timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } @@ -104,6 +110,7 @@ lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-co lifecycle-viewmodel= { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } # dataStore dataStore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "dataStore" } +dataStore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "dataStore" } # hilt hilt-compile = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt= { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } @@ -155,7 +162,8 @@ camerax=[ "camera2", "camera2-lifecycle", "camera2-view", - "mlkit" + "mlkit", + "zxing" ] lifecycle = [ @@ -175,7 +183,7 @@ network = [ "okhttp3-logging-interceptor" ] -dataStore = ["dataStore"] +dataStore = ["dataStore", "dataStore-core"] retrofitSerialization = [ "kotlinx-serialization-json", diff --git a/presentation/src/main/java/com/kusitms/presentation/model/home/attend/AttendViewModel.kt b/presentation/src/main/java/com/kusitms/presentation/model/home/attend/AttendViewModel.kt index 0fda1cf7..3eaa5ba0 100644 --- a/presentation/src/main/java/com/kusitms/presentation/model/home/attend/AttendViewModel.kt +++ b/presentation/src/main/java/com/kusitms/presentation/model/home/attend/AttendViewModel.kt @@ -7,12 +7,15 @@ import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bumptech.glide.Glide.init +import com.kusitms.domain.model.home.AttendCheckModel import com.kusitms.domain.model.home.AttendCurrentModel import com.kusitms.domain.model.home.AttendInfoModel import com.kusitms.domain.model.home.AttendModel import com.kusitms.domain.usecase.home.* +import com.kusitms.domain.usecase.signin.GetIsLoginUseCase import com.kusitms.presentation.R import com.kusitms.presentation.common.ui.theme.KusitmsColorPalette +import com.kusitms.presentation.ui.notice.detail.NoticeDetailViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -30,11 +33,12 @@ import javax.inject.Inject @HiltViewModel class AttendViewModel @Inject constructor( - getAttendCurrentListUseCase: GetAttendCurrentListUseCase, + private val getAttendCurrentListUseCase: GetAttendCurrentListUseCase, getAttendInfoUseCase: GetAttendInfoUseCase, getAttendScoreUseCase: GetAttendScoreUseCase, - postAttendCheckUseCase: PostAttendCheckUseCase, - getAttendQrUseCase: GetAttendQrUseCase + getAttendQrUseCase: GetAttendQrUseCase, + private val getIsLoginUseCase: GetIsLoginUseCase, + private val PostAttendCheckUseCase: PostAttendCheckUseCase ):ViewModel() { val attendListInit: StateFlow> = getAttendCurrentListUseCase() @@ -61,6 +65,18 @@ class AttendViewModel @Inject constructor( initialValue = AttendInfoModel(0, "", false, "", "") ) + private val _attendCheckModel = MutableStateFlow( + AttendCheckModel(curriculumId = upcomingAttend.value.curriculumId, text = "") + ) + val attendCheckModel = _attendCheckModel.asStateFlow() + + fun updateScannedQrCode(qrText: String) { + _attendCheckModel.value = _attendCheckModel.value.copy(text = qrText) + } + + private val _snackbarEvent = MutableSharedFlow() + val snackbarEvent : SharedFlow = _snackbarEvent.asSharedFlow() + val attendScore = getAttendScoreUseCase().catch { }.stateIn( viewModelScope, @@ -80,6 +96,21 @@ class AttendViewModel @Inject constructor( } } + fun postAttendCheck() { + viewModelScope.launch { + val model = attendCheckModel + PostAttendCheckUseCase(curriculumId = model.value.curriculumId, qrText = model.value.text). + catch { + Log.d("출석 확인", "출석 실패") + _snackbarEvent.emit(AttendSnackBarEvent.Attend_fail) + } + .collectLatest { + Log.d("출석 확인", "출석 성공") + _snackbarEvent.emit(AttendSnackBarEvent.Attend_success) + } + } + } + fun formatTime(timeString: String): String { val inputFormat = SimpleDateFormat("a hh:mm", Locale.KOREAN) val outputFormat = SimpleDateFormat("a h:mm", Locale.KOREAN) @@ -129,4 +160,8 @@ class AttendViewModel @Inject constructor( } } } + + enum class AttendSnackBarEvent { + Attend_success, Attend_fail + } } \ No newline at end of file diff --git a/presentation/src/main/java/com/kusitms/presentation/model/signIn/SplashViewModel.kt b/presentation/src/main/java/com/kusitms/presentation/model/signIn/SplashViewModel.kt index 4b86d443..62b4601f 100644 --- a/presentation/src/main/java/com/kusitms/presentation/model/signIn/SplashViewModel.kt +++ b/presentation/src/main/java/com/kusitms/presentation/model/signIn/SplashViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kusitms.domain.usecase.home.GetNetworkStatusUseCase import com.kusitms.domain.usecase.signin.GetAuthTokenUseCase +import com.kusitms.domain.usecase.signin.GetIsLoginUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -18,7 +19,8 @@ enum class TokenStatus { VALID, INVALID, DEFAULT, SERVER } @HiltViewModel class SplashViewModel @Inject constructor( private val getAuthTokenUseCase: GetAuthTokenUseCase, - private val getNetworkStatusUseCase: GetNetworkStatusUseCase + private val getNetworkStatusUseCase: GetNetworkStatusUseCase, + private val getIsLoginUseCase: GetIsLoginUseCase ): ViewModel() { private val _tokenStatus = MutableStateFlow(TokenStatus.DEFAULT) @@ -27,6 +29,9 @@ class SplashViewModel @Inject constructor( private val _networkStatus = MutableStateFlow(true) val networkStatus : StateFlow = _networkStatus + private val _isLogin = MutableStateFlow(true) + val isLogin : StateFlow = _isLogin + val snackbarHostState = SnackbarHostState() fun showInvalidTokenMessage() { @@ -58,7 +63,8 @@ class SplashViewModel @Inject constructor( _networkStatus.value = getNetworkStatusUseCase() if(_networkStatus.value) { updateTokenStatus(TokenStatus.VALID) - } else { + } + else { updateTokenStatus(TokenStatus.SERVER) showInvalidTokenMessage() } @@ -71,6 +77,15 @@ class SplashViewModel @Inject constructor( updateTokenStatus(TokenStatus.INVALID) showInvalidTokenMessage() } + getIsLoginUseCase() + .onSuccess { + if (it != null) { + _isLogin.value = it + } + } + .onFailure { + _isLogin.value = false + } } } } \ No newline at end of file diff --git a/presentation/src/main/java/com/kusitms/presentation/navigation/NavGraph.kt b/presentation/src/main/java/com/kusitms/presentation/navigation/NavGraph.kt index 84e61eaa..43a66677 100644 --- a/presentation/src/main/java/com/kusitms/presentation/navigation/NavGraph.kt +++ b/presentation/src/main/java/com/kusitms/presentation/navigation/NavGraph.kt @@ -48,6 +48,7 @@ import com.kusitms.presentation.model.signIn.SplashViewModel import com.kusitms.presentation.ui.home.HomeScreen import com.kusitms.presentation.ui.home.attend.AttendScreen import com.kusitms.presentation.ui.home.attend.camera.CameraPreview +import com.kusitms.presentation.ui.home.attend.camera.CameraScreen import com.kusitms.presentation.ui.home.profile.MyProfileScreen import com.kusitms.presentation.ui.home.team.HomeTeamDetailScreen import com.kusitms.presentation.ui.login.LoginScreen @@ -167,6 +168,14 @@ fun MainNavigation() { navController ) } + + kusitmsComposableWithAnimation(NavRoutes.CameraPreview.route) { + CameraScreen( + attendViewModel, + navController + ) + } + kusitmsComposableWithAnimation(NavRoutes.SignInProfileComplete.route) { SignInProfileComplete( signInViewModel, @@ -246,10 +255,6 @@ fun MainNavigation() { } } - kusitmsComposableWithAnimation(NavRoutes.CameraPreview.route) { - CameraPreview() - } - //HomeScreen kusitmsComposableWithAnimation(NavRoutes.HomeScreen.route) { HomeScreen( diff --git a/presentation/src/main/java/com/kusitms/presentation/ui/ImageVector/CameraLayer.kt b/presentation/src/main/java/com/kusitms/presentation/ui/ImageVector/CameraLayer.kt new file mode 100644 index 00000000..932e39b6 --- /dev/null +++ b/presentation/src/main/java/com/kusitms/presentation/ui/ImageVector/CameraLayer.kt @@ -0,0 +1,3 @@ +package com.kusitms.presentation.ui.ImageVector + +object CameraLayer diff --git a/presentation/src/main/java/com/kusitms/presentation/ui/ImageVector/LeftArrow.kt b/presentation/src/main/java/com/kusitms/presentation/ui/ImageVector/LeftArrow.kt index 8fc2f056..5909faf7 100644 --- a/presentation/src/main/java/com/kusitms/presentation/ui/ImageVector/LeftArrow.kt +++ b/presentation/src/main/java/com/kusitms/presentation/ui/ImageVector/LeftArrow.kt @@ -58,9 +58,3 @@ object LeftArrow { alignment = Alignment.Center) } } - -@Preview(showBackground = true) -@Composable -fun previewArrow() { - LeftArrow.DrawRightArrow() -} \ No newline at end of file diff --git a/presentation/src/main/java/com/kusitms/presentation/ui/home/attend/camera/CameraPreview.kt b/presentation/src/main/java/com/kusitms/presentation/ui/home/attend/camera/CameraPreview.kt index fbf50854..e7cf49aa 100644 --- a/presentation/src/main/java/com/kusitms/presentation/ui/home/attend/camera/CameraPreview.kt +++ b/presentation/src/main/java/com/kusitms/presentation/ui/home/attend/camera/CameraPreview.kt @@ -1,130 +1,273 @@ package com.kusitms.presentation.ui.home.attend.camera +import android.Manifest +import android.content.pm.PackageManager import android.util.Log import android.view.Surface import android.view.ViewGroup +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.Button +import androidx.compose.material.Text import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.BlendMode.Companion.Overlay -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat +import androidx.navigation.NavController import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.common.InputImage +import com.google.zxing.* +import com.google.zxing.common.HybridBinarizer import com.kusitms.presentation.common.ui.theme.KusitmsColorPalette +import com.kusitms.presentation.model.home.attend.AttendViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch -@androidx.compose.ui.tooling.preview.Preview @Composable -fun CameraPreviewWithOverlay() { - Box { - CameraPreview() - Overlay() +fun CameraScreen( + viewModel: AttendViewModel, + navController: NavController +) { + ComposablePermission( + permission = Manifest.permission.CAMERA, + onDenied = { requester -> + Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = "카메라 권한이 필요합니다.") + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = requester) { + Text(text = "권한 요청") + } + } + }, + onGranted = { + val onQrCodeScanned: (String) -> Unit = { qrText -> + viewModel.updateScannedQrCode(qrText) + viewModel.postAttendCheck() + } + CameraPreview(onQrCodeScanned = onQrCodeScanned) + } + ) +} + +@Composable +fun CameraOverlay() { + Canvas(modifier = Modifier + .fillMaxSize() + .graphicsLayer(alpha = 0.99f) + ) { + val rectSize = Size(size.width * 0.568f, size.width * 0.568f) + + drawPath( + createCornersPath( + left = (size.width - rectSize.width) / 2f, + top = (size.height - rectSize.width) / 2f, + right = (size.width - rectSize.width) / 2f + rectSize.height, + bottom = (size.height - rectSize.width) / 2f + rectSize.width, + cornerRadius = 40f, + cornerLength = 40f + ), + color = Color.White, + style = Stroke(width = 10f) + ) } } @Composable -fun Overlay() { - Surface { - Box( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - Canvas( - modifier = Modifier - .align(Alignment.Center) - .size(200.dp) - ) { - val strokeWidth = 4.dp.toPx() - val inset = strokeWidth / 2 - drawRect( - color = Color.Black, - topLeft = this.center.copy(x = inset, y = inset), - size = Size(size.width - strokeWidth, size.height - strokeWidth), - ) - drawRect( - color = Color.White, - size = this.size, - style = Stroke(width = strokeWidth) - ) +fun ComposablePermission( + permission: String, + onDenied: @Composable (requester: () -> Unit) -> Unit, + onGranted: @Composable () -> Unit +) { + val ctx = LocalContext.current + + // check initial state of permission, it may be already granted + var grantState by remember { + mutableStateOf( + ContextCompat.checkSelfPermission( + ctx, + permission + ) == PackageManager.PERMISSION_GRANTED + ) + } + if (grantState) { + onGranted() + } else { + val launcher: ManagedActivityResultLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { + grantState = it } - } + onDenied { launcher.launch(permission) } } } + +private fun createCornersPath( + left: Float, + top: Float, + right: Float, + bottom: Float, + cornerRadius: Float, + cornerLength: Float +): Path { + val path = Path() + + // top left + path.moveTo(left, (top + cornerRadius)) + path.arcTo( + Rect(left = left, top = top, right = left + cornerRadius, bottom = top + cornerRadius), + 180f, + 90f, + true + ) + + path.moveTo(left + (cornerRadius / 2f), top) + path.lineTo(left + (cornerRadius / 2f) + cornerLength, top) + + path.moveTo(left, top + (cornerRadius / 2f)) + path.lineTo(left, top + (cornerRadius / 2f) + cornerLength) + + // top right + path.moveTo(right - cornerRadius, top) + path.arcTo( + Rect(left = right - cornerRadius, top = top, right = right, bottom = top + cornerRadius), + 270f, + 90f, + true + ) + + path.moveTo(right - (cornerRadius / 2f), top) + path.lineTo(right - (cornerRadius / 2f) - cornerLength, top) + + path.moveTo(right, top + (cornerRadius / 2f)) + path.lineTo(right, top + (cornerRadius / 2f) + cornerLength) + + // bottom left + path.moveTo(left, bottom - cornerRadius) + path.arcTo( + Rect(left = left, top = bottom - cornerRadius, right = left+cornerRadius, bottom = bottom), + 90f, + 90f, + true + ) + + path.moveTo(left + (cornerRadius / 2f), bottom) + path.lineTo(left + (cornerRadius / 2f) + cornerLength, bottom) + + path.moveTo(left, bottom - (cornerRadius / 2f)) + path.lineTo(left, bottom - (cornerRadius / 2f) - cornerLength) + + // bottom right + path.moveTo(left, bottom - cornerRadius) + path.arcTo( + Rect(left = right - cornerRadius, top = bottom - cornerRadius, right = right, bottom = bottom), + 0f, + 90f, + true + ) + + path.moveTo(right - (cornerRadius / 2f), bottom) + path.lineTo(right - (cornerRadius / 2f) - cornerLength, bottom) + + path.moveTo(right, bottom - (cornerRadius / 2f)) + path.lineTo(right, bottom - (cornerRadius / 2f) - cornerLength) + + return path +} + @Composable @androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class) -fun CameraPreview() { - val context = LocalContext.current +fun CameraPreview(onQrCodeScanned: (String) -> Unit) { val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } + val previewView = remember { PreviewView(context) } + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } - AndroidView(factory = { context -> - PreviewView(context).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - implementationMode = PreviewView.ImplementationMode.COMPATIBLE - } - }, update = { previewView -> - val cameraProviderFuture = ProcessCameraProvider.getInstance(context) - cameraProviderFuture.addListener({ - val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() - val preview = Preview.Builder().build().also { - it.setSurfaceProvider(previewView.surfaceProvider) - } - try { - cameraProvider.unbindAll() - cameraProvider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview) - } catch (e: Exception) { - Log.e("CameraPreview", "Use case binding failed", e) - } - }, ContextCompat.getMainExecutor(context)) - }) - + // QR 코드 스캐너 설정 val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() .also { it.setAnalyzer(ContextCompat.getMainExecutor(context)) { imageProxy -> - val mediaImage = imageProxy.image - if (mediaImage != null) { - val inputImage = - InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) - // 바코드 스캐너 인스턴스 생성 - val scanner = BarcodeScanning.getClient() - scanner.process(inputImage) - .addOnSuccessListener { barcodes -> - for (barcode in barcodes) { - val rawValue = barcode.rawValue - // QR 코드 값을 사용 - } - } - .addOnFailureListener { - // 처리 실패 - } - .addOnCompleteListener { - imageProxy.close() + val image = imageProxy.image + if (image != null) { + val buffer = image.planes[0].buffer + val bytes = ByteArray(buffer.remaining()) + buffer.get(bytes) + val source = PlanarYUVLuminanceSource( + bytes, image.width, image.height, 0, 0, image.width, image.height, false + ) + val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) + try { + val reader = MultiFormatReader().apply { + val hints = mapOf( + DecodeHintType.POSSIBLE_FORMATS to listOf( + BarcodeFormat.QR_CODE + ) + ) + setHints(hints) } + val result = reader.decode(binaryBitmap) + val qrText = result.text + onQrCodeScanned(qrText) + } catch (e: NotFoundException) { + // QR 코드를 찾지 못함 + } finally { + imageProxy.close() + } } } } + // 카메라 시작 + LaunchedEffect(previewView) { + val cameraProvider = cameraProviderFuture.get() + cameraProvider.unbindAll() // 기존 바인딩 제거 + + try { + // 카메라와 분석기 바인딩 + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageAnalysis + ) + } catch (e: Exception) { + Log.e("CameraPreview", "카메라 시작 실패", e) + } + } + + // Compose에 카메라 프리뷰 표시 + AndroidView({ previewView }, modifier = Modifier.fillMaxSize()) + + // QR 코드 스캔 영역 표시 (CameraOverlay) + CameraOverlay() } + diff --git a/presentation/src/main/java/com/kusitms/presentation/ui/splash/SplashScreen.kt b/presentation/src/main/java/com/kusitms/presentation/ui/splash/SplashScreen.kt index d4bcc515..03d0f732 100644 --- a/presentation/src/main/java/com/kusitms/presentation/ui/splash/SplashScreen.kt +++ b/presentation/src/main/java/com/kusitms/presentation/ui/splash/SplashScreen.kt @@ -38,6 +38,7 @@ import kotlinx.coroutines.delay fun SplashScreen(viewModel: SplashViewModel, navController: NavController) { val tokenStatus by viewModel.tokenStatus.collectAsState() val snackbarHostState = remember { viewModel.snackbarHostState } + val isLogin by viewModel.isLogin.collectAsState() val snackbarMessage = when (tokenStatus) { TokenStatus.INVALID -> "토큰 상태가 유효하지 않습니다" @@ -63,6 +64,20 @@ fun SplashScreen(viewModel: SplashViewModel, navController: NavController) { else -> Unit } } + LaunchedEffect(isLogin) { + when (isLogin) { + isLogin -> { + + } + !isLogin -> { + delay(2000) + navController.navigate(NavRoutes.LogInScreen.route) { + popUpTo(NavRoutes.SplashScreen.route) { inclusive = true } + } + } + else -> Unit + } + } Scaffold( snackbarHost = { SnackbarHost( diff --git a/presentation/src/main/res/drawable/ic_camera_layer.xml b/presentation/src/main/res/drawable/ic_camera_layer.xml new file mode 100644 index 00000000..167d9a5d --- /dev/null +++ b/presentation/src/main/res/drawable/ic_camera_layer.xml @@ -0,0 +1,22 @@ + + + + + + + + + +