diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e3a7dc5..04c6e7a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,8 +18,8 @@ android { applicationId = "com.nativeapptemplate.nativeapptemplatefree" targetSdk = 35 minSdk = 26 - versionCode = 1 - versionName = "1.0.0" + versionCode = 2 + versionName = "2.0.0" vectorDrawables { useSupportLibrary = true @@ -140,10 +140,13 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.androidx.profileinstaller) implementation(libs.androidx.tracing.ktx) + implementation(libs.capturable) + implementation(libs.compose.qr.code) implementation(libs.hilt.android) implementation(libs.kotlin.stdlib.jdk8) implementation(libs.kotlinx.coroutines.guava) implementation(libs.kotlinx.serialization.json) + implementation(libs.lottie.compose) implementation(libs.okhttp) implementation(libs.okhttp.logging.interceptor) implementation(libs.retrofit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 010b4e7..880d741 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,9 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/item_tag.json b/app/src/main/assets/item_tag.json new file mode 100644 index 0000000..b717faa --- /dev/null +++ b/app/src/main/assets/item_tag.json @@ -0,0 +1,17 @@ +{ + "data": { + "id": "9712F2DF-DFC7-A3AA-66BC-191203654A1A", + "type": "item_tag", + "attributes": { + "shop_id": "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + "queue_number": "A001", + "state": "idled", + "scan_state": "unscanned", + "created_at": "2025-01-02T12:00:00.000Z", + "shop_name": "8th & Townsend", + "customer_read_at": "2025-01-02T12:00:01.000Z", + "completed_at": "2025-01-02T12:00:03.000Z", + "already_completed": false + } + } +} diff --git a/app/src/main/assets/item_tags.json b/app/src/main/assets/item_tags.json new file mode 100644 index 0000000..aab87cf --- /dev/null +++ b/app/src/main/assets/item_tags.json @@ -0,0 +1,49 @@ +{ + "data": [ + { + "id": "9712F2DF-DFC7-A3AA-66BC-191203654A1A", + "type": "item_tag", + "attributes": { + "shop_id": "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + "queue_number": "A001", + "state": "idled", + "scan_state": "unscanned", + "created_at": "2025-01-02T12:00:00.000Z", + "shop_name": "8th & Townsend", + "customer_read_at": "2025-01-02T12:00:01.000Z", + "completed_at": "2025-01-02T12:00:03.000Z", + "already_completed": false + } + }, + { + "id": "9712F2DF-DFC7-A3AA-66BC-191203654A1", + "type": "shop", + "attributes": { + "shop_id": "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + "queue_number": "A002", + "state": "idled", + "scan_state": "unscanned", + "created_at": "2025-01-02T12:00:00.000Z", + "shop_name": "8th & Townsend", + "customer_read_at": "2025-01-02T12:00:01.000Z", + "completed_at": "2025-01-02T12:00:03.000Z", + "already_completed": false + } + }, + { + "id": "9712F2DF-DFC7-A3AA-66BC-191203654A1C", + "type": "shop", + "attributes": { + "shop_id": "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + "queue_number": "A003", + "state": "idled", + "scan_state": "unscanned", + "created_at": "2025-01-02T12:00:00.000Z", + "shop_name": "8th & Townsend", + "customer_read_at": "2025-01-02T12:00:01.000Z", + "completed_at": "2025-01-02T12:00:03.000Z", + "already_completed": false + } + } + ] +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivity.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivity.kt index 798654a..3887629 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivity.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivity.kt @@ -1,6 +1,10 @@ package com.nativeapptemplate.nativeapptemplatefree +import android.content.Intent import android.graphics.Color +import android.nfc.NdefMessage +import android.nfc.NfcAdapter +import android.os.Build.VERSION.SDK_INT import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle @@ -22,13 +26,16 @@ import com.nativeapptemplate.nativeapptemplatefree.MainActivityUiState.Success import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository import com.nativeapptemplate.nativeapptemplatefree.designsystem.theme.NatTheme import com.nativeapptemplate.nativeapptemplatefree.model.DarkThemeConfig +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagInfoFromNdefMessage import com.nativeapptemplate.nativeapptemplatefree.ui.app_root.NatApp import com.nativeapptemplate.nativeapptemplatefree.ui.app_root.rememberNatAppState import com.nativeapptemplate.nativeapptemplatefree.utils.NetworkMonitor +import com.nativeapptemplate.nativeapptemplatefree.utils.Utility import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import java.util.Date import javax.inject.Inject @AndroidEntryPoint @@ -47,6 +54,16 @@ class MainActivity : ComponentActivity() { var uiState: MainActivityUiState by mutableStateOf(Loading) + viewModel.updateShouldNavigateToScanView(false) + viewModel.updateShouldFetchItemTagForShowTagInfoScan(false) + viewModel.updateShouldCompleteItemTagForCompleteScan(false) + viewModel.initScanViewSelectedTabIndex() + viewModel.initShowTagInfoScanResult() + viewModel.initCompleteScanResult() + +// viewModel.updateDidShowTapShopBelowTip(false) +// viewModel.updateDidShowReadInstructionsTip(false) + // Update the uiState lifecycleScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -103,12 +120,81 @@ class MainActivity : ComponentActivity() { NatApp(appState) } } + + val intent = intent + if (NfcAdapter.ACTION_NDEF_DISCOVERED == intent.action) { + viewModel.updateShouldNavigateToScanView(false) + + val ndefMessage: NdefMessage? + val rawMessages = if (SDK_INT >= 33) { // TIRAMISU + intent.getParcelableArrayExtra( + NfcAdapter.EXTRA_NDEF_MESSAGES, + NdefMessage::class.java + ) + }else{ + @Suppress("DEPRECATION") + intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) + } + + if (!rawMessages.isNullOrEmpty()) { + ndefMessage = rawMessages[0] as NdefMessage + + val itemTagInfoFromNdefMessage = Utility.extractItemTagInfoFrom( + context = this, + ndefMessage = ndefMessage + ) + + updateItemTagInfoFromNdefMessage(itemTagInfoFromNdefMessage) + viewModel.initScanViewSelectedTabIndex() + viewModel.updateShouldNavigateToScanView(true) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + if (NfcAdapter.ACTION_NDEF_DISCOVERED == intent.action) { + viewModel.updateShouldNavigateToScanView(false) + + val ndefMessage: NdefMessage? + val rawMessages = if (SDK_INT >= 33) { // TIRAMISU + intent.getParcelableArrayExtra( + NfcAdapter.EXTRA_NDEF_MESSAGES, + NdefMessage::class.java + ) + }else{ + @Suppress("DEPRECATION") + intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) + } + + if (!rawMessages.isNullOrEmpty()) { + ndefMessage = rawMessages[0] as NdefMessage + + val itemTagInfoFromNdefMessage = Utility.extractItemTagInfoFrom( + context = this, + ndefMessage = ndefMessage + ) + + updateItemTagInfoFromNdefMessage(itemTagInfoFromNdefMessage) + viewModel.initScanViewSelectedTabIndex() + viewModel.updateShouldNavigateToScanView(true) + } + } } override fun onResume() { super.onResume() viewModel.updatePermissions() } + + private fun updateItemTagInfoFromNdefMessage(itemTagInfoFromNdefMessage: ItemTagInfoFromNdefMessage) { + if (itemTagInfoFromNdefMessage.success) { + itemTagInfoFromNdefMessage.scannedAt = Date().toInstant().toString() + } + + viewModel.updateItemTagInfoFromNdefMessage(itemTagInfoFromNdefMessage) + viewModel.updateShouldCompleteItemTagForCompleteScan(itemTagInfoFromNdefMessage.success) + } } /** diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModel.kt index 98d8f12..3728b5e 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModel.kt @@ -6,7 +6,11 @@ import androidx.lifecycle.viewModelScope import com.nativeapptemplate.nativeapptemplatefree.MainActivityUiState.Loading import com.nativeapptemplate.nativeapptemplatefree.MainActivityUiState.Success import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository +import com.nativeapptemplate.nativeapptemplatefree.model.CompleteScanResult +import com.nativeapptemplate.nativeapptemplatefree.model.CompleteScanResultType +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagInfoFromNdefMessage import com.nativeapptemplate.nativeapptemplatefree.model.Permissions +import com.nativeapptemplate.nativeapptemplatefree.model.ShowTagInfoScanResult import com.nativeapptemplate.nativeapptemplatefree.model.UserData import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow @@ -32,6 +36,54 @@ class MainActivityViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(5_000), ) + fun updateShouldFetchItemTagForShowTagInfoScan(shouldFetchItemTagForShowTagInfoScan: Boolean) { + viewModelScope.launch { + loginRepository.setShouldFetchItemTagForShowTagInfoScan(shouldFetchItemTagForShowTagInfoScan) + } + } + + fun updateShouldCompleteItemTagForCompleteScan(shouldCompleteItemTagForCompleteScan: Boolean) { + viewModelScope.launch { + loginRepository.setShouldCompleteItemTagForCompleteScan(shouldCompleteItemTagForCompleteScan) + } + } + + fun updateShouldNavigateToScanView(shouldNavigateToScanView: Boolean) { + viewModelScope.launch { + loginRepository.setShouldNavigateToScanView(shouldNavigateToScanView) + } + } + + fun initScanViewSelectedTabIndex() { + viewModelScope.launch { + loginRepository.setScanViewSelectedTabIndex(0) + } + } + + fun initShowTagInfoScanResult() { + viewModelScope.launch { + loginRepository.setShowTagInfoScanResult(ShowTagInfoScanResult()) + } + } + + fun initCompleteScanResult() { + viewModelScope.launch { + loginRepository.setCompleteScanResult(CompleteScanResult()) + } + } + + fun updateDidShowTapShopBelowTip(didShowTapShopBelowTip: Boolean) { + viewModelScope.launch { + loginRepository.setDidShowTapShopBelowTip(didShowTapShopBelowTip) + } + } + + fun updateDidShowReadInstructionsTip(didShowReadInstructionsTip: Boolean) { + viewModelScope.launch { + loginRepository.setDidShowReadInstructionsTip(didShowReadInstructionsTip) + } + } + fun updatePermissions() { viewModelScope.launch { val isLoggedIn = loginRepository.isLoggedIn().first() @@ -58,6 +110,30 @@ class MainActivityViewModel @Inject constructor( } } + fun updateItemTagInfoFromNdefMessage( + itemTagInfoFromNdefMessage: ItemTagInfoFromNdefMessage, + ) { + viewModelScope.launch { + val completeScanResult = CompleteScanResult() + completeScanResult.itemTagInfoFromNdefMessage = itemTagInfoFromNdefMessage + + if (!completeScanResult.itemTagInfoFromNdefMessage.success) { + completeScanResult.message = itemTagInfoFromNdefMessage.message + completeScanResult.completeScanResultType = CompleteScanResultType.Failed + } + + try { + loginRepository.setCompleteScanResult(completeScanResult) + } catch (exception: Exception) { + val message = exception.message + completeScanResult.message = message ?: "Unknown Error" + completeScanResult.completeScanResultType = CompleteScanResultType.Failed + + loginRepository.setCompleteScanResult(completeScanResult) + } + } + } + fun isLoggedIn(): StateFlow = loginRepository .isLoggedIn() .stateIn( diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/NatConstants.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/NatConstants.kt index 6bf672e..bf27901 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/NatConstants.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/NatConstants.kt @@ -1,9 +1,13 @@ package com.nativeapptemplate.nativeapptemplatefree object NatConstants { + const val SCAN_PATH: String = "scan" + const val SCAN_PATH_CUSTOMER: String = "scan_customer" + const val SUPPORT_MAIL: String = "support@nativeapptemplate.com" const val HOW_TO_USE_URL: String = "https://myturntag.com/how" const val SUPPORT_WEBSITE_URL: String = "https://nativeapptemplate.com" + const val FAQS_URL: String = "https://nativeapptemplate.com/faqs" const val DISCUSSIONS_URL: String = "https://github.com/nativeapptemplate/NativeAppTemplate-Free-Android/discussions" const val PRIVACY_POLICY_URL: String = "https://nativeapptemplate.com/privacy" const val TERMS_OF_USE_URL: String = "https://nativeapptemplate.com/terms" diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagApi.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagApi.kt new file mode 100644 index 0000000..60c5f47 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagApi.kt @@ -0,0 +1,58 @@ +package com.nativeapptemplate.nativeapptemplatefree.data.item_tag + +import com.nativeapptemplate.nativeapptemplatefree.model.* +import com.skydoves.sandwich.ApiResponse +import retrofit2.Retrofit +import retrofit2.http.* + +interface ItemTagApi { + @GET("{account_id}/api/v1/shopkeeper/shops/{shop_id}/item_tags") + suspend fun getItemTags( + @Path("account_id") accountId: String, + @Path("shop_id") shopId: String + ): ApiResponse + + @GET("{account_id}/api/v1/shopkeeper/item_tags/{id}") + suspend fun getItemTag( + @Path("account_id") accountId: String, + @Path("id") id: String + ): ApiResponse + + @POST("{account_id}/api/v1/shopkeeper/shops/{shop_id}/item_tags") + suspend fun createItemTag( + @Path("account_id") accountId: String, + @Path("shop_id") shopId: String, + @Body data: ItemTagBody + ): ApiResponse + + @PATCH("{account_id}/api/v1/shopkeeper/item_tags/{id}") + suspend fun updateItemTag( + @Path("account_id") accountId: String, + @Path("id") id: String, + @Body data: ItemTagBody + ): ApiResponse + + @DELETE("{account_id}/api/v1/shopkeeper/item_tags/{id}") + suspend fun deleteItemTag( + @Path("account_id") accountId: String, + @Path("id") id: String + ): ApiResponse + + @PATCH("{account_id}/api/v1/shopkeeper/item_tags/{id}/complete") + suspend fun completeItemTag( + @Path("account_id") accountId: String, + @Path("id") id: String, + ): ApiResponse + + @PATCH("{account_id}/api/v1/shopkeeper/item_tags/{id}/reset") + suspend fun resetItemTag( + @Path("account_id") accountId: String, + @Path("id") id: String, + ): ApiResponse + + companion object { + fun create(retroFit: Retrofit): ItemTagApi = retroFit.create( + ItemTagApi::class.java + ) + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepository.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepository.kt new file mode 100644 index 0000000..69dc849 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepository.kt @@ -0,0 +1,36 @@ +package com.nativeapptemplate.nativeapptemplatefree.data.item_tag + +import com.nativeapptemplate.nativeapptemplatefree.model.* +import kotlinx.coroutines.flow.Flow + +interface ItemTagRepository { + fun getItemTags( + shopId: String, + ): Flow + + fun getItemTag( + id: String, + ): Flow + + fun createItemTag( + shopId: String, + itemTagBody: ItemTagBody, + ): Flow + + fun updateItemTag( + id: String, + itemTagBody: ItemTagBody + ): Flow + + fun deleteItemTag( + id: String, + ): Flow + + fun completeItemTag( + id: String, + ): Flow + + fun resetItemTag( + id: String, + ): Flow +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepositoryImpl.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepositoryImpl.kt new file mode 100644 index 0000000..37a3669 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepositoryImpl.kt @@ -0,0 +1,233 @@ +package com.nativeapptemplate.nativeapptemplatefree.data.item_tag + +import com.nativeapptemplate.nativeapptemplatefree.datastore.NatPreferencesDataSource +import com.nativeapptemplate.nativeapptemplatefree.model.* +import com.nativeapptemplate.nativeapptemplatefree.network.Dispatcher +import com.nativeapptemplate.nativeapptemplatefree.network.NatDispatchers +import com.skydoves.sandwich.message +import com.skydoves.sandwich.retrofit.serialization.deserializeErrorBody +import com.skydoves.sandwich.suspendOnFailure +import com.skydoves.sandwich.suspendOnSuccess +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject + +class ItemTagRepositoryImpl @Inject constructor( + private val mtcPreferencesDataSource: NatPreferencesDataSource, + private val api: ItemTagApi, + @Dispatcher(NatDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, +) : ItemTagRepository { + + override fun getItemTags( + shopId: String, + ) = flow { + val response = api.getItemTags( + mtcPreferencesDataSource.userData.first().accountId, + shopId, + ) + + response.suspendOnSuccess { + emit(data) + }.suspendOnFailure { + val nativeAppTemplateApiError: NativeAppTemplateApiError? + + try { + nativeAppTemplateApiError = response.deserializeErrorBody() + } catch (exception: Exception) { + val message= "Not processable error(${message()})." + throw Exception(message) + } + + if (nativeAppTemplateApiError != null) { + val message= "${nativeAppTemplateApiError.message} [Status: ${nativeAppTemplateApiError.code}]" + throw Exception(message) + } else { + val message= "Not processable error(${message()})." + throw Exception(message) + } + } + }.flowOn(ioDispatcher) + + override fun getItemTag( + id: String, + ) = flow { + val response = api.getItemTag( + mtcPreferencesDataSource.userData.first().accountId, + id + ) + + response.suspendOnSuccess { + emit(data) + }.suspendOnFailure { + val nativeAppTemplateApiError: NativeAppTemplateApiError? + + try { + nativeAppTemplateApiError = response.deserializeErrorBody() + } catch (exception: Exception) { + val message= "Not processable error(${message()})." + throw Exception(message) + } + + if (nativeAppTemplateApiError != null) { + val message= "${nativeAppTemplateApiError.message} [Status: ${nativeAppTemplateApiError.code}]" + throw Exception(message) + } else { + val message= "Not processable error(${message()})." + throw Exception(message) + } + } + }.flowOn(ioDispatcher) + + override fun createItemTag( + shopId: String, + itemTagBody: ItemTagBody, + ) = flow { + var itemTag: ItemTag + + val response = api.createItemTag( + mtcPreferencesDataSource.userData.first().accountId, + shopId, + itemTagBody, + ) + + response.suspendOnSuccess { + itemTag = data + emit(itemTag) + }.suspendOnFailure { + val nativeAppTemplateApiError: NativeAppTemplateApiError? + + try { + nativeAppTemplateApiError = response.deserializeErrorBody() + } catch (exception: Exception) { + val message= "Not processable error(${message()})." + throw Exception(message) + } + + if (nativeAppTemplateApiError != null) { + val message= "${nativeAppTemplateApiError.message} [Status: ${nativeAppTemplateApiError.code}]" + throw Exception(message) + } else { + val message= "Not processable error(${message()})." + throw Exception(message) + } + } + }.flowOn(ioDispatcher) + + override fun updateItemTag( + id: String, + itemTagBody: ItemTagBody, + ) = flow { + var itemTag: ItemTag + + val response = api.updateItemTag( + mtcPreferencesDataSource.userData.first().accountId, + id, + itemTagBody + ) + + response.suspendOnSuccess { + itemTag = data + emit(itemTag) + }.suspendOnFailure { + val nativeAppTemplateApiError: NativeAppTemplateApiError? + + try { + nativeAppTemplateApiError = response.deserializeErrorBody() + } catch (exception: Exception) { + val message= "Not processable error(${message()})." + throw Exception(message) + } + + if (nativeAppTemplateApiError != null) { + val message= "${nativeAppTemplateApiError.message} [Status: ${nativeAppTemplateApiError.code}]" + throw Exception(message) + } else { + val message= "Not processable error(${message()})." + throw Exception(message) + } + } + }.flowOn(ioDispatcher) + + override fun deleteItemTag( + id: String, + ) = flow { + val response = api.deleteItemTag(mtcPreferencesDataSource.userData.first().accountId, id) + + response.suspendOnSuccess { + emit(true) + }.suspendOnFailure { + val nativeAppTemplateApiError: NativeAppTemplateApiError? + + try { + nativeAppTemplateApiError = response.deserializeErrorBody() + } catch (exception: Exception) { + val message= "Not processable error(${message()})." + throw Exception(message) + } + + if (nativeAppTemplateApiError != null) { + val message= "${nativeAppTemplateApiError.message} [Status: ${nativeAppTemplateApiError.code}]" + throw Exception(message) + } else { + val message= "Not processable error(${message()})." + throw Exception(message) + } + } + }.flowOn(ioDispatcher) + + override fun completeItemTag( + id: String, + ) = flow { + val response = api.completeItemTag(mtcPreferencesDataSource.userData.first().accountId, id) + + response.suspendOnSuccess { + emit(data) + }.suspendOnFailure { + val nativeAppTemplateApiError: NativeAppTemplateApiError? + + try { + nativeAppTemplateApiError = response.deserializeErrorBody() + } catch (exception: Exception) { + val message= "Not processable error(${message()})." + throw Exception(message) + } + + if (nativeAppTemplateApiError != null) { + val message= "${nativeAppTemplateApiError.message} [Status: ${nativeAppTemplateApiError.code}]" + throw Exception(message) + } else { + val message= "Not processable error(${message()})." + throw Exception(message) + } + } + }.flowOn(ioDispatcher) + + override fun resetItemTag( + id: String, + ) = flow { + val response = api.resetItemTag(mtcPreferencesDataSource.userData.first().accountId, id) + + response.suspendOnSuccess { + emit(data) + }.suspendOnFailure { + val nativeAppTemplateApiError: NativeAppTemplateApiError? + + try { + nativeAppTemplateApiError = response.deserializeErrorBody() + } catch (exception: Exception) { + val message= "Not processable error(${message()})." + throw Exception(message) + } + + if (nativeAppTemplateApiError != null) { + val message= "${nativeAppTemplateApiError.message} [Status: ${nativeAppTemplateApiError.code}]" + throw Exception(message) + } else { + val message= "Not processable error(${message()})." + throw Exception(message) + } + } + }.flowOn(ioDispatcher) +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepository.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepository.kt index cdb2ea9..19724dd 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepository.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepository.kt @@ -22,6 +22,18 @@ interface LoginRepository { fun updateConfirmedTermsVersion( ): Flow + suspend fun setShouldFetchItemTagForShowTagInfoScan(shouldFetchItemTagForShowTagInfoScan: Boolean) + + suspend fun setShouldCompleteItemTagForCompleteScan(shouldCompleteItemTagForCompleteScan: Boolean) + + suspend fun setShouldNavigateToScanView(shouldNavigateToScanView: Boolean) + + suspend fun setScanViewSelectedTabIndex(scanViewSelectedTabIndex: Int) + + suspend fun setCompleteScanResult(completeScanResult: CompleteScanResult) + + suspend fun setShowTagInfoScanResult(showTagInfoScanResult: ShowTagInfoScanResult) + suspend fun setAccountId(accountId: String) suspend fun setShopkeeper(loggedInShopkeeper: LoggedInShopkeeper) @@ -30,6 +42,10 @@ interface LoginRepository { suspend fun setPermissions(permissions: Permissions) + suspend fun setDidShowTapShopBelowTip(didShowTapShopBelowTip: Boolean) + + suspend fun setDidShowReadInstructionsTip(didShowReadInstructionsTip: Boolean) + suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) suspend fun setIsEmailUpdated(isEmailUpdated: Boolean) @@ -53,5 +69,23 @@ interface LoginRepository { fun isMyAccountDeleted(): Flow fun isShopDeleted(): Flow + + fun didShowTapShopBelowTip(): Flow + + fun didShowReadInstructionsTip(): Flow + + fun getMaximumQueueNumberLength(): Flow + + fun shouldFetchItemTagForShowTagInfoScan(): Flow + + fun shouldCompleteItemTagForCompleteScan(): Flow + + fun shouldNavigateToScanView(): Flow + + fun scanViewSelectedTabIndex(): Flow + + fun completeScanResult(): Flow + + fun showTagInfoScanResult(): Flow } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt index 54fd411..aceecf8 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt @@ -157,6 +157,30 @@ class LoginRepositoryImpl @Inject constructor( } }.flowOn(ioDispatcher) + override suspend fun setShouldFetchItemTagForShowTagInfoScan(shouldFetchItemTagForShowTagInfoScan: Boolean) { + natPreferencesDataSource.setShouldFetchItemTagForShowTagInfoScan(shouldFetchItemTagForShowTagInfoScan) + } + + override suspend fun setShouldCompleteItemTagForCompleteScan(shouldCompleteItemTagForCompleteScan: Boolean) { + natPreferencesDataSource.setShouldCompleteItemTagForCompleteScan(shouldCompleteItemTagForCompleteScan) + } + + override suspend fun setShouldNavigateToScanView(shouldNavigateToScanView: Boolean) { + natPreferencesDataSource.setShouldNavigateToScanView(shouldNavigateToScanView) + } + + override suspend fun setScanViewSelectedTabIndex(scanViewSelectedTabIndex: Int) { + natPreferencesDataSource.setScanViewSelectedTabIndex(scanViewSelectedTabIndex) + } + + override suspend fun setCompleteScanResult(completeScanResult: CompleteScanResult) { + natPreferencesDataSource.setCompleteScanResult(completeScanResult) + } + + override suspend fun setShowTagInfoScanResult(showTagInfoScanResult: ShowTagInfoScanResult) { + natPreferencesDataSource.setShowTagInfoScanResult(showTagInfoScanResult) + } + override suspend fun setAccountId(accountId: String) { natPreferencesDataSource.setAccountId(accountId) } @@ -177,6 +201,14 @@ class LoginRepositoryImpl @Inject constructor( natPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) } + override suspend fun setDidShowTapShopBelowTip(didShowTapShopBelowTip: Boolean) { + natPreferencesDataSource.setDidShowTapShopBelowTip(didShowTapShopBelowTip) + } + + override suspend fun setDidShowReadInstructionsTip(didShowReadInstructionsTip: Boolean) { + natPreferencesDataSource.setDidShowReadInstructionsTip(didShowReadInstructionsTip) + } + override suspend fun setIsEmailUpdated(isEmailUpdated: Boolean) { natPreferencesDataSource.setIsEmailUpdated(isEmailUpdated) } @@ -206,4 +238,22 @@ class LoginRepositoryImpl @Inject constructor( override fun isMyAccountDeleted(): Flow = natPreferencesDataSource.isMyAccountDeleted() override fun isShopDeleted(): Flow = natPreferencesDataSource.isShopDeleted() + + override fun didShowTapShopBelowTip(): Flow = natPreferencesDataSource.didShowTapShopBelowTip() + + override fun didShowReadInstructionsTip(): Flow = natPreferencesDataSource.didShowReadInstructionsTip() + + override fun getMaximumQueueNumberLength(): Flow = natPreferencesDataSource.getMaximumQueueNumberLength() + + override fun shouldFetchItemTagForShowTagInfoScan(): Flow = natPreferencesDataSource.shouldFetchItemTagForShowTagInfoScan() + + override fun shouldCompleteItemTagForCompleteScan(): Flow = natPreferencesDataSource.shouldCompleteItemTagForCompleteScan() + + override fun shouldNavigateToScanView(): Flow = natPreferencesDataSource.shouldNavigateToScanView() + + override fun scanViewSelectedTabIndex(): Flow = natPreferencesDataSource.scanViewSelectedTabIndex() + + override fun completeScanResult(): Flow = natPreferencesDataSource.completeScanResult() + + override fun showTagInfoScanResult(): Flow = natPreferencesDataSource.showTagInfoScanResult() } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopApi.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopApi.kt index 2af7ef5..518a681 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopApi.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopApi.kt @@ -36,6 +36,12 @@ interface ShopApi { @Path("id") id: String, ): ApiResponse + @DELETE("{account_id}/api/v1/shopkeeper/shops/{id}/reset") + suspend fun resetShop( + @Path("account_id") accountId: String, + @Path("id") id: String, + ): ApiResponse + companion object { fun create(retroFit: Retrofit): ShopApi = retroFit.create( ShopApi::class.java diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepository.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepository.kt index 315f94b..cdf0206 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepository.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepository.kt @@ -22,4 +22,8 @@ interface ShopRepository { fun deleteShop( id: String, ): Flow + + fun resetShop( + id: String, + ): Flow } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepositoryImpl.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepositoryImpl.kt index 1d31dfa..ce41606 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepositoryImpl.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepositoryImpl.kt @@ -171,4 +171,31 @@ class ShopRepositoryImpl @Inject constructor( } } }.flowOn(ioDispatcher) + + override fun resetShop( + id: String, + ) = flow { + val response = api.resetShop(natPreferencesDataSource.userData.first().accountId, id) + + response.suspendOnSuccess { + emit(true) + }.suspendOnFailure { + val nativeAppTemplateApiError: NativeAppTemplateApiError? + + try { + nativeAppTemplateApiError = response.deserializeErrorBody() + } catch (exception: Exception) { + val message= "Not processable error(${message()})." + throw Exception(message) + } + + if (nativeAppTemplateApiError != null) { + val message= "${nativeAppTemplateApiError.message} [Status: ${nativeAppTemplateApiError.code}]" + throw Exception(message) + } else { + val message= "Not processable error(${message()})." + throw Exception(message) + } + } + }.flowOn(ioDispatcher) } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NatPreferencesDataSource.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NatPreferencesDataSource.kt index 625003f..ca7819f 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NatPreferencesDataSource.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NatPreferencesDataSource.kt @@ -4,11 +4,23 @@ import android.util.Log import androidx.datastore.core.DataStore import com.nativeapptemplate.nativeapptemplatefree.BuildConfig import com.nativeapptemplate.nativeapptemplatefree.DarkThemeConfigProto +import com.nativeapptemplate.nativeapptemplatefree.ItemTagDataProto +import com.nativeapptemplate.nativeapptemplatefree.ItemTagInfoFromNdefMessageProto +import com.nativeapptemplate.nativeapptemplatefree.ScanResultProto import com.nativeapptemplate.nativeapptemplatefree.UserPreferences import com.nativeapptemplate.nativeapptemplatefree.copy +import com.nativeapptemplate.nativeapptemplatefree.model.CompleteScanResult +import com.nativeapptemplate.nativeapptemplatefree.model.CompleteScanResultType import com.nativeapptemplate.nativeapptemplatefree.model.DarkThemeConfig +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagData +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagInfoFromNdefMessage +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagState +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagType import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper import com.nativeapptemplate.nativeapptemplatefree.model.Permissions +import com.nativeapptemplate.nativeapptemplatefree.model.ScanState +import com.nativeapptemplate.nativeapptemplatefree.model.ShowTagInfoScanResult +import com.nativeapptemplate.nativeapptemplatefree.model.ShowTagInfoScanResultType import com.nativeapptemplate.nativeapptemplatefree.model.UserData import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -38,7 +50,7 @@ class NatPreferencesDataSource @Inject constructor( DarkThemeConfigProto.DARK_THEME_CONFIG_UNSPECIFIED, DarkThemeConfigProto.UNRECOGNIZED, DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM, - -> + -> DarkThemeConfig.FOLLOW_SYSTEM DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT -> DarkThemeConfig.LIGHT @@ -50,10 +62,15 @@ class NatPreferencesDataSource @Inject constructor( androidAppVersion = it.androidAppVersion, shouldUpdatePrivacy = it.shouldUpdatePrivacy, shouldUpdateTerms = it.shouldUpdateTerms, + maximumQueueNumberLength = it.maximumQueueNumberLength, shopLimitCount = it.shopLimitCount, isEmailUpdated = it.isEmailUpdated, isMyAccountDeleted = it.isMyAccountDeleted, + + scanViewSelectedTabIndex = it.scanViewSelectedTabIndex, + shouldFetchItemTagForShowTagInfoScan = it.shouldFetchItemTagForShowTagInfoScan, + shouldCompleteItemTagForCompleteScan = it.shouldCompleteItemTagForCompleteScan, ) } @@ -100,7 +117,6 @@ class NatPreferencesDataSource @Inject constructor( suspend fun setPermissions(permissions: Permissions) { try { - userPreferences.updateData { it.copy { val androidAppVersion = permissions.getAndroidAppVersion()!! @@ -109,6 +125,7 @@ class NatPreferencesDataSource @Inject constructor( this.shouldUpdatePrivacy = permissions.getShouldUpdatePrivacy()!! this.shouldUpdateTerms = permissions.getShouldUpdateTerms()!! + this.maximumQueueNumberLength = permissions.getMaximumQueueNumberLength()!! this.shopLimitCount = permissions.getShopLimitCount()!! } } @@ -118,6 +135,147 @@ class NatPreferencesDataSource @Inject constructor( } } + suspend fun setShouldFetchItemTagForShowTagInfoScan(shouldFetchItemTagForShowTagInfoScan: Boolean) { + try { + userPreferences.updateData { + it.copy { this.shouldFetchItemTagForShowTagInfoScan = shouldFetchItemTagForShowTagInfoScan } + } + } catch (ioException: IOException) { + Log.e("MtcPreferences", "Failed to update user preferences", ioException) + throw ioException + } + } + + suspend fun setShouldCompleteItemTagForCompleteScan(shouldCompleteItemTagForCompleteScan: Boolean) { + try { + userPreferences.updateData { + it.copy { this.shouldCompleteItemTagForCompleteScan = shouldCompleteItemTagForCompleteScan } + } + } catch (ioException: IOException) { + Log.e("MtcPreferences", "Failed to update user preferences", ioException) + throw ioException + } + } + + suspend fun setShouldNavigateToScanView(shouldNavigateToScanView: Boolean) { + try { + userPreferences.updateData { + it.copy { this.shouldNavigateToScanView = shouldNavigateToScanView } + } + } catch (ioException: IOException) { + Log.e("MtcPreferences", "Failed to update user preferences", ioException) + throw ioException + } + } + + suspend fun setScanViewSelectedTabIndex(scanViewSelectedTabIndex: Int) { + try { + userPreferences.updateData { + it.copy { this.scanViewSelectedTabIndex = scanViewSelectedTabIndex } + } + } catch (ioException: IOException) { + Log.e("MtcPreferences", "Failed to update user preferences", ioException) + throw ioException + } + } + + suspend fun setCompleteScanResult( + completeScanResult: CompleteScanResult, + ) { + val itemTagInfoFromNdefMessage = completeScanResult.itemTagInfoFromNdefMessage + val itemTagData = completeScanResult.itemTagData + val completeScanResultType = completeScanResult.completeScanResultType + val message = completeScanResult.message + + try { + val scanResultProto = setScanResult( + itemTagInfoFromNdefMessage = itemTagInfoFromNdefMessage, + itemTagData = itemTagData, + scanResultType = completeScanResultType.param, + message = message, + ) + + userPreferences.updateData { + it.copy { + this.completeScanResult = scanResultProto + } + } + } catch (ioException: IOException) { + Log.e("MtcPreferences", "Failed to update user preferences", ioException) + throw ioException + } + } + + suspend fun setShowTagInfoScanResult( + showTagInfoScanResult: ShowTagInfoScanResult, + ) { + val itemTagInfoFromNdefMessage = showTagInfoScanResult.itemTagInfoFromNdefMessage + val itemTagData = showTagInfoScanResult.itemTagData + val showTagInfoScanResultType = showTagInfoScanResult.showTagInfoScanResultType + val message = showTagInfoScanResult.message + + try { + val scanResultProto = setScanResult( + itemTagInfoFromNdefMessage = itemTagInfoFromNdefMessage, + itemTagData = itemTagData, + scanResultType = showTagInfoScanResultType.param, + message = message, + ) + + userPreferences.updateData { + it.copy { + this.showTagInfoScanResult = scanResultProto + } + } + } catch (ioException: IOException) { + Log.e("MtcPreferences", "Failed to update user preferences", ioException) + throw ioException + } + } + + private fun setScanResult( + itemTagInfoFromNdefMessage: ItemTagInfoFromNdefMessage, + itemTagData: ItemTagData, + scanResultType: String, + message: String, + ): ScanResultProto { + val itemTagInfoFromNdefMessageProto = setItemTagInfoFromNdefMessage(itemTagInfoFromNdefMessage) + val itemTagProto = setItemTagData(itemTagData) + + return ScanResultProto.newBuilder() + .setItemTagInfoFromNdefMessage(itemTagInfoFromNdefMessageProto) + .setItemTagData(itemTagProto) + .setScanResultType(scanResultType) + .setMessage(message) + .build() + } + + private fun setItemTagInfoFromNdefMessage(itemTagInfoFromNdefMessage: ItemTagInfoFromNdefMessage): ItemTagInfoFromNdefMessageProto { + return ItemTagInfoFromNdefMessageProto.newBuilder() + .setId(itemTagInfoFromNdefMessage.id) + .setItemTagType(itemTagInfoFromNdefMessage.itemTagType.param) + .setSuccess(itemTagInfoFromNdefMessage.success) + .setMessage(itemTagInfoFromNdefMessage.message) + .setIsReadOnly(itemTagInfoFromNdefMessage.isReadOnly) + .setScannedAt(itemTagInfoFromNdefMessage.scannedAt) + .build() + } + + private fun setItemTagData(itemTagData: ItemTagData): ItemTagDataProto { + return ItemTagDataProto.newBuilder() + .setId(itemTagData.id) + .setShopId(itemTagData.shopId) + .setQueueNumber(itemTagData.queueNumber) + .setState(itemTagData.state.param) + .setScanState(itemTagData.scanState.param) + .setCreatedAt(itemTagData.createdAt) + .setCustomerReadAt(itemTagData.customerReadAt) + .setCompletedAt(itemTagData.completedAt) + .setShopName(itemTagData.shopName) + .setAlreadyCompleted(itemTagData.alreadyCompleted) + .build() + } + suspend fun setAccountId(accountId: String) { try { userPreferences.updateData { @@ -149,6 +307,28 @@ class NatPreferencesDataSource @Inject constructor( } } + suspend fun setDidShowTapShopBelowTip(didShowTapShopBelowTip: Boolean) { + try { + userPreferences.updateData { + it.copy { this.didShowTapShopBelowTip = didShowTapShopBelowTip } + } + } catch (ioException: IOException) { + Log.e("NatPreferences", "Failed to update user preferences", ioException) + throw ioException + } + } + + suspend fun setDidShowReadInstructionsTip(didShowReadInstructionsTip: Boolean) { + try { + userPreferences.updateData { + it.copy { this.didShowReadInstructionsTip = didShowReadInstructionsTip } + } + } catch (ioException: IOException) { + Log.e("NatPreferences", "Failed to update user preferences", ioException) + throw ioException + } + } + suspend fun setIsEmailUpdated(isEmailUpdated: Boolean) { try { userPreferences.updateData { @@ -227,4 +407,112 @@ class NatPreferencesDataSource @Inject constructor( .map { data -> data.isShopDeleted } + + fun didShowTapShopBelowTip(): Flow = userPreferences.data + .map { data -> + data.didShowTapShopBelowTip + } + + fun didShowReadInstructionsTip(): Flow = userPreferences.data + .map { data -> + data.didShowReadInstructionsTip + } + + fun getMaximumQueueNumberLength(): Flow = userPreferences.data + .map { data -> + data.maximumQueueNumberLength + } + + fun shouldFetchItemTagForShowTagInfoScan(): Flow = userPreferences.data + .map { data -> + data.shouldFetchItemTagForShowTagInfoScan + } + + fun shouldCompleteItemTagForCompleteScan(): Flow = userPreferences.data + .map { data -> + data.shouldCompleteItemTagForCompleteScan + } + + fun shouldNavigateToScanView(): Flow = userPreferences.data + .map { data -> + data.shouldNavigateToScanView + } + + fun scanViewSelectedTabIndex(): Flow = userPreferences.data + .map { data -> + data.scanViewSelectedTabIndex + } + + fun completeScanResult(): Flow = userPreferences.data + .map { data -> + completeScanResultFrom(data.completeScanResult) + } + + fun showTagInfoScanResult(): Flow = userPreferences.data + .map { data -> + showTagInfoScanResultFrom(data.showTagInfoScanResult) + } + + private fun showTagInfoScanResultFrom(scanResultProto: ScanResultProto): ShowTagInfoScanResult { + val itemTagInfoFromNdefMessage = itemTagInfoFromNdefMessageFrom( + scanResultProto.itemTagInfoFromNdefMessage + ) + + val itemTagData = itemTagDataFrom( + scanResultProto.itemTagData + ) + + return ShowTagInfoScanResult( + itemTagInfoFromNdefMessage = itemTagInfoFromNdefMessage, + itemTagData = itemTagData, + showTagInfoScanResultType = ShowTagInfoScanResultType.fromParam(scanResultProto.scanResultType) ?: ShowTagInfoScanResultType.Idled, + message = scanResultProto.message, + ) + } + + private fun completeScanResultFrom(scanResultProto: ScanResultProto): CompleteScanResult { + val itemTagInfoFromNdefMessage = itemTagInfoFromNdefMessageFrom( + scanResultProto.itemTagInfoFromNdefMessage + ) + + val itemTagData = itemTagDataFrom( + scanResultProto.itemTagData + ) + + return CompleteScanResult( + itemTagInfoFromNdefMessage = itemTagInfoFromNdefMessage, + itemTagData = itemTagData, + completeScanResultType = CompleteScanResultType.fromParam(scanResultProto.scanResultType) ?: CompleteScanResultType.Idled, + message = scanResultProto.message, + ) + } + + private fun itemTagInfoFromNdefMessageFrom( + itemTagInfoFromNdefMessageProto: ItemTagInfoFromNdefMessageProto + ): ItemTagInfoFromNdefMessage { + return ItemTagInfoFromNdefMessage( + id = itemTagInfoFromNdefMessageProto.id, + itemTagType = ItemTagType.fromParam(itemTagInfoFromNdefMessageProto.itemTagType) + ?: ItemTagType.Server, + success = itemTagInfoFromNdefMessageProto.success, + message = itemTagInfoFromNdefMessageProto.message, + isReadOnly = itemTagInfoFromNdefMessageProto.isReadOnly, + scannedAt = itemTagInfoFromNdefMessageProto.scannedAt, + ) + } + + private fun itemTagDataFrom(itemTagDataProto: ItemTagDataProto): ItemTagData { + return ItemTagData( + id = itemTagDataProto.id, + shopId = itemTagDataProto.shopId, + queueNumber = itemTagDataProto.queueNumber, + state = ItemTagState.fromParam(itemTagDataProto.state) ?: ItemTagState.Idled, + scanState = ScanState.fromParam(itemTagDataProto.scanState) ?: ScanState.Unscanned, + createdAt = itemTagDataProto.createdAt, + customerReadAt = itemTagDataProto.customerReadAt, + completedAt = itemTagDataProto.completedAt, + shopName = itemTagDataProto.shopName, + alreadyCompleted = itemTagDataProto.alreadyCompleted, + ) + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/designsystem/theme/CustomColorScheme.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/designsystem/theme/CustomColorScheme.kt new file mode 100644 index 0000000..e0797dd --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/designsystem/theme/CustomColorScheme.kt @@ -0,0 +1,29 @@ +package com.nativeapptemplate.nativeapptemplatefree.designsystem.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +@Immutable +data class CustomColorScheme( + val success: Color = Color.Unspecified, + val onSuccess: Color = Color.Unspecified, + val successContainer: Color = Color.Unspecified, + val onSuccessContainer: Color = Color.Unspecified, +) + +val LightCustomColorScheme = CustomColorScheme( + success = Teal40, + onSuccess = Color.White, + successContainer = Teal90, + onSuccessContainer = Teal10, +) + +val DarkCustomColorScheme = CustomColorScheme( + success = Teal80, + onSuccess = Teal20, + successContainer = Teal30, + onSuccessContainer = Teal90, +) + +val LocalCustomColorScheme = staticCompositionLocalOf { CustomColorScheme() } \ No newline at end of file diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/designsystem/theme/Theme.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/designsystem/theme/Theme.kt index dfec6aa..63ea020 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/designsystem/theme/Theme.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/designsystem/theme/Theme.kt @@ -79,8 +79,13 @@ fun NatTheme( val tintTheme = TintTheme() + val customColorsPalette = + if (darkTheme) DarkCustomColorScheme + else LightCustomColorScheme + // Composition locals CompositionLocalProvider( + LocalCustomColorScheme provides customColorsPalette, LocalBackgroundTheme provides backgroundTheme, LocalTintTheme provides tintTheme, ) { diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/di/modules/DataModule.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/di/modules/DataModule.kt index 1d9930f..11c94c1 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/di/modules/DataModule.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/di/modules/DataModule.kt @@ -1,6 +1,8 @@ package com.nativeapptemplate.nativeapptemplatefree.di.modules import android.annotation.SuppressLint +import com.nativeapptemplate.nativeapptemplatefree.data.item_tag.ItemTagRepository +import com.nativeapptemplate.nativeapptemplatefree.data.item_tag.ItemTagRepositoryImpl import com.nativeapptemplate.nativeapptemplatefree.data.login.AccountPasswordRepository import com.nativeapptemplate.nativeapptemplatefree.data.login.AccountPasswordRepositoryImpl import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository @@ -32,6 +34,9 @@ abstract class DataModule { @Binds internal abstract fun bindShopRepository(shopRepositoryImpl: ShopRepositoryImpl): ShopRepository + @Binds + internal abstract fun bindItemTagRepository(itemTagRepositoryImpl: ItemTagRepositoryImpl): ItemTagRepository + @Binds internal abstract fun bindsNetworkMonitor( networkMonitor: ConnectivityManagerNetworkMonitor, diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/di/modules/NetModule.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/di/modules/NetModule.kt index 2614738..eefc0e0 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/di/modules/NetModule.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/di/modules/NetModule.kt @@ -4,6 +4,7 @@ import androidx.tracing.trace import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.nativeapptemplate.nativeapptemplatefree.BuildConfig import com.nativeapptemplate.nativeapptemplatefree.NatConstants +import com.nativeapptemplate.nativeapptemplatefree.data.item_tag.ItemTagApi import com.nativeapptemplate.nativeapptemplatefree.data.login.AccountPasswordApi import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginApi import com.nativeapptemplate.nativeapptemplatefree.data.login.SignUpApi @@ -95,6 +96,9 @@ class NetModule { @Provides fun provideShopApi(retrofit: Retrofit): ShopApi = ShopApi.create(retrofit) + @Provides + fun provideItemTagApi(retrofit: Retrofit): ItemTagApi = ItemTagApi.create(retrofit) + @Provides @Singleton fun okHttpCallFactory(): Call.Factory = trace("NatOkHttpClient") { diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/navigation/NatNavHost.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/navigation/NatNavHost.kt index 9c180fb..41e345d 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/navigation/NatNavHost.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/navigation/NatNavHost.kt @@ -31,11 +31,27 @@ import com.nativeapptemplate.nativeapptemplatefree.ui.app_root.navigation.resend import com.nativeapptemplate.nativeapptemplatefree.ui.app_root.navigation.signInEmailAndPasswordView import com.nativeapptemplate.nativeapptemplatefree.ui.app_root.navigation.signUpOrSignInView import com.nativeapptemplate.nativeapptemplatefree.ui.app_root.navigation.signUpView +import com.nativeapptemplate.nativeapptemplatefree.ui.scan.navigation.doScanView +import com.nativeapptemplate.nativeapptemplatefree.ui.scan.navigation.navigateToDoScan +import com.nativeapptemplate.nativeapptemplatefree.ui.scan.navigation.scanBaseView +import com.nativeapptemplate.nativeapptemplatefree.ui.scan.navigation.scanView import com.nativeapptemplate.nativeapptemplatefree.ui.settings.navigation.settingBaseView import com.nativeapptemplate.nativeapptemplatefree.ui.shop_detail.navigation.navigateToShopDetail import com.nativeapptemplate.nativeapptemplatefree.ui.shop_detail.navigation.shopDetailView +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.itemTagCreateView +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.itemTagDetailView +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.itemTagEditView +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.itemTagListView +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.itemTagWriteView +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.navigateToItemTagCreate +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.navigateToItemTagDetail +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.navigateToItemTagEdit +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.navigateToItemTagList +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.navigateToItemTagWrite +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.navigateToNumberTagsWebpageList import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.navigateToShopBasicSettings import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.navigateToShopSettings +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.numberTagsWebpageListView import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.shopBasicSettingsView import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.shopSettingsView import com.nativeapptemplate.nativeapptemplatefree.ui.shops.navigation.ShopBaseRoute @@ -63,8 +79,9 @@ fun NatNavHost( val shouldUpdateApp by appState.shouldUpdateApp.collectAsStateWithLifecycle() val shouldUpdatePrivacy by appState.shouldUpdatePrivacy.collectAsStateWithLifecycle() val shouldUpdateTerms by appState.shouldUpdateTerms.collectAsStateWithLifecycle() + val shouldNavigateToScanView by appState.shouldNavigateToScanView.collectAsStateWithLifecycle() - LaunchedEffect( + LaunchedEffect( isLoggedIn, shouldUpdateApp, shouldUpdatePrivacy, @@ -85,6 +102,12 @@ fun NatNavHost( } } + LaunchedEffect(shouldNavigateToScanView) { + if (shouldNavigateToScanView) { + appState.navigateToScan() + } + } + NavHost( navController = navController, startDestination = ShopBaseRoute, @@ -142,6 +165,9 @@ fun NatNavHost( ) shopSettingsView( onShowBasicSettingsClick = { shopId -> navController.navigateToShopBasicSettings(shopId) }, + onShowItemTagListClick = { shopId -> navController.navigateToItemTagList(shopId) }, + onShowNumberTagsWebpageListClick = { shopId -> navController.navigateToNumberTagsWebpageList(shopId) }, + onShowSnackbar = onShowSnackbar, onBackClick = navController::popBackStack, ) @@ -149,6 +175,45 @@ fun NatNavHost( onShowSnackbar = onShowSnackbar, onBackClick = navController::popBackStack, ) + + numberTagsWebpageListView( + onShowSnackbar = onShowSnackbar, + onBackClick = navController::popBackStack, + ) + + itemTagListView( + onItemClick = { itemTagId -> navController.navigateToItemTagDetail(itemTagId) }, + onAddItemTagClick = { shopId -> navController.navigateToItemTagCreate(shopId) }, + onShowSnackbar = onShowSnackbar, + onBackClick =navController::popBackStack, + ) + itemTagCreateView( + onShowSnackbar = onShowSnackbar, + onBackClick = navController::popBackStack, + ) + itemTagDetailView( + onShowItemTagEditClick = { itemTagId -> navController.navigateToItemTagEdit(itemTagId) }, + onShowItemTagWriteClick = { itemTagId, isLock, itemTagType -> navController.navigateToItemTagWrite(itemTagId, isLock, itemTagType) }, + onShowSnackbar = onShowSnackbar, + onBackClick = navController::popBackStack, + ) + itemTagEditView( + onShowSnackbar = onShowSnackbar, + onBackClick = navController::popBackStack, + ) + itemTagWriteView( + onBackClick = navController::popBackStack, + ) + } + + scanBaseView { + scanView( + onShowDoScanViewClick = { isTest -> navController.navigateToDoScan(isTest) }, + onShowSnackbar = onShowSnackbar, + ) + doScanView( + onBackClick = navController::popBackStack, + ) } settingBaseView { diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/navigation/TopLevelDestination.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/navigation/TopLevelDestination.kt index 5d49519..4af6e7a 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/navigation/TopLevelDestination.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/navigation/TopLevelDestination.kt @@ -1,12 +1,16 @@ package com.nativeapptemplate.nativeapptemplatefree.navigation import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PhoneAndroid import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Storefront +import androidx.compose.material.icons.rounded.PhoneAndroid import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Storefront import androidx.compose.ui.graphics.vector.ImageVector import com.nativeapptemplate.nativeapptemplatefree.R +import com.nativeapptemplate.nativeapptemplatefree.ui.scan.navigation.ScanBaseRoute +import com.nativeapptemplate.nativeapptemplatefree.ui.scan.navigation.ScanRoute import com.nativeapptemplate.nativeapptemplatefree.ui.settings.navigation.SettingBaseRoute import com.nativeapptemplate.nativeapptemplatefree.ui.settings.navigation.SettingsRoute import com.nativeapptemplate.nativeapptemplatefree.ui.shops.navigation.ShopBaseRoute @@ -32,6 +36,13 @@ enum class TopLevelDestination( route = ShopsRoute::class, baseRoute = ShopBaseRoute::class, ), + SCAN_TAB( + selectedIcon = Icons.Rounded.PhoneAndroid, + unselectedIcon = Icons.Outlined.PhoneAndroid, + iconTextId = R.string.title_scan, + route = ScanRoute::class, + baseRoute = ScanBaseRoute::class, + ), SETTINGS_TAB( selectedIcon = Icons.Rounded.Settings, unselectedIcon = Icons.Outlined.Settings, diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NatAppState.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NatAppState.kt index af75bb7..2bc790b 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NatAppState.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NatAppState.kt @@ -14,8 +14,10 @@ import androidx.tracing.trace import com.nativeapptemplate.nativeapptemplatefree.ui.settings.navigation.navigateToSettings import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository import com.nativeapptemplate.nativeapptemplatefree.navigation.TopLevelDestination +import com.nativeapptemplate.nativeapptemplatefree.navigation.TopLevelDestination.SCAN_TAB import com.nativeapptemplate.nativeapptemplatefree.navigation.TopLevelDestination.SETTINGS_TAB import com.nativeapptemplate.nativeapptemplatefree.navigation.TopLevelDestination.SHOPS_TAB +import com.nativeapptemplate.nativeapptemplatefree.ui.scan.navigation.navigateToScan import com.nativeapptemplate.nativeapptemplatefree.ui.shops.navigation.navigateToShopList import com.nativeapptemplate.nativeapptemplatefree.utils.NetworkMonitor import kotlinx.coroutines.CoroutineScope @@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch @Composable fun rememberNatAppState( @@ -118,6 +121,13 @@ class NatAppState( initialValue = false, ) + val shouldNavigateToScanView = loginRepository.shouldNavigateToScanView() + .stateIn( + scope = coroutineScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = false, + ) + val isShopDeleted = loginRepository.isShopDeleted() .stateIn( scope = coroutineScope, @@ -147,6 +157,8 @@ class NatAppState( * @param topLevelDestination: The destination the app needs to navigate to. */ fun navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) { + updateShouldNavigateToScanViewToFalse() + trace("Navigation: ${topLevelDestination.name}") { val topLevelNavOptions = navOptions { // Pop up to the start destination of the graph to @@ -164,8 +176,33 @@ class NatAppState( when (topLevelDestination) { SHOPS_TAB -> navController.navigateToShopList(topLevelNavOptions) + SCAN_TAB -> navController.navigateToScan(topLevelNavOptions) SETTINGS_TAB -> navController.navigateToSettings(topLevelNavOptions) } } } + + fun navigateToScan() { + val topLevelNavOptions = navOptions { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + // on the back stack as users select items + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } + + navController.navigateToScan(topLevelNavOptions) + } + + private fun updateShouldNavigateToScanViewToFalse() { + coroutineScope.launch { + loginRepository.setShouldNavigateToScanView(false) + } + } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/ActionIcon.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/ActionIcon.kt new file mode 100644 index 0000000..38669e8 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/ActionIcon.kt @@ -0,0 +1,32 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.common + +import androidx.compose.foundation.background +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector + +//https://github.com/philipplackner/ComposeSwipeToReveal +@Composable +fun ActionIcon( + onClick: () -> Unit, + backgroundColor: Color, + icon: ImageVector, + modifier: Modifier = Modifier, + contentDescription: String? = null, + tint: Color = Color.White +) { + IconButton( + onClick = onClick, + modifier = modifier + .background(backgroundColor) + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = tint + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/ActionText.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/ActionText.kt new file mode 100644 index 0000000..d26a074 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/ActionText.kt @@ -0,0 +1,39 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.material3.IconButton +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nativeapptemplate.nativeapptemplatefree.ui.common.NonScaledSp.nonScaledSp + +//https://github.com/philipplackner/ComposeSwipeToReveal +@Composable +fun ActionText( + onClick: () -> Unit, + backgroundColor: Color, + text: String, + modifier: Modifier = Modifier, +) { + IconButton( + onClick = onClick, + modifier = modifier + .background(backgroundColor) + ) { + Text( + text = text, + modifier = Modifier.requiredWidth(64.dp), + fontSize = 12.sp.nonScaledSp, + maxLines = 1, + textAlign = TextAlign.Center, + color = Color.White, + fontWeight = FontWeight.Bold, + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SwipeableItemWithActions.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SwipeableItemWithActions.kt new file mode 100644 index 0000000..de260a3 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SwipeableItemWithActions.kt @@ -0,0 +1,105 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.common + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntOffset +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +//https://github.com/philipplackner/ComposeSwipeToReveal +@Composable +fun SwipeableItemWithActions( + isRevealed: Boolean, + actions: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, + onExpanded: () -> Unit = {}, + onCollapsed: () -> Unit = {}, + content: @Composable () -> Unit +) { + var contextMenuWidth by remember { + mutableFloatStateOf(0f) + } + val offset = remember { + Animatable(initialValue = 0f) + } + val scope = rememberCoroutineScope() + + LaunchedEffect(key1 = isRevealed, contextMenuWidth) { + if(isRevealed) { + offset.animateTo(contextMenuWidth) + } else { + offset.animateTo(0f) + } + } + + Box( + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + Row( + modifier = Modifier + .onSizeChanged { + contextMenuWidth = it.width.toFloat() + }, + verticalAlignment = Alignment.CenterVertically + ) { + actions() + } + Surface( + modifier = Modifier + .fillMaxSize() + .offset { IntOffset(offset.value.roundToInt(), 0) } + .pointerInput(contextMenuWidth) { + detectHorizontalDragGestures( + onHorizontalDrag = { _, dragAmount -> + scope.launch { + val newOffset = (offset.value + dragAmount) + .coerceIn(0f, contextMenuWidth) + offset.snapTo(newOffset) + } + }, + onDragEnd = { + when { + offset.value >= contextMenuWidth / 2f -> { + scope.launch { + offset.animateTo(contextMenuWidth) + onExpanded() + } + } + + else -> { + scope.launch { + offset.animateTo(0f) + onCollapsed() + } + } + } + } + ) + } + ) { + content() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/tags/CompletedTag.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/tags/CompletedTag.kt new file mode 100644 index 0000000..5c5a3f5 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/tags/CompletedTag.kt @@ -0,0 +1,23 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.common.tags + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.nativeapptemplate.nativeapptemplatefree.designsystem.theme.LocalCustomColorScheme +import com.nativeapptemplate.nativeapptemplatefree.designsystem.theme.NatTheme + +@Composable +fun CompletedTag() { + TagView( + text = "COMPLETED", + textColor = LocalCustomColorScheme.current.onSuccess, + backgroundColor = LocalCustomColorScheme.current.success, + ) +} + +@Preview +@Composable +private fun CompletedTagPreview() { + NatTheme { + CompletedTag() + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/tags/CustomerScannedTag.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/tags/CustomerScannedTag.kt new file mode 100644 index 0000000..62516f6 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/tags/CustomerScannedTag.kt @@ -0,0 +1,23 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.common.tags + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.nativeapptemplate.nativeapptemplatefree.designsystem.theme.NatTheme + +@Composable +fun CustomerScannedTag() { + TagView( + text = "CUSTOMER SCANNED", + textColor = MaterialTheme.colorScheme.onError, + backgroundColor = MaterialTheme.colorScheme.error, + ) +} + +@Preview +@Composable +private fun CustomerScannedTagPreview() { + NatTheme { + CustomerScannedTag() + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/tags/IdlingTag.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/tags/IdlingTag.kt new file mode 100644 index 0000000..727cfc7 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/tags/IdlingTag.kt @@ -0,0 +1,23 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.common.tags + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.nativeapptemplate.nativeapptemplatefree.designsystem.theme.NatTheme + +@Composable +fun IdlingTag() { + TagView( + text = "IDLING", + textColor = MaterialTheme.colorScheme.onSurfaceVariant, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + ) +} + +@Preview +@Composable +private fun IdlingTagPreview() { + NatTheme { + IdlingTag() + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/tags/TagView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/tags/TagView.kt new file mode 100644 index 0000000..ee2965b --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/tags/TagView.kt @@ -0,0 +1,56 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.common.tags + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.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.nativeapptemplate.nativeapptemplatefree.designsystem.theme.NatTheme +import com.nativeapptemplate.nativeapptemplatefree.ui.common.NonScaledSp.nonScaledSp + +@Composable +fun TagView( + text: String, + textColor: Color, + backgroundColor: Color, +) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(backgroundColor), + ) { + Text( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 8.dp), + text = text, + color = textColor, + lineHeight = 20.sp.nonScaledSp, + fontSize = 12.sp.nonScaledSp, + fontWeight = FontWeight.Bold, + ) + } +} + +@Preview +@Composable +private fun TagViewPreview() { + NatTheme { + TagView( + text = "COMPLETE", + textColor = MaterialTheme.colorScheme.onError, + backgroundColor = MaterialTheme.colorScheme.error, + ) + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/CompleteScanResultView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/CompleteScanResultView.kt new file mode 100644 index 0000000..b5ebe28 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/CompleteScanResultView.kt @@ -0,0 +1,241 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.scan + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Done +import androidx.compose.material.icons.outlined.Error +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nativeapptemplate.nativeapptemplatefree.designsystem.theme.LocalCustomColorScheme +import com.nativeapptemplate.nativeapptemplatefree.model.CompleteScanResult +import com.nativeapptemplate.nativeapptemplatefree.model.CompleteScanResultType +import com.nativeapptemplate.nativeapptemplatefree.ui.common.NonScaledSp.nonScaledSp +import com.nativeapptemplate.nativeapptemplatefree.ui.common.tags.CompletedTag +import com.nativeapptemplate.nativeapptemplatefree.ui.common.tags.IdlingTag +import com.nativeapptemplate.nativeapptemplatefree.utils.DateUtility.cardTimeAgoInWordsDateString + +@Composable +fun CompleteScanResultView( + completeScanResult: CompleteScanResult, +) { + ContentView( + completeScanResult = completeScanResult, + ) +} + +@Composable +private fun ContentView( + completeScanResult: CompleteScanResult, +) { + when (completeScanResult.completeScanResultType) { + CompleteScanResultType.Completed, + CompleteScanResultType.Reset -> SucceededView(completeScanResult) + CompleteScanResultType.Failed -> FailedView(completeScanResult) + CompleteScanResultType.Idled -> IdledView() + } +} + +@Composable +private fun SucceededView( + completeScanResult: CompleteScanResult, +) { + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = LocalCustomColorScheme.current.successContainer), + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp) + ) { + val fontSizeMedium = 16 + val fontSizeLarge = 20 + val lineHeightMedium = 20 + val lineHeightLarge = 24 + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .padding(24.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Done, + contentDescription = null, + tint = LocalCustomColorScheme.current.onSuccessContainer, + ) + + Text( + "Result", + fontSize = fontSizeLarge.sp.nonScaledSp, + lineHeight = lineHeightLarge.sp.nonScaledSp, + style = MaterialTheme.typography.titleMedium, + color = LocalCustomColorScheme.current.onSuccessContainer, + ) + } + + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth(), + ) { + Text( + completeScanResult.itemTagData.queueNumber, + style = MaterialTheme.typography.displaySmall, + color = LocalCustomColorScheme.current.onSuccessContainer, + ) + } + + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth(), + ) { + when (completeScanResult.completeScanResultType) { + CompleteScanResultType.Completed -> CompletedTag() + else -> IdlingTag() + } + } + + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth(), + ) { + if (completeScanResult.completeScanResultType == CompleteScanResultType.Reset) { + Text( + completeScanResult.completeScanResultType.title, + fontSize = fontSizeLarge.sp.nonScaledSp, + lineHeight = lineHeightLarge.sp.nonScaledSp, + color = LocalCustomColorScheme.current.onSuccessContainer, + ) + } + } + + Row( + horizontalArrangement = Arrangement + .spacedBy( + space = 8.dp, + alignment = Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.Bottom, + modifier = Modifier + .fillMaxWidth(), + ) { + Text( + completeScanResult.itemTagInfoFromNdefMessage.scannedAt.cardTimeAgoInWordsDateString(), + fontSize = fontSizeLarge.sp.nonScaledSp, + lineHeight = lineHeightLarge.sp.nonScaledSp, + color = LocalCustomColorScheme.current.onSuccessContainer, + style = MaterialTheme.typography.titleMedium + ) + Text( + "complete scanned", + fontSize = fontSizeMedium.sp.nonScaledSp, + lineHeight = lineHeightMedium.sp.nonScaledSp, + color = LocalCustomColorScheme.current.onSuccessContainer, + ) + } + } + } +} + +@Composable +private fun FailedView( + completeScanResult: CompleteScanResult, +) { + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .padding(24.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + + Text( + "Error", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth(), + ) { + Text( + completeScanResult.message, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } +} + +@Composable +private fun IdledView( +) { + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = LocalCustomColorScheme.current.successContainer), + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .padding(24.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Done, + contentDescription = null, + tint = LocalCustomColorScheme.current.onSuccessContainer, + ) + + Text( + "Result", + style = MaterialTheme.typography.titleMedium, + color = LocalCustomColorScheme.current.onSuccessContainer, + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/DoScanView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/DoScanView.kt new file mode 100644 index 0000000..76f3684 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/DoScanView.kt @@ -0,0 +1,261 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.scan + +import android.nfc.NfcAdapter +import android.nfc.tech.Ndef +import android.os.Bundle +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Done +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.rememberLottieComposition +import com.nativeapptemplate.nativeapptemplatefree.R +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagInfoFromNdefMessage +import com.nativeapptemplate.nativeapptemplatefree.ui.common.MainButtonView +import com.nativeapptemplate.nativeapptemplatefree.utils.Utility +import com.nativeapptemplate.nativeapptemplatefree.utils.Utility.getActivity +import kotlinx.coroutines.delay +import java.util.Date + +@Composable +internal fun DoScanView( + viewModel: DoScanViewModel = hiltViewModel(), + onBackClick: () -> Unit, +) { + val uiState: DoScanUiState by viewModel.uiState.collectAsStateWithLifecycle() + + val context = LocalContext.current + val activity = context.getActivity() + val nfcAdapter = NfcAdapter.getDefaultAdapter(context) + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.updateScanViewSelectedTabIndex() + viewModel.updateExecFlagAfterScanning(false) + } + + LaunchedEffect(uiState.isScanned) { + if (uiState.isScanned) { + delay(2_000) + onBackClick() + } + } + + DisposableEffect(nfcAdapter) { + val nfcCallback = NfcAdapter.ReaderCallback { tag -> + if (tag != null) { + val ndef = Ndef.get(tag) + var itemTagInfoFromNdefMessage = ItemTagInfoFromNdefMessage() + + if (ndef != null) { + try { + ndef.connect() + + val ndefMessage = ndef.ndefMessage + itemTagInfoFromNdefMessage = Utility.extractItemTagInfoFrom( + context = context, + ndefMessage = ndefMessage, + isTest = viewModel.isTest + ) + + if (itemTagInfoFromNdefMessage.success) { + itemTagInfoFromNdefMessage.isReadOnly = !ndef.isWritable + itemTagInfoFromNdefMessage.scannedAt = Date().toInstant().toString() + } + } catch (e: Exception) { + itemTagInfoFromNdefMessage.success = false + itemTagInfoFromNdefMessage.message = + "Reading tag failed. Please try again(${e.message})" + } finally { + try { + ndef.close() + } catch (e: Exception) { + itemTagInfoFromNdefMessage.success = false + itemTagInfoFromNdefMessage.message = + "Reading tag failed. Please try again(${e.message})" + } + } + + viewModel.updateItemTagInfoFromNdefMessage(itemTagInfoFromNdefMessage) + viewModel.updateExecFlagAfterScanning(itemTagInfoFromNdefMessage.success) + viewModel.updateIsScanned(true) + } else { + itemTagInfoFromNdefMessage.success = false + itemTagInfoFromNdefMessage.message = "Invalid tag." + viewModel.updateItemTagInfoFromNdefMessage(itemTagInfoFromNdefMessage) + viewModel.updateExecFlagAfterScanning(false) + viewModel.updateIsScanned(true) + } + } + } + + val options = Bundle() + options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250) + + nfcAdapter.enableReaderMode( + activity, + nfcCallback, + NfcAdapter.FLAG_READER_NFC_A, + options, + ) + + onDispose { + nfcAdapter.disableReaderMode(activity) + } + } + + DoScanView( + uiState = uiState, + onBackClick = onBackClick, + ) +} + +@Composable +fun DoScanView( + uiState: DoScanUiState, + onBackClick: () -> Unit, +) { + ContentView( + uiState = uiState, + onBackClick = onBackClick, + ) +} + +@Composable +private fun ContentView( + uiState: DoScanUiState, + onBackClick: () -> Unit, +) { + DoScanContentView( + uiState = uiState, + onBackClick = onBackClick, + ) +} + +@Composable +private fun DoScanContentView( + uiState: DoScanUiState, + onBackClick: () -> Unit, +) { + val holdYourAndroidNearTheItemMessage = stringResource(R.string.hold_your_android_near_the_item) + + Scaffold( + modifier = Modifier + .widthIn(max = LocalConfiguration.current.screenWidthDp.dp), + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement + .spacedBy( + space = 16.dp, + alignment = Alignment.CenterVertically, + ), + modifier = Modifier + .padding(padding) + .padding(horizontal = 16.dp) + .align(Alignment.Center) + .verticalScroll(rememberScrollState()), + ) { + Card( + shape = RoundedCornerShape(16.dp), + border = BorderStroke( + width = 2.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + modifier = Modifier + .padding(24.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement + .spacedBy( + space = 16.dp, + alignment = Alignment.CenterVertically, + ), + modifier = Modifier + .padding(24.dp) + ) { + Text( + stringResource(R.string.ready_for_scanning), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) + + Text( + holdYourAndroidNearTheItemMessage, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) + + if (uiState.isScanned) { + Icon( + Icons.Outlined.Done, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(128.dp) + ) + } else { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.nfc_reader)) + LottieAnimation( + composition, + iterations = LottieConstants.IterateForever, + ) + } + + MainButtonView( + title = stringResource(R.string.cancel), + onClick = { onBackClick() }, + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 24.dp) + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/DoScanViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/DoScanViewModel.kt new file mode 100644 index 0000000..886e2fc --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/DoScanViewModel.kt @@ -0,0 +1,130 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.scan + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository +import com.nativeapptemplate.nativeapptemplatefree.model.CompleteScanResult +import com.nativeapptemplate.nativeapptemplatefree.model.CompleteScanResultType +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagInfoFromNdefMessage +import com.nativeapptemplate.nativeapptemplatefree.model.ShowTagInfoScanResult +import com.nativeapptemplate.nativeapptemplatefree.model.ShowTagInfoScanResultType +import com.nativeapptemplate.nativeapptemplatefree.ui.scan.navigation.DoScanRoute +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class DoScanUiState( + val isScanned: Boolean = false, + val isLoading: Boolean = false, +) + +@HiltViewModel +class DoScanViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val loginRepository: LoginRepository, + ) : ViewModel() { + val isTest = savedStateHandle.toRoute().isTest + + private val _uiState = MutableStateFlow(DoScanUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun updateItemTagInfoFromNdefMessage( + itemTagInfoFromNdefMessage: ItemTagInfoFromNdefMessage, + ) { + _uiState.update { it.copy(isLoading = true) } + + viewModelScope.launch { + if (isTest) { + val showTagInfoScanResult = ShowTagInfoScanResult() + showTagInfoScanResult.itemTagInfoFromNdefMessage = itemTagInfoFromNdefMessage + + if (!showTagInfoScanResult.itemTagInfoFromNdefMessage.success) { + showTagInfoScanResult.message = itemTagInfoFromNdefMessage.message + showTagInfoScanResult.showTagInfoScanResultType = ShowTagInfoScanResultType.Failed + } + + try { + loginRepository.setShowTagInfoScanResult(showTagInfoScanResult) + } catch (exception: Exception) { + val message = exception.message + showTagInfoScanResult.message = message ?: "Unknown Error" + showTagInfoScanResult.showTagInfoScanResultType = ShowTagInfoScanResultType.Failed + + loginRepository.setShowTagInfoScanResult(showTagInfoScanResult) + } finally { + _uiState.update { it.copy(isLoading = false) } + } + } else { + val completeScanResult = CompleteScanResult() + completeScanResult.itemTagInfoFromNdefMessage = itemTagInfoFromNdefMessage + + if (!completeScanResult.itemTagInfoFromNdefMessage.success) { + completeScanResult.message = itemTagInfoFromNdefMessage.message + completeScanResult.completeScanResultType = CompleteScanResultType.Failed + } + + try { + loginRepository.setCompleteScanResult(completeScanResult) + } catch (exception: Exception) { + val message = exception.message + completeScanResult.message = message ?: "Unknown Error" + completeScanResult.completeScanResultType = CompleteScanResultType.Failed + + loginRepository.setCompleteScanResult(completeScanResult) + } finally { + _uiState.update { it.copy(isLoading = false) } + } + } + } + } + + fun updateIsScanned(newIsScanned: Boolean) { + _uiState.update { + it.copy(isScanned = newIsScanned) + } + } + + fun updateScanViewSelectedTabIndex( + ) { + val scanViewSelectedTabIndex = if (isTest) 1 else 0 + _uiState.update { it.copy(isLoading = true) } + + viewModelScope.launch { + try { + loginRepository.setScanViewSelectedTabIndex(scanViewSelectedTabIndex) + } catch (exception: Exception) { + Log.e("DoScanViewModel", "Failed to update scanViewSelectedTabIndex", exception) + } finally { + _uiState.update { it.copy(isLoading = false) } + } + } + } + + fun updateExecFlagAfterScanning(execFlagAfterScanning: Boolean) { + if (isTest) { + updateShouldFetchItemTagForShowTagInfoScan(execFlagAfterScanning) + } else { + updateShouldCompleteItemTagForCompleteScan(execFlagAfterScanning) + } + } + + private fun updateShouldFetchItemTagForShowTagInfoScan(shouldFetchItemTagForShowTagInfoScan: Boolean) { + viewModelScope.launch { + loginRepository.setShouldFetchItemTagForShowTagInfoScan(shouldFetchItemTagForShowTagInfoScan) + } + } + + private fun updateShouldCompleteItemTagForCompleteScan(shouldCompleteItemTagForCompleteScan: Boolean) { + viewModelScope.launch { + loginRepository.setShouldCompleteItemTagForCompleteScan(shouldCompleteItemTagForCompleteScan) + } + } +} + diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/ScanView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/ScanView.kt new file mode 100644 index 0000000..80b2b23 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/ScanView.kt @@ -0,0 +1,405 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.scan + +import android.nfc.NfcAdapter +import androidx.annotation.StringRes +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.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Flag +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nativeapptemplate.nativeapptemplatefree.R +import com.nativeapptemplate.nativeapptemplatefree.ui.common.ErrorView +import com.nativeapptemplate.nativeapptemplatefree.ui.common.LoadingView +import com.nativeapptemplate.nativeapptemplatefree.ui.common.MainButtonView +import com.nativeapptemplate.nativeapptemplatefree.ui.common.NonScaledSp.nonScaledSp +import kotlinx.coroutines.launch + +enum class ScanPage( + @StringRes val titleResId: Int, +) { + COMPLETE_SCAN(R.string.complete_scan), + SHOW_TAG_INFO_SCAN(R.string.show_tag_info_scan) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ScanView( + viewModel: ScanViewModel = hiltViewModel(), + onShowDoScanViewClick: (Boolean) -> Unit, + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, + pages: Array = ScanPage.entries.toTypedArray(), +) { + val uiState: ScanUiState by viewModel.uiState.collectAsStateWithLifecycle() + val sheetState = rememberModalBottomSheetState() + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.reload() + } + + LaunchedEffect(uiState.message) { + if (uiState.message.isNotBlank()) { + onShowSnackbar(uiState.message, "dismiss", SnackbarDuration.Indefinite) + viewModel.snackbarMessageShown() + } + } + + LaunchedEffect(uiState.userData.shouldFetchItemTagForShowTagInfoScan) { + if (uiState.userData.shouldFetchItemTagForShowTagInfoScan) { + viewModel.fetchItemTagForShowTagInfoScan(uiState.showTagInfoScanResult.itemTagInfoFromNdefMessage) + viewModel.updateShouldFetchItemTagForShowTagInfoScan(false) + } + } + + LaunchedEffect(uiState.userData.shouldCompleteItemTagForCompleteScan) { + if (uiState.userData.shouldCompleteItemTagForCompleteScan) { + viewModel.completeItemTag(uiState.completeScanResult.itemTagInfoFromNdefMessage) + viewModel.updateShouldCompleteItemTagForCompleteScan(false) + } + } + + if (uiState.isAlreadyCompleted) { + ModalBottomSheet( + onDismissRequest = { + viewModel.updateIsAlreadyCompleted(false) + }, + sheetState = sheetState + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement + .spacedBy( + space = 16.dp, + alignment = Alignment.CenterVertically, + ), + modifier = Modifier + .padding(24.dp) + ) { + Text(stringResource(R.string.are_you_sure)) + + MainButtonView( + title = stringResource(R.string.reset), + onClick = { + viewModel.resetItemTag(uiState.completeScanResult.itemTagInfoFromNdefMessage) + }, + color = MaterialTheme.colorScheme.onErrorContainer, + titleColor = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier + .padding(horizontal = 24.dp) + ) + } + } + } + + ScanView( + viewModel = viewModel, + uiState = uiState, + onShowDoScanViewClick = onShowDoScanViewClick, + pages = pages, + ) +} + +@Composable +fun ScanView( + viewModel: ScanViewModel, + uiState: ScanUiState, + onShowDoScanViewClick: (Boolean) -> Unit, + pages: Array = ScanPage.entries.toTypedArray(), +) { + ContentView( + viewModel = viewModel, + uiState = uiState, + onShowDoScanViewClick = onShowDoScanViewClick, + pages = pages, + ) +} + +@Composable +private fun ContentView( + viewModel: ScanViewModel, + uiState: ScanUiState, + onShowDoScanViewClick: (Boolean) -> Unit, + pages: Array = ScanPage.entries.toTypedArray(), +) { + if (uiState.isLoading) { + ScanLoadingView() + } else if (uiState.success) { + ScanContentView( + viewModel = viewModel, + uiState = uiState, + onShowDoScanViewClick = onShowDoScanViewClick, + pages = pages, + ) + } else { + ScanErrorView(viewModel) + } +} + +@Composable +private fun ScanContentView( + viewModel: ScanViewModel, + uiState: ScanUiState, + onShowDoScanViewClick: (Boolean) -> Unit, + pages: Array = ScanPage.entries.toTypedArray(), +) { + val fontSizeMedium = 16 + val fontSizeLarge = 20 + val lineHeightMedium = 20 + val lineHeightLarge = 24 + val initialPage = uiState.userData.scanViewSelectedTabIndex + val pagerState = rememberPagerState(pageCount = { pages.size }, initialPage = initialPage) + val context = LocalContext.current + val doesDeviceSupportTagScanning = NfcAdapter.getDefaultAdapter(context) != null + val deviceDoesNotSupportTagScanningMessage = stringResource(R.string.this_device_does_not_support_tag_scanning) + + Scaffold { padding -> + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding), + ) { + val coroutineScope = rememberCoroutineScope() + + // Tab Row + TabRow( + selectedTabIndex = pagerState.currentPage + ) { + pages.forEachIndexed { index, page -> + val title = stringResource(id = page.titleResId) + Tab( + selected = pagerState.currentPage == index, + onClick = { + coroutineScope.launch { pagerState.animateScrollToPage(index) } + }, + text = { + Text( + title, + fontSize = fontSizeMedium.sp.nonScaledSp, + lineHeight = lineHeightMedium.sp.nonScaledSp, + ) + }, + unselectedContentColor = MaterialTheme.colorScheme.secondary + ) + } + } + + // Pages + HorizontalPager( + modifier = Modifier.background(MaterialTheme.colorScheme.background), + state = pagerState, + verticalAlignment = Alignment.Top + ) { index -> + when (pages[index]) { + ScanPage.COMPLETE_SCAN -> { + val scrollStateForCompleteScan = rememberScrollState() + + Column( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 16.dp) + .verticalScroll(scrollStateForCompleteScan), + ) { + if (!uiState.isAlreadyCompleted) { + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primary), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .padding(24.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Flag, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + + Text( + stringResource(R.string.complete_scan), + fontSize = fontSizeLarge.sp.nonScaledSp, + lineHeight = lineHeightLarge.sp.nonScaledSp, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + MainButtonView( + title = stringResource(R.string.title_scan), + onClick = { + if (doesDeviceSupportTagScanning) { + onShowDoScanViewClick(false) + } else { + viewModel.updateMessage(deviceDoesNotSupportTagScanningMessage) + } + }, + color = MaterialTheme.colorScheme.onPrimary, + titleColor = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .padding(horizontal = 24.dp) + ) + + Text( + stringResource(R.string.complete_scan_help), + fontSize = fontSizeMedium.sp.nonScaledSp, + lineHeight = lineHeightMedium.sp.nonScaledSp, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } + CompleteScanResultView(uiState.completeScanResult) + } + } + + ScanPage.SHOW_TAG_INFO_SCAN -> { + val scrollStateForShowTagInfoScan = rememberScrollState() + + Column( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 16.dp) + .verticalScroll(scrollStateForShowTagInfoScan), + ) { + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.inverseSurface), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .padding(24.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.inverseOnSurface, + ) + + Text( + stringResource(R.string.show_tag_info_scan), + fontSize = fontSizeLarge.sp.nonScaledSp, + lineHeight = lineHeightLarge.sp.nonScaledSp, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.inverseOnSurface, + ) + } + MainButtonView( + title = stringResource(R.string.title_scan), + onClick = { + if (doesDeviceSupportTagScanning) { + onShowDoScanViewClick(true) + } else { + viewModel.updateMessage(deviceDoesNotSupportTagScanningMessage) + } + }, + color = MaterialTheme.colorScheme.inverseOnSurface, + titleColor = MaterialTheme.colorScheme.inverseOnSurface, + modifier = Modifier + .padding(horizontal = 24.dp) + ) + + Text( + stringResource(R.string.show_tag_info_scan_help), + fontSize = fontSizeMedium.sp.nonScaledSp, + lineHeight = lineHeightMedium.sp.nonScaledSp, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.inverseOnSurface, + ) + } + } + + ShowTagInfoScanResultView(uiState.showTagInfoScanResult) + } + } + } + } + } + } +} + +@Composable +private fun ScanErrorView( + viewModel: ScanViewModel, +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding), + contentAlignment = Alignment.Center + ) { + ErrorView( + onClick = { viewModel.reload() } + ) + } + } +} + +@Composable +private fun ScanLoadingView( +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding), + contentAlignment = Alignment.Center + ) { + LoadingView() + } + } +} + + + diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/ScanViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/ScanViewModel.kt new file mode 100644 index 0000000..db25d0d --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/ScanViewModel.kt @@ -0,0 +1,280 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.scan + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nativeapptemplate.nativeapptemplatefree.data.item_tag.ItemTagRepository +import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository +import com.nativeapptemplate.nativeapptemplatefree.model.CompleteScanResult +import com.nativeapptemplate.nativeapptemplatefree.model.CompleteScanResultType +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagData +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagInfoFromNdefMessage +import com.nativeapptemplate.nativeapptemplatefree.model.ShowTagInfoScanResult +import com.nativeapptemplate.nativeapptemplatefree.model.ShowTagInfoScanResultType +import com.nativeapptemplate.nativeapptemplatefree.model.UserData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ScanUiState( + val userData: UserData = UserData(), + val itemTagForShowTagInfoScan: ItemTag = ItemTag(), + + val showTagInfoScanResult: ShowTagInfoScanResult = ShowTagInfoScanResult(), + val completeScanResult: CompleteScanResult = CompleteScanResult(), + + val isAlreadyCompleted: Boolean = false, + + val isLoading: Boolean = true, + val success: Boolean = false, + val message: String = "", +) + +/** + * ViewModel for library view + */ +@HiltViewModel +class ScanViewModel @Inject constructor( + private val loginRepository: LoginRepository, + private val itemTagRepository: ItemTagRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(ScanUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun reload() { + fetchData() + } + + private fun fetchData() { + _uiState.update { + it.copy( + isLoading = true, + success = false, + ) + } + + viewModelScope.launch { + val userDataFlow: Flow = loginRepository.userData + val showTagInfoScanResultFlow: Flow = loginRepository.showTagInfoScanResult() + val completeScanResultFlow: Flow = loginRepository.completeScanResult() + + combine( + userDataFlow, + showTagInfoScanResultFlow, + completeScanResultFlow, + ) { array -> + val userData = array[0] as UserData + val showTagInfoScanResult = array[1] as ShowTagInfoScanResult + val completeScanResult = array[2] as CompleteScanResult + + _uiState.update { + it.copy( + userData = userData, + completeScanResult = completeScanResult, + showTagInfoScanResult = showTagInfoScanResult, + success = true, + isLoading = false, + ) + } + }.catch { exception -> + val message = exception.message + _uiState.update { + it.copy( + message = message ?: "Unknown Error", + isLoading = false, + ) + } + }.first() + } + } + + fun fetchItemTagForShowTagInfoScan(itemTagInfoFromNdefMessage: ItemTagInfoFromNdefMessage) { + _uiState.update { + it.copy( + isLoading = true, + ) + } + + viewModelScope.launch { + val itemTagFlow: Flow = itemTagRepository.getItemTag(itemTagInfoFromNdefMessage.id) + + itemTagFlow + .catch { exception -> + val message = exception.message + val showTagInfoScanResult = uiState.value.showTagInfoScanResult + showTagInfoScanResult.showTagInfoScanResultType = ShowTagInfoScanResultType.Failed + showTagInfoScanResult.message = message ?: "Unknown Error" + + loginRepository.setShowTagInfoScanResult(showTagInfoScanResult) + + _uiState.update { + it.copy( + isLoading = false, + ) + } + } + .collect { itemTag -> + _uiState.update { it.copy(itemTagForShowTagInfoScan = itemTag) } + + val showTagInfoScanResult = uiState.value.showTagInfoScanResult + showTagInfoScanResult.itemTagInfoFromNdefMessage = itemTagInfoFromNdefMessage + + val itemTagData = ItemTagData(itemTag) + showTagInfoScanResult.itemTagData = itemTagData + + showTagInfoScanResult.showTagInfoScanResultType = ShowTagInfoScanResultType.Succeeded + + loginRepository.setShowTagInfoScanResult(showTagInfoScanResult) + + _uiState.update { + it.copy( + isLoading = false, + ) + } + } + + delay(200) + reload() + } + } + + fun completeItemTag(itemTagInfoFromNdefMessage: ItemTagInfoFromNdefMessage) { + _uiState.update { + it.copy( + isAlreadyCompleted = false, + isLoading = true, + ) + } + + viewModelScope.launch { + val itemTagFlow: Flow = itemTagRepository.completeItemTag(itemTagInfoFromNdefMessage.id) + + itemTagFlow + .catch { exception -> + val message = exception.message + val completeScanResult = uiState.value.completeScanResult + completeScanResult.completeScanResultType = CompleteScanResultType.Failed + completeScanResult.message = message ?: "Unknown Error" + + loginRepository.setCompleteScanResult(completeScanResult) + + _uiState.update { + it.copy( + isLoading = false + ) + } + } + .collect { itemTag -> + val completeScanResult = uiState.value.completeScanResult + completeScanResult.itemTagInfoFromNdefMessage = itemTagInfoFromNdefMessage + + val itemTagData = ItemTagData(itemTag) + completeScanResult.itemTagData = itemTagData + + if (itemTagData.alreadyCompleted) { + _uiState.update { it.copy(isAlreadyCompleted = true) } + completeScanResult.completeScanResultType = CompleteScanResultType.Completed + } else { + completeScanResult.completeScanResultType = CompleteScanResultType.Completed + } + + loginRepository.setCompleteScanResult(completeScanResult) + + _uiState.update { + it.copy( + isLoading = false, + ) + } + } + + delay(200) + reload() + } + } + + fun resetItemTag(itemTagInfoFromNdefMessage: ItemTagInfoFromNdefMessage) { + _uiState.update { + it.copy( + isLoading = true, + isAlreadyCompleted = false, + ) + } + + viewModelScope.launch { + val itemTagFlow: Flow = itemTagRepository.resetItemTag(itemTagInfoFromNdefMessage.id) + + itemTagFlow + .catch { exception -> + val message = exception.message + val completeScanResult = uiState.value.completeScanResult + completeScanResult.completeScanResultType = CompleteScanResultType.Failed + completeScanResult.message = message ?: "Unknown Error" + + loginRepository.setCompleteScanResult(completeScanResult) + + _uiState.update { + it.copy( + isLoading = false, + ) + } + } + .collect { itemTag -> + val completeScanResult = uiState.value.completeScanResult + completeScanResult.itemTagInfoFromNdefMessage = itemTagInfoFromNdefMessage + + val itemTagData = ItemTagData(itemTag) + completeScanResult.itemTagData = itemTagData + completeScanResult.completeScanResultType = CompleteScanResultType.Reset + + loginRepository.setCompleteScanResult(completeScanResult) + + _uiState.update { + it.copy( + isLoading = false, + ) + } + } + + delay(200) + reload() + } + } + + fun updateMessage(newMessage: String) { + _uiState.update { + it.copy(message = newMessage) + } + } + + fun updateIsAlreadyCompleted(newIsAlreadyCompleted: Boolean) { + _uiState.update { + it.copy(isAlreadyCompleted = newIsAlreadyCompleted) + } + } + + fun updateShouldFetchItemTagForShowTagInfoScan(shouldFetchItemTagForShowTagInfoScan: Boolean) { + viewModelScope.launch { + loginRepository.setShouldFetchItemTagForShowTagInfoScan(shouldFetchItemTagForShowTagInfoScan) + } + } + + fun updateShouldCompleteItemTagForCompleteScan(shouldCompleteItemTagForCompleteScan: Boolean) { + viewModelScope.launch { + loginRepository.setShouldCompleteItemTagForCompleteScan(shouldCompleteItemTagForCompleteScan) + } + } + + fun snackbarMessageShown() { + _uiState.update { it.copy(message = "") } + } +} + diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/ShowTagInfoScanResultView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/ShowTagInfoScanResultView.kt new file mode 100644 index 0000000..6450720 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/ShowTagInfoScanResultView.kt @@ -0,0 +1,381 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.scan + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccessTime +import androidx.compose.material.icons.outlined.Error +import androidx.compose.material.icons.outlined.Flag +import androidx.compose.material.icons.outlined.FlagCircle +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.People +import androidx.compose.material.icons.outlined.Rectangle +import androidx.compose.material.icons.outlined.Storefront +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nativeapptemplate.nativeapptemplatefree.R +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagState +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagType +import com.nativeapptemplate.nativeapptemplatefree.model.ScanState +import com.nativeapptemplate.nativeapptemplatefree.model.ShowTagInfoScanResult +import com.nativeapptemplate.nativeapptemplatefree.model.ShowTagInfoScanResultType +import com.nativeapptemplate.nativeapptemplatefree.ui.common.NonScaledSp.nonScaledSp +import com.nativeapptemplate.nativeapptemplatefree.ui.common.tags.CompletedTag +import com.nativeapptemplate.nativeapptemplatefree.ui.common.tags.IdlingTag +import com.nativeapptemplate.nativeapptemplatefree.utils.DateUtility.cardDateString +import com.nativeapptemplate.nativeapptemplatefree.utils.DateUtility.cardTimeAgoInWordsDateString +import com.nativeapptemplate.nativeapptemplatefree.utils.DateUtility.cardTimeString + +@Composable +fun ShowTagInfoScanResultView( + showTagInfoScanResult: ShowTagInfoScanResult, +) { + ContentView( + showTagInfoScanResult = showTagInfoScanResult, + ) +} + +@Composable +private fun ContentView( + showTagInfoScanResult: ShowTagInfoScanResult, +) { + when (showTagInfoScanResult.showTagInfoScanResultType) { + ShowTagInfoScanResultType.Succeeded -> SucceededView(showTagInfoScanResult) + ShowTagInfoScanResultType.Failed -> FailedView(showTagInfoScanResult) + ShowTagInfoScanResultType.Idled -> IdledView() + } +} + +@Composable +private fun SucceededView( + showTagInfoScanResult: ShowTagInfoScanResult, +) { + val itemTagType = showTagInfoScanResult.itemTagInfoFromNdefMessage.itemTagType + val itemTagData = showTagInfoScanResult.itemTagData + val itemTagTypeColor = if (itemTagType == ItemTagType.Server) Color.Red else Color.Blue + val isReadOnly = showTagInfoScanResult.itemTagInfoFromNdefMessage.isReadOnly + val displayReadOnly = if (isReadOnly) stringResource(R.string.read_only) else stringResource(R.string.writable) + + val fontSizeMedium = 16 + val fontSizeLarge = 20 + val lineHeightMedium = 20 + val lineHeightLarge = 24 + + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.inverseSurface), + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .padding(24.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Rectangle, + contentDescription = null, + tint = MaterialTheme.colorScheme.inverseOnSurface, + ) + + Text( + stringResource(R.string.tag_info), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.inverseOnSurface, + ) + } + + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth(), + ) { + Text( + showTagInfoScanResult.itemTagData.queueNumber, + color = itemTagTypeColor, + style = MaterialTheme.typography.displaySmall + ) + } + + Row( + horizontalArrangement = Arrangement + .spacedBy( + space = 8.dp, + alignment = Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.Bottom, + modifier = Modifier + .fillMaxWidth(), + ) { + Text( + showTagInfoScanResult.itemTagInfoFromNdefMessage.scannedAt.cardTimeAgoInWordsDateString(), + fontSize = fontSizeLarge.sp.nonScaledSp, + lineHeight = lineHeightLarge.sp.nonScaledSp, + color = MaterialTheme.colorScheme.inverseOnSurface, + style = MaterialTheme.typography.titleMedium, + ) + Text( + "show tag info scanned", + fontSize = fontSizeMedium.sp.nonScaledSp, + lineHeight = lineHeightMedium.sp.nonScaledSp, + color = MaterialTheme.colorScheme.inverseOnSurface, + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(top = 16.dp), + ) { + Row( + horizontalArrangement = Arrangement + .spacedBy( + space = 8.dp, + alignment = Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.Bottom + ) { + Icon( + Icons.Outlined.Storefront, + contentDescription = null + ) + + Text( + itemTagData.shopName, + fontSize = fontSizeLarge.sp.nonScaledSp, + lineHeight = lineHeightLarge.sp.nonScaledSp, + color = MaterialTheme.colorScheme.inverseOnSurface, + style = MaterialTheme.typography.titleLarge, + ) + } + + InfoRow( + Icons.Outlined.Info, + "tag type" + ) { + Text( + itemTagType.title, + fontSize = fontSizeLarge.sp.nonScaledSp, + lineHeight = lineHeightLarge.sp.nonScaledSp, + color = itemTagTypeColor, + style = MaterialTheme.typography.titleLarge + ) + } + + InfoRow( + Icons.Outlined.Flag, + "tag status" + ) { + when (itemTagData.state) { + ItemTagState.Completed -> CompletedTag() + else -> IdlingTag() + } + } + + if (itemTagData.scanState == ScanState.Scanned && itemTagData.customerReadAt.isNotBlank()) { + InfoRow( + Icons.Outlined.People, + "scanned by a customer" + ) { + Text( + itemTagData.customerReadAt.cardTimeString(), + fontSize = fontSizeLarge.sp.nonScaledSp, + lineHeight = lineHeightLarge.sp.nonScaledSp, + color = MaterialTheme.colorScheme.inverseOnSurface, + style = MaterialTheme.typography.titleLarge + ) + } + } + + if (itemTagData.state == ItemTagState.Completed && itemTagData.completedAt.isNotBlank()) { + InfoRow( + Icons.Outlined.FlagCircle, + "completed" + ) { + Text( + itemTagData.completedAt.cardTimeString(), + fontSize = fontSizeLarge.sp.nonScaledSp, + lineHeight = lineHeightLarge.sp.nonScaledSp, + color = MaterialTheme.colorScheme.inverseOnSurface, + style = MaterialTheme.typography.titleLarge + ) + } + } + + InfoRow( + Icons.Outlined.Rectangle, + "NFC tag" + ) { + Text( + displayReadOnly, + fontSize = fontSizeLarge.sp.nonScaledSp, + lineHeight = lineHeightLarge.sp.nonScaledSp, + color = MaterialTheme.colorScheme.inverseOnSurface, + style = MaterialTheme.typography.titleLarge + ) + } + + InfoRow( + Icons.Outlined.AccessTime, + "created" + ) { + Text( + itemTagData.createdAt.cardDateString(), + fontSize = fontSizeLarge.sp.nonScaledSp, + lineHeight = lineHeightLarge.sp.nonScaledSp, + color = MaterialTheme.colorScheme.inverseOnSurface, + style = MaterialTheme.typography.titleLarge + ) + } + } + } + } +} + +@Composable +private fun FailedView( + showTagInfoScanResult: ShowTagInfoScanResult, +) { + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .padding(24.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + + Text( + "Error", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth(), + ) { + Text( + showTagInfoScanResult.message, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } +} + +@Composable +private fun IdledView( +) { + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.inverseSurface), + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .padding(24.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Rectangle, + contentDescription = null, + tint = MaterialTheme.colorScheme.inverseOnSurface, + ) + + Text( + "Tag Info", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.inverseOnSurface, + ) + } + } + } +} + +@Composable +private fun InfoRow( + icon: ImageVector, + infoLabel: String, + content: @Composable () -> Unit, +) { + val fontSizeMedium = 16 + val lineHeightMedium = 20 + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement + .spacedBy( + space = 8.dp, + alignment = Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null + ) + + Row( + modifier = Modifier + .width(128.dp) + ) { + content() + } + + Text( + text = infoLabel, + fontSize = fontSizeMedium.sp.nonScaledSp, + lineHeight = lineHeightMedium.sp.nonScaledSp, + modifier = Modifier + .padding(start = 8.dp) + ) + } + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/navigation/ScanNavigation.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/navigation/ScanNavigation.kt new file mode 100644 index 0000000..494c921 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/navigation/ScanNavigation.kt @@ -0,0 +1,62 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.scan.navigation + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.ui.window.DialogProperties +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.compose.composable +import androidx.navigation.compose.dialog +import androidx.navigation.compose.navigation +import com.nativeapptemplate.nativeapptemplatefree.ui.scan.DoScanView +import com.nativeapptemplate.nativeapptemplatefree.ui.scan.ScanView +import kotlinx.serialization.Serializable + +@Serializable data object ScanBaseRoute +@Serializable data object ScanRoute +@Serializable data class DoScanRoute(val isTest: Boolean) + +fun NavGraphBuilder.scanBaseView( + destination: NavGraphBuilder.() -> Unit, +) { + navigation(startDestination = ScanRoute) { + destination() + } +} + +fun NavController.navigateToScan(navOptions: NavOptions? = null) = navigate(route = ScanRoute, navOptions) + +fun NavGraphBuilder.scanView( + onShowDoScanViewClick: (Boolean) -> Unit, + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, +) { + composable { + ScanView( + onShowDoScanViewClick = onShowDoScanViewClick, + onShowSnackbar = onShowSnackbar, + ) + } +} + +fun NavController.navigateToDoScan(isTest: Boolean, navOptions: NavOptionsBuilder.() -> Unit = {}) { + navigate(route = DoScanRoute(isTest)) { + navOptions() + } +} + +fun NavGraphBuilder.doScanView( + onBackClick: () -> Unit, +) { + dialog( + dialogProperties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = false, + dismissOnClickOutside = false, + ) + ) { + DoScanView( + onBackClick = onBackClick, + ) + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/SettingsView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/SettingsView.kt index 975face..9800afb 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/SettingsView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/SettingsView.kt @@ -15,8 +15,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Forum import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Mail import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.QuestionMark import androidx.compose.material.icons.outlined.ThumbUp import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api @@ -43,6 +45,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nativeapptemplate.nativeapptemplatefree.BuildConfig import com.nativeapptemplate.nativeapptemplatefree.NatConstants import com.nativeapptemplate.nativeapptemplatefree.R import com.nativeapptemplate.nativeapptemplatefree.model.DarkThemeConfig @@ -270,7 +273,7 @@ private fun SettingsContentView( }, leadingContent = { Icon( - Icons.Outlined.Info, + Icons.Outlined.Language, contentDescription = stringResource(R.string.support_website), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -287,6 +290,57 @@ private fun SettingsContentView( ) HorizontalDivider() } + item { + ListItem( + headlineContent = { + Text( + stringResource(R.string.how_to_use), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + }, + leadingContent = { + Icon( + Icons.Outlined.Info, + contentDescription = stringResource(R.string.how_to_use), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = Modifier + .clickable { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse(NatConstants.HOW_TO_USE_URL) + ) + ) + }, + ) + HorizontalDivider() + } + item { + ListItem( + headlineContent = { + Text( + stringResource(R.string.faqs), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + }, + leadingContent = { + Icon( + Icons.Outlined.QuestionMark, + contentDescription = stringResource(R.string.faqs), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = Modifier + .clickable { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(NatConstants.FAQS_URL))) + }, + ) + HorizontalDivider() + } item { ListItem( headlineContent = { @@ -430,6 +484,13 @@ private fun SettingsContentView( ) } } + + if (BuildConfig.DEBUG) { + item { + Text("accountOwnerId: ${userData.accountOwnerId}") + HorizontalDivider() + } + } } } } @@ -491,6 +552,3 @@ private fun SettingsLoadingView( } } } - - - diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailCardView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailCardView.kt new file mode 100644 index 0000000..b71d7d0 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailCardView.kt @@ -0,0 +1,88 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_detail + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagState +import com.nativeapptemplate.nativeapptemplatefree.model.ScanState +import com.nativeapptemplate.nativeapptemplatefree.ui.common.tags.CompletedTag +import com.nativeapptemplate.nativeapptemplatefree.ui.common.tags.CustomerScannedTag +import com.nativeapptemplate.nativeapptemplatefree.ui.common.tags.IdlingTag +import com.nativeapptemplate.nativeapptemplatefree.utils.DateUtility.cardTimeString + +@Composable +fun ShopDetailCardView( + data: Data, +) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp), + ) { + val queueNumberFontSize = with(LocalDensity.current) { MaterialTheme.typography.titleLarge.fontSize.value.dp.toSp() } + val timestampFontSize = with(LocalDensity.current) { MaterialTheme.typography.bodySmall.fontSize.value.dp.toSp() } + val customerReadAt = data.getCustomerReadAt() + val completedAt = data.getCompletedAt() + + Text( + data.getQueueNumber(), + style = MaterialTheme.typography.titleLarge, + fontSize = queueNumberFontSize, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Column( + horizontalAlignment = Alignment.End + ) { + + data.getScanState()?.let { scanState -> + if (scanState == ScanState.Scanned) { + CustomerScannedTag() + Text( + customerReadAt.cardTimeString(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(top = 4.dp), + fontSize = timestampFontSize, + ) + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + Column( + horizontalAlignment = Alignment.End + ) { + data.getItemTagState()?.let { itemTagState -> + when (itemTagState) { + ItemTagState.Completed -> { + CompletedTag() + + Text( + completedAt.cardTimeString(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(top = 4.dp), + fontSize = timestampFontSize, + ) + } + else -> { + IdlingTag() + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailView.kt index 16b77de..0604c3c 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailView.kt @@ -7,33 +7,56 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip +import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nativeapptemplate.nativeapptemplatefree.NatConstants +import com.nativeapptemplate.nativeapptemplatefree.R +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagState.* +import com.nativeapptemplate.nativeapptemplatefree.ui.common.ActionText import com.nativeapptemplate.nativeapptemplatefree.ui.common.ErrorView import com.nativeapptemplate.nativeapptemplatefree.ui.common.LoadingView +import com.nativeapptemplate.nativeapptemplatefree.ui.common.SwipeableItemWithActions @Composable internal fun ShopDetailView( @@ -89,6 +112,7 @@ private fun ContentView( ShopDetailLoadingView(uiState, onSettingsClick, onBackClick) } else if (uiState.success) { ShopDetailContentView( + viewModel = viewModel, uiState = uiState, onSettingsClick = onSettingsClick, onBackClick = onBackClick, @@ -98,12 +122,16 @@ private fun ContentView( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ShopDetailContentView( + viewModel: ShopDetailViewModel, uiState: ShopDetailUiState, onSettingsClick: (String) -> Unit, onBackClick: () -> Unit, ) { + val itemTags = uiState.itemTags.getDatumWithRelationships().toMutableList() + Scaffold( topBar = { TopAppBar( @@ -114,39 +142,200 @@ private fun ShopDetailContentView( }, modifier = Modifier.fillMaxSize(), ) { padding -> + val pullToRefreshState = rememberPullToRefreshState() + Box( modifier = Modifier .fillMaxWidth() .fillMaxHeight() + .pullToRefresh( + state = pullToRefreshState, + isRefreshing = uiState.isLoading, + onRefresh = viewModel::reload, + ) .padding(padding) ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp), - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(16.dp) + LazyColumn( + Modifier.padding(24.dp) ) { - Text( - uiState.shop.getName(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.displaySmall, - modifier = Modifier - .fillMaxWidth() + item { + if (!uiState.didShowReadInstructionsTip) { + ReadInstructionsTip( + stringResource(R.string.read_instructions), + ) { + viewModel.updateDidShowReadInstructionsTip(true) + } + } + } + + item { + Surface(Modifier.fillParentMaxWidth()) { + Header( + uiState = uiState + ) + } + } + itemsIndexed( + items = uiState.itemTags.getDatumWithRelationships(), + ) { index, itemTag -> + val itemTagState = itemTag.getItemTagState() + + SwipeableItemWithActions( + isRevealed = itemTag.isOptionsRevealed, + onExpanded = { + itemTags[index] = itemTag.copy(isOptionsRevealed = true) + }, + onCollapsed = { + itemTags[index] = itemTag.copy(isOptionsRevealed = false) + }, + actions = { + if (itemTagState == Idled) { + ActionText( + onClick = { + viewModel.completeItemTag(itemTag.id!!) + }, + backgroundColor = Color.Blue, + text = "Complete", + modifier = Modifier + .fillMaxHeight() + .width(64.dp) + ) + } else { + ActionText( + onClick = { + viewModel.resetItemTag(itemTag.id!!) + }, + backgroundColor = Color.Red, + text = "Reset", + modifier = Modifier.fillMaxHeight() + ) + } + }, + ) { + ShopDetailCardView( + data = itemTag, + ) + } + + HorizontalDivider() + } + } + Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = uiState.isLoading, + state = pullToRefreshState, + ) + } + } +} + +@Composable +private fun Header( + uiState: ShopDetailUiState, +) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + "${stringResource(R.string.instructions)}:", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + val instruction1 = buildAnnotatedString { + withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.onSurfaceVariant)) { + append("1. ") + append(stringResource(R.string.open)) + append(" ") + } + + withLink( + LinkAnnotation.Url( + uiState.shop.displayShopServerUrlString(NatConstants.baseUrlString()), + TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) ) + ) { + append(stringResource(R.string.server_number_tags_webpage)) + } + + withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.onSurfaceVariant)) { + append(".") + } + } + + val instruction2 = buildAnnotatedString { + append("2. ") + append(stringResource(R.string.swipe_number_tag_below)) + append(" ") + append(stringResource(R.string.tap_displayed_button)) + } - Text( - uiState.shop.getDescription(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.displaySmall, - modifier = Modifier - .fillMaxWidth() + val instruction3 = buildAnnotatedString { + append("3. ") + append(stringResource(R.string.server_number_tags_webpage_will_be_updated)) + } + + val learnMore = buildAnnotatedString { + withLink( + LinkAnnotation.Url( + NatConstants.HOW_TO_USE_URL, + TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) ) + ) { + append(stringResource(R.string.learn_more)) } } + + Text(instruction1) + + Text( + instruction2, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + instruction3, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text(learnMore) } } +@Composable +fun ReadInstructionsTip( + text: String, + onDismiss: () -> Unit, +) { + InputChip( + onClick = { + onDismiss() + }, + label = { Text( + text, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.tertiary, + ) }, + selected = false, + avatar = { + Icon( + Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(InputChipDefaults.AvatarSize) + ) + }, + trailingIcon = { + Icon( + Icons.Default.Close, + contentDescription = null, + Modifier.size(InputChipDefaults.AvatarSize) + ) + }, + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TopAppBar( diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailViewModel.kt index 3c880f1..39b11b1 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailViewModel.kt @@ -4,7 +4,11 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.nativeapptemplate.nativeapptemplatefree.data.item_tag.ItemTagRepository +import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository import com.nativeapptemplate.nativeapptemplatefree.data.shop.ShopRepository +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTags import com.nativeapptemplate.nativeapptemplatefree.model.Shop import com.nativeapptemplate.nativeapptemplatefree.ui.shop_detail.navigation.ShopDetailRoute import dagger.hilt.android.lifecycle.HiltViewModel @@ -13,6 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -22,6 +27,8 @@ data class ShopDetailUiState( val success: Boolean = false, val message: String = "", val shop: Shop = Shop(), + val itemTags: ItemTags = ItemTags(), + val didShowReadInstructionsTip: Boolean = false, ) /** @@ -30,7 +37,9 @@ data class ShopDetailUiState( @HiltViewModel class ShopDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val loginRepository: LoginRepository, private val shopRepository: ShopRepository, + private val itemTagRepository: ItemTagRepository, ) : ViewModel() { private val shopId = savedStateHandle.toRoute().id private val _uiState = MutableStateFlow(ShopDetailUiState()) @@ -50,8 +59,82 @@ class ShopDetailViewModel @Inject constructor( viewModelScope.launch { val shopFlow: Flow = shopRepository.getShop(shopId) + val itemTagsFlow: Flow = itemTagRepository.getItemTags(shopId) + val didShowReadInstructionsTipFlow = loginRepository.didShowReadInstructionsTip() - shopFlow + combine( + shopFlow, + itemTagsFlow, + didShowReadInstructionsTipFlow, + ) { shop, + itemTags, + didShowReadInstructionsTip, -> + _uiState.update { + it.copy( + shop = shop, + itemTags = itemTags, + didShowReadInstructionsTip = didShowReadInstructionsTip, + success = true, + isLoading = false, + ) + } + }.catch { exception -> + val message = exception.message + _uiState.update { + it.copy( + message = message ?: "Unknown Error", + isLoading = false, + ) + } + }.collect { + } + } + } + + fun completeItemTag(itemTagId: String) { + _uiState.update { + it.copy( + isLoading = true, + ) + } + + viewModelScope.launch { + val itemTagFlow: Flow = itemTagRepository.completeItemTag(itemTagId) + + itemTagFlow + .catch { exception -> + val message = exception.message + + _uiState.update { + it.copy( + message = message ?: "Unknown Error", + isLoading = false, + ) + } + } + .collect { + _uiState.update { + it.copy( + isLoading = false, + ) + } + + reload() + } + } + } + + fun resetItemTag(itemTagId: String) { + _uiState.update { + it.copy( + isLoading = true, + ) + } + + viewModelScope.launch { + val itemTagFlow: Flow = itemTagRepository.resetItemTag(itemTagId) + + itemTagFlow .catch { exception -> val message = exception.message _uiState.update { @@ -61,18 +144,30 @@ class ShopDetailViewModel @Inject constructor( ) } } - .collect { shop -> + .collect { _uiState.update { it.copy( - shop = shop, - success = true, isLoading = false, ) } + + reload() } } } + fun updateMessage(newMessage: String) { + _uiState.update { + it.copy(message = newMessage) + } + } + + fun updateDidShowReadInstructionsTip(didShowReadInstructionsTip: Boolean) { + viewModelScope.launch { + loginRepository.setDidShowReadInstructionsTip(didShowReadInstructionsTip) + } + } + fun snackbarMessageShown() { _uiState.update { it.copy(message = "") } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/NumberTagsWebpageListView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/NumberTagsWebpageListView.kt new file mode 100644 index 0000000..86777a9 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/NumberTagsWebpageListView.kt @@ -0,0 +1,229 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.outlined.Web +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nativeapptemplate.nativeapptemplatefree.NatConstants +import com.nativeapptemplate.nativeapptemplatefree.R +import com.nativeapptemplate.nativeapptemplatefree.ui.common.ErrorView +import com.nativeapptemplate.nativeapptemplatefree.ui.common.LoadingView + +@Composable +internal fun NumberTagsWebpageListView( + viewModel: NumberTagsWebpageListViewModel = hiltViewModel(), + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, + onBackClick: () -> Unit, +) { + val uiState: NumberTagsWebpageListUiState by viewModel.uiState.collectAsStateWithLifecycle() + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.reload() + } + + LaunchedEffect(uiState.message) { + if (uiState.message.isNotBlank()) { + onShowSnackbar(uiState.message, "dismiss", SnackbarDuration.Indefinite) + viewModel.snackbarMessageShown() + } + } + + NumberTagsWebpageListView( + viewModel = viewModel, + uiState = uiState, + onBackClick = onBackClick, + ) +} + +@Composable +fun NumberTagsWebpageListView( + viewModel: NumberTagsWebpageListViewModel, + uiState: NumberTagsWebpageListUiState, + onBackClick: () -> Unit, +) { + ContentView( + viewModel = viewModel, + uiState = uiState, + onBackClick = onBackClick, + ) +} + +@Composable +private fun ContentView( + viewModel: NumberTagsWebpageListViewModel, + uiState: NumberTagsWebpageListUiState, + onBackClick: () -> Unit, +) { + if (uiState.isLoading) { + NumberTagsWebpageListLoadingView(onBackClick) + } else if (uiState.success) { + NumberTagsWebpageListContentView( + uiState = uiState, + onBackClick = onBackClick, + ) + } else { + NumberTagsWebpageListErrorView(viewModel, onBackClick) + } +} + +@Composable +private fun NumberTagsWebpageListContentView( + uiState: NumberTagsWebpageListUiState, + onBackClick: () -> Unit, +) { + val localClipboardManager = LocalClipboardManager.current + + Scaffold( + topBar = { + TopAppBar( + onBackClick, + ) + }, + modifier = Modifier.fillMaxSize(), + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding) + ) { + LazyColumn( + Modifier.padding(24.dp) + ) { + item { + Text( + uiState.shop.getName(), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth(), + ) + } + + item { + ListItem( + headlineContent = { + Text( + stringResource(R.string.server_number_tags_webpage), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleMedium, + ) + }, + leadingContent = { + Icon( + Icons.Outlined.Web, + contentDescription = stringResource(R.string.label_shop_settings_number_tags_webpage), + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clickable { + localClipboardManager.setText(AnnotatedString(uiState.shop.displayShopServerUrlString(NatConstants.baseUrlString()))) + }, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopAppBar( + onBackClick: () -> Unit, +) { + CenterAlignedTopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + ), + title = { Text(stringResource(R.string.label_shop_settings_number_tags_webpage)) }, + navigationIcon = { + IconButton(onClick = { + onBackClick() + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + }, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +private fun NumberTagsWebpageListErrorView( + viewModel: NumberTagsWebpageListViewModel, + onBackClick: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + onBackClick + ) + }, + modifier = Modifier.fillMaxSize(), + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding), + contentAlignment = Alignment.Center + ) { + ErrorView(onClick = viewModel::reload) + } + } +} + +@Composable +private fun NumberTagsWebpageListLoadingView( + onBackClick: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + onBackClick, + ) + }, + modifier = Modifier.fillMaxSize(), + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding), + contentAlignment = Alignment.Center + ) { + LoadingView() + } + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/NumberTagsWebpageListViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/NumberTagsWebpageListViewModel.kt new file mode 100644 index 0000000..7e226b3 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/NumberTagsWebpageListViewModel.kt @@ -0,0 +1,82 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.nativeapptemplate.nativeapptemplatefree.data.shop.ShopRepository +import com.nativeapptemplate.nativeapptemplatefree.model.Shop +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.NumberTagsWebpageListRoute +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class NumberTagsWebpageListUiState( + val isLoading: Boolean = true, + val success: Boolean = false, + val message: String = "", + val shop: Shop = Shop(), +) + +/** + * ViewModel for library view + */ +@HiltViewModel +class NumberTagsWebpageListViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val shopRepository: ShopRepository, + + ) : ViewModel() { + private val shopId = savedStateHandle.toRoute().id + + private val _uiState = MutableStateFlow(NumberTagsWebpageListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun reload() { + fetchData(shopId) + } + + private fun fetchData(shopId: String) { + _uiState.update { + it.copy( + isLoading = true, + success = false, + ) + } + + viewModelScope.launch { + val shopFlow: Flow = shopRepository.getShop(shopId) + + shopFlow + .catch { exception -> + val message = exception.message + _uiState.update { + it.copy( + message = message ?: "Unknown Error", + isLoading = false, + ) + } + } + .collect { shop -> + _uiState.update { + it.copy( + shop = shop, + success = true, + isLoading = false, + ) + } + } + } + } + + fun snackbarMessageShown() { + _uiState.update { it.copy(message = "") } + } +} + diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopSettingsView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopSettingsView.kt index 91a09ea..e72c793 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopSettingsView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopSettingsView.kt @@ -1,7 +1,9 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -10,7 +12,9 @@ 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.outlined.AddAlert +import androidx.compose.material.icons.outlined.Rectangle import androidx.compose.material.icons.outlined.Storefront +import androidx.compose.material.icons.outlined.Web import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -50,6 +54,8 @@ internal fun ShopSettingsView( viewModel: ShopSettingsViewModel = hiltViewModel(), onShowBasicSettingsClick: (String) -> Unit, + onShowItemTagListClick: (String) -> Unit, + onShowNumberTagsWebpageListClick: (String) -> Unit, onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, onBackClick: () -> Unit, @@ -67,6 +73,15 @@ internal fun ShopSettingsView( } } + if (uiState.isShopReset) { + NatAlertDialog( + dialogTitle= stringResource(R.string.message_shop_reset), + onDismissRequest = { + onBackClick() + }, + ) + } + if (uiState.isShopDeleted) { val context = LocalContext.current context.restartApp() @@ -77,6 +92,8 @@ internal fun ShopSettingsView( uiState = uiState, onShowBasicSettingsClick = onShowBasicSettingsClick, + onShowItemTagListClick = onShowItemTagListClick, + onShowNumberTagsWebpageListClick = onShowNumberTagsWebpageListClick, onBackClick = onBackClick, ) @@ -88,6 +105,8 @@ fun ShopSettingsView( uiState: ShopSettingsUiState, onShowBasicSettingsClick: (String) -> Unit, + onShowItemTagListClick: (String) -> Unit, + onShowNumberTagsWebpageListClick: (String) -> Unit, onBackClick: () -> Unit, ) { @@ -96,6 +115,8 @@ fun ShopSettingsView( uiState = uiState, onShowBasicSettingsClick = onShowBasicSettingsClick, + onShowItemTagListClick = onShowItemTagListClick, + onShowNumberTagsWebpageListClick = onShowNumberTagsWebpageListClick, onBackClick = onBackClick, ) @@ -107,6 +128,8 @@ private fun ContentView( uiState: ShopSettingsUiState, onShowBasicSettingsClick: (String) -> Unit, + onShowItemTagListClick: (String) -> Unit, + onShowNumberTagsWebpageListClick: (String) -> Unit, onBackClick: () -> Unit, ) { @@ -118,6 +141,8 @@ private fun ContentView( uiState = uiState, onShowBasicSettingsClick = onShowBasicSettingsClick, + onShowItemTagListClick = onShowItemTagListClick, + onShowNumberTagsWebpageListClick = onShowNumberTagsWebpageListClick, onBackClick = onBackClick, ) @@ -132,11 +157,24 @@ private fun ShopSettingsContentView( uiState: ShopSettingsUiState, onShowBasicSettingsClick: (String) -> Unit, + onShowItemTagListClick: (String) -> Unit, + onShowNumberTagsWebpageListClick: (String) -> Unit, onBackClick: () -> Unit, ) { + var isShowingResetConfirmationDialog by remember { mutableStateOf(false) } var isShowingDeleteConfirmationDialog by remember { mutableStateOf(false) } + if (isShowingResetConfirmationDialog) { + NatAlertDialog( + dialogTitle= stringResource(R.string.are_you_sure), + confirmButtonTitle = stringResource(R.string.title_reset_number_tags), + onDismissRequest = { isShowingResetConfirmationDialog = false }, + onConfirmation = { viewModel.resetShop(uiState.shop.getData()?.id!!) }, + icon = Icons.Outlined.AddAlert, + ) + } + if (isShowingDeleteConfirmationDialog) { NatAlertDialog( dialogTitle= stringResource(R.string.are_you_sure), @@ -210,6 +248,90 @@ private fun ShopSettingsContentView( ) } + item { + HorizontalDivider() + + ListItem( + headlineContent = { + Text( + stringResource(R.string.label_shop_settings_manage_number_tags), + style = MaterialTheme.typography.titleMedium + ) + }, + leadingContent = { + Icon( + Icons.Outlined.Rectangle, + contentDescription = stringResource(R.string.label_shop_settings_manage_number_tags), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = Modifier + .clickable { onShowItemTagListClick(uiState.shop.getData()?.id!!) } + ) + + HorizontalDivider() + } + + item { + ListItem( + headlineContent = { + Text( + "", + style = MaterialTheme.typography.titleMedium + ) + }, + ) + } + + item { + HorizontalDivider() + + ListItem( + headlineContent = { + Text( + stringResource(R.string.label_shop_settings_number_tags_webpage), + style = MaterialTheme.typography.titleMedium + ) + }, + leadingContent = { + Icon( + Icons.Outlined.Web, + contentDescription = stringResource(R.string.label_shop_settings_number_tags_webpage), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = Modifier + .clickable { onShowNumberTagsWebpageListClick(uiState.shop.getData()?.id!!) }, + ) + + HorizontalDivider() + } + + item { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(top = 48.dp) + ) { + MainButtonView( + title = stringResource(R.string.title_reset_number_tags), + onClick = { isShowingResetConfirmationDialog = true }, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 24.dp) + ) + + Text( + stringResource(R.string.reset_number_tags_description), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + ) + } + } + item { MainButtonView( title = stringResource(R.string.title_delete_shop), diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopSettingsViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopSettingsViewModel.kt index de9074d..4a08c25 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopSettingsViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopSettingsViewModel.kt @@ -21,6 +21,7 @@ import javax.inject.Inject data class ShopSettingsUiState( val shop: Shop = Shop(), val isShopDeleted: Boolean = false, + val isShopReset: Boolean = false, val isLoading: Boolean = true, val success: Boolean = false, @@ -76,6 +77,37 @@ class ShopSettingsViewModel @Inject constructor( } } + fun resetShop(shopId: String) { + _uiState.update { + it.copy( + isLoading = true, + isShopReset = false, + ) + } + + viewModelScope.launch { + val booleanFlow: Flow = shopRepository.resetShop(shopId) + + booleanFlow + .catch { exception -> + val message = exception.message + _uiState.update { + it.copy( + message = message ?: "Unknown Error", + isLoading = false, + ) + } + } + .collect { + _uiState.update { + it.copy( + isShopReset = true, + ) + } + } + } + } + fun deleteShop(shopId: String) { _uiState.update { it.copy( diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailView.kt new file mode 100644 index 0000000..ec37692 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailView.kt @@ -0,0 +1,558 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_detail + +import android.nfc.NfcAdapter +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.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.outlined.AddAlert +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.People +import androidx.compose.material.icons.outlined.Storefront +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.SnackbarDuration +import androidx.compose.material3.Switch +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.lightspark.composeqr.QrCodeView +import com.nativeapptemplate.nativeapptemplatefree.R +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagType +import com.nativeapptemplate.nativeapptemplatefree.ui.common.ErrorView +import com.nativeapptemplate.nativeapptemplatefree.ui.common.LoadingView +import com.nativeapptemplate.nativeapptemplatefree.ui.common.MainButtonView +import com.nativeapptemplate.nativeapptemplatefree.ui.common.NatAlertDialog +import com.nativeapptemplate.nativeapptemplatefree.ui.common.NonScaledSp.nonScaledSp +import com.nativeapptemplate.nativeapptemplatefree.utils.Utility +import com.nativeapptemplate.nativeapptemplatefree.utils.Utility.shareImage +import dev.shreyaspatil.capturable.capturable +import dev.shreyaspatil.capturable.controller.rememberCaptureController +import kotlinx.coroutines.launch + +@Composable +internal fun ItemTagDetailView( + viewModel: ItemTagDetailViewModel = hiltViewModel(), + onShowItemTagEditClick: (String) -> Unit, + onShowItemTagWriteClick: (String, Boolean, String) -> Unit, + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, + onBackClick: () -> Unit, +) { + val uiState: ItemTagDetailUiState by viewModel.uiState.collectAsStateWithLifecycle() + + LifecycleEventEffect(Lifecycle.Event.ON_CREATE) { + viewModel.reload() + } + + LaunchedEffect(uiState.message) { + if (uiState.message.isNotBlank()) { + onShowSnackbar(uiState.message, "dismiss", SnackbarDuration.Indefinite) + viewModel.snackbarMessageShown() + } + } + + if (uiState.isDeleted) { + NatAlertDialog( + dialogTitle= stringResource(R.string.message_item_tag_deleted), + onDismissRequest = { onBackClick() }, + ) + } + + ItemTagDetailView( + viewModel = viewModel, + uiState = uiState, + onShowItemTagEditClick = onShowItemTagEditClick, + onShowItemTagWriteClick = onShowItemTagWriteClick, + onBackClick = onBackClick, + ) +} + +@Composable +fun ItemTagDetailView( + viewModel: ItemTagDetailViewModel, + uiState: ItemTagDetailUiState, + onShowItemTagEditClick: (String) -> Unit, + onShowItemTagWriteClick: (String, Boolean, String) -> Unit, + onBackClick: () -> Unit, +) { + ContentView( + viewModel = viewModel, + uiState = uiState, + onShowItemTagEditClick = onShowItemTagEditClick, + onShowItemTagWriteClick = onShowItemTagWriteClick, + onBackClick = onBackClick, + ) +} + +@Composable +private fun ContentView( + viewModel: ItemTagDetailViewModel, + uiState: ItemTagDetailUiState, + onShowItemTagEditClick: (String) -> Unit, + onShowItemTagWriteClick: (String, Boolean, String) -> Unit, + onBackClick: () -> Unit, +) { + if (uiState.isLoading) { + ItemTagDetailLoadingView( + viewModel = viewModel, + uiState = uiState, + onBackClick = onBackClick, + ) + } else if (uiState.success) { + ItemTagDetailContentView( + viewModel = viewModel, + uiState = uiState, + onShowItemTagEditClick = onShowItemTagEditClick, + onShowItemTagWriteClick = onShowItemTagWriteClick, + onBackClick = onBackClick, + ) + } else { + ItemTagDetailErrorView( + viewModel = viewModel, + uiState = uiState, + onBackClick = onBackClick, + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun ItemTagDetailContentView( + viewModel: ItemTagDetailViewModel, + uiState: ItemTagDetailUiState, + onShowItemTagEditClick: (String) -> Unit, + onShowItemTagWriteClick: (String, Boolean, String) -> Unit, + onBackClick: () -> Unit, +) { + val context = LocalContext.current + var isShowingDeleteConfirmationDialog by remember { mutableStateOf(false) } + var isLocked by remember { mutableStateOf(false) } + val doesDeviceSupportTagScanning = NfcAdapter.getDefaultAdapter(context) != null + val deviceDoesNotSupportTagScanningMessage = stringResource(R.string.this_device_does_not_support_tag_scanning) + + if (isShowingDeleteConfirmationDialog) { + NatAlertDialog( + dialogTitle= stringResource(R.string.are_you_sure), + confirmButtonTitle = stringResource(R.string.title_delete_item_tag), + onDismissRequest = { isShowingDeleteConfirmationDialog = false }, + onConfirmation = { viewModel.deleteItemTag() }, + icon = Icons.Outlined.AddAlert, + ) + } + + Scaffold( + topBar = { + TopAppBar( + viewModel = viewModel, + onShowItemTagEditClick = onShowItemTagEditClick, + onDeleteItemTagClick = { isShowingDeleteConfirmationDialog = true }, + uiState = uiState, + onBackClick = onBackClick, + ) + }, + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding) + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + "Write Info to Tag / Save Customer QR code", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth(), + ) + + Text( + uiState.itemTag.getShopName(), + style = MaterialTheme.typography.titleSmall, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) + + Text( + uiState.itemTag.getQueueNumber(), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) + + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiary), + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .padding(24.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiary, + ) + + Text( + "Lock", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onTertiary, + ) + } + Row( + horizontalArrangement = Arrangement + .spacedBy( + space = 8.dp, + alignment = Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth(), + ) { + Text( + "Lock", + color = MaterialTheme.colorScheme.onTertiary, + ) + + Switch( + checked = isLocked, + onCheckedChange = { + isLocked = it + viewModel.updateIsLock(it) + }, + ) + } + if (isLocked) { + Text( + stringResource(R.string.you_cannot_undo_after_locking_tag), + color = MaterialTheme.colorScheme.onError, + ) + } + } + } + + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.error), + modifier = Modifier + .padding(top = 24.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .padding(24.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Storefront, + contentDescription = null, + tint = MaterialTheme.colorScheme.onError, + ) + + Text( + "Server", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onError, + ) + } + MainButtonView( + title = stringResource(R.string.write_server_tag), + onClick = { + if (doesDeviceSupportTagScanning) { + onShowItemTagWriteClick(viewModel.itemTagId, uiState.isLock, ItemTagType.Server.param) + } else { + viewModel.updateMessage(deviceDoesNotSupportTagScanningMessage) + } + }, + color = MaterialTheme.colorScheme.onError, + titleColor = MaterialTheme.colorScheme.onError, + modifier = Modifier + .padding(horizontal = 24.dp) + ) + } + } + + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primary), + modifier = Modifier + .padding(top = 48.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .padding(24.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.People, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + + Text( + "Customer", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + MainButtonView( + title = stringResource(R.string.write_customer_tag), + onClick = { + if (doesDeviceSupportTagScanning) { + onShowItemTagWriteClick(viewModel.itemTagId, uiState.isLock, ItemTagType.Customer.param) + } else { + viewModel.updateMessage(deviceDoesNotSupportTagScanningMessage) + } + }, + color = MaterialTheme.colorScheme.onPrimary, + titleColor = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .padding(horizontal = 24.dp) + ) + + val captureController = rememberCaptureController() + val scope = rememberCoroutineScope() + + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth(), + ) { + Button( + onClick = { + // Capture content + scope.launch { + val bitmapAsync = captureController.captureAsync() + try { + val bitmap = bitmapAsync.await() + context.shareImage( + uiState.itemTag.getQueueNumber(), + bitmap, + uiState.itemTag.getQueueNumber() + ) + // Do something with `bitmap`. + } catch (error: Throwable) { + // Error occurred, do something. + viewModel.updateMessage(error.localizedMessage ?: "") + } + } + } + ) { + QrCode(viewModel, uiState, Modifier.capturable(captureController)) + } + } + } + } + } + } + } +} + +@Composable +fun QrCode( + viewModel: ItemTagDetailViewModel, + uiState: ItemTagDetailUiState, + modifier: Modifier, +) { + val scanUri = Utility.scanUri(viewModel.itemTagId, ItemTagType.Customer.param) + + QrCodeView( + data = scanUri.toString(), + modifier + .size(96.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .clip( + RectangleShape + ) + .background(Color.White) + ) { + Text( + uiState.itemTag.getQueueNumber(), + color = Color.Black, + fontSize = 7.sp.nonScaledSp, + textAlign = TextAlign.Center, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopAppBar( + viewModel: ItemTagDetailViewModel, + uiState: ItemTagDetailUiState, + onShowItemTagEditClick: (String) -> Unit, + onDeleteItemTagClick: () -> Unit, + onBackClick: () -> Unit, +) { + CenterAlignedTopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + ), + title = {}, + navigationIcon = { + IconButton(onClick = { + onBackClick() + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + }, + actions = { + if (uiState.success) { + TextButton( + onClick = { onShowItemTagEditClick(viewModel.itemTagId) }, + ) { + Text( + "Edit", + color = MaterialTheme.colorScheme.onSurface, + ) + } + + IconButton( + onClick = { onDeleteItemTagClick() }, + ) { + Icon( + Icons.Filled.Delete, + "Delete", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +private fun ItemTagDetailErrorView( + viewModel: ItemTagDetailViewModel, + uiState: ItemTagDetailUiState, + onBackClick: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + viewModel = viewModel, + uiState = uiState, + onShowItemTagEditClick = {}, + onDeleteItemTagClick = {}, + onBackClick = onBackClick, + ) + }, + modifier = Modifier.fillMaxSize(), + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding), + contentAlignment = Alignment.Center + ) { + ErrorView( + onClick = { viewModel.reload() } + ) + } + } +} + +@Composable +private fun ItemTagDetailLoadingView( + viewModel: ItemTagDetailViewModel, + uiState: ItemTagDetailUiState, + onBackClick: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + viewModel = viewModel, + uiState = uiState, + onShowItemTagEditClick = {}, + onDeleteItemTagClick = {}, + onBackClick = onBackClick, + ) + }, + modifier = Modifier.fillMaxSize(), + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding), + contentAlignment = Alignment.Center + ) { + LoadingView() + } + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailViewModel.kt new file mode 100644 index 0000000..4122ab8 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailViewModel.kt @@ -0,0 +1,127 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.nativeapptemplate.nativeapptemplatefree.data.item_tag.ItemTagRepository +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.ItemTagDetailRoute +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ItemTagDetailUiState( + val itemTag: ItemTag = ItemTag(), + + val isLock: Boolean = false, + + val isDeleted: Boolean = false, + + val isLoading: Boolean = true, + val success: Boolean = false, + val message: String = "", +) + +@HiltViewModel +class ItemTagDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val itemTagRepository: ItemTagRepository, +) : ViewModel() { + val itemTagId = savedStateHandle.toRoute().id + + private val _uiState = MutableStateFlow(ItemTagDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun reload() { + fetchData() + } + + private fun fetchData() { + _uiState.update { + it.copy( + isLoading = true, + success = false, + isDeleted = false, + ) + } + + viewModelScope.launch { + val itemTagFlow: Flow = itemTagRepository.getItemTag(itemTagId) + + itemTagFlow + .catch { exception -> + val message = exception.message + _uiState.update { + it.copy( + message = message ?: "Unknown Error", + isLoading = false, + ) + } + } + .collect { itemTag -> + _uiState.update { + it.copy( + itemTag = itemTag, + success = true, + isLoading = false, + ) + } + } + } + } + + fun deleteItemTag() { + _uiState.update { + it.copy( + isLoading = true, + isDeleted = false, + ) + } + + viewModelScope.launch { + val booleanFlow: Flow = itemTagRepository.deleteItemTag(itemTagId) + + booleanFlow + .catch { exception -> + val message = exception.message + _uiState.update { + it.copy( + message = message ?: "Unknown Error", + isLoading = false, + ) + } + } + .collect { + _uiState.update { + it.copy( + isDeleted = true, + ) + } + } + } + } + + fun updateIsLock(newIsLock: Boolean) { + _uiState.update { + it.copy(isLock = newIsLock) + } + } + + fun updateMessage(newMessage: String) { + _uiState.update { + it.copy(message = newMessage) + } + } + + fun snackbarMessageShown() { + _uiState.update { it.copy(message = "") } + } +} + diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagEditView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagEditView.kt new file mode 100644 index 0000000..c7aa420 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagEditView.kt @@ -0,0 +1,242 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_detail + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.Button +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.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nativeapptemplate.nativeapptemplatefree.R +import com.nativeapptemplate.nativeapptemplatefree.ui.common.ErrorView +import com.nativeapptemplate.nativeapptemplatefree.ui.common.LoadingView + +@Composable +fun ItemTagEditView( + viewModel: ItemTagEditViewModel = hiltViewModel(), + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, + onBackClick: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val itemTagUpdatedMessage = stringResource(id = R.string.message_item_tag_updated) + + LifecycleEventEffect(Lifecycle.Event.ON_CREATE) { + viewModel.reload() + } + + LaunchedEffect(uiState.message) { + if (uiState.message.isNotBlank()) { + onShowSnackbar(uiState.message, "dismiss", SnackbarDuration.Indefinite) + viewModel.snackbarMessageShown() + } + } + + LaunchedEffect(uiState.isUpdated) { + if (uiState.isUpdated) { + onShowSnackbar(itemTagUpdatedMessage, "dismiss", SnackbarDuration.Short) + } + } + + ItemTagEditView( + viewModel, + uiState, + onBackClick + ) +} + +@Composable +fun ItemTagEditView( + viewModel: ItemTagEditViewModel, + uiState: ItemTagEditUiState, + onBackClick: () -> Unit, +) { + ContentView(viewModel, uiState, onBackClick) +} + +@Composable +private fun ContentView( + viewModel: ItemTagEditViewModel, + uiState: ItemTagEditUiState, + onBackClick: () -> Unit, +) { + if (uiState.isLoading) { + ItemTagEditLoadingView(onBackClick) + } else if (uiState.success) { + ItemTagEditContentView(viewModel, uiState, onBackClick) + } else { + ItemTagEditErrorView(viewModel, onBackClick) + } +} + +@Composable +fun ItemTagEditContentView( + viewModel: ItemTagEditViewModel, + uiState: ItemTagEditUiState, + onBackClick: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar(onBackClick) + }, + floatingActionButton = { + // FloatingActionButton doesn't support the enabled property + // https://stackoverflow.com/a/68853697/1160200 + Button( + onClick = { viewModel.updateItemTag() }, + modifier = Modifier.defaultMinSize(minWidth = 64.dp, minHeight = 64.dp), + enabled = !viewModel.hasInvalidData(), + shape = CircleShape + + ){ + Icon(Icons.Filled.Done, contentDescription = null) + } + }, + modifier = Modifier.fillMaxSize(), + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(horizontal = 16.dp, vertical = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedTextField( + label = { + Text( + text = stringResource(R.string.tag_number) + ) + }, + placeholder = { Text("A001") }, + value = uiState.queueNumber, + onValueChange = { viewModel.updateQueueNumber(it) }, + supportingText = { + Column { + Text( + text = "Tag Number must be a 2-${uiState.maximumQueueNumberLength} alphanumeric characters.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.zero_padding), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(id = R.string.tag_number_is_invalid), + style = MaterialTheme.typography.bodyLarge, + color = if (viewModel.hasInvalidDataQueueNumber()) Color.Red else Color.Transparent + ) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + modifier = Modifier + .fillMaxWidth(), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopAppBar( + onBackClick: () -> Unit, +) { + CenterAlignedTopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + ), + title = { Text(text = stringResource(id = R.string.label_edit_item_tag)) }, + navigationIcon = { + IconButton(onClick = { + onBackClick() + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + }, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +private fun ItemTagEditErrorView( + viewModel: ItemTagEditViewModel, + onBackClick: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + onBackClick = onBackClick, + ) + }, + modifier = Modifier.fillMaxSize(), + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding), + contentAlignment = Alignment.Center + ) { + ErrorView( + onClick = { viewModel.reload() } + ) + } + } +} + +@Composable +private fun ItemTagEditLoadingView( + onBackClick: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + onBackClick, + ) + }, + modifier = Modifier.fillMaxSize(), + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding), + contentAlignment = Alignment.Center + ) { + LoadingView() + } + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagEditViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagEditViewModel.kt new file mode 100644 index 0000000..d4483e3 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagEditViewModel.kt @@ -0,0 +1,160 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.nativeapptemplate.nativeapptemplatefree.data.item_tag.ItemTagRepository +import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagBody +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagBodyDetail +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.ItemTagEditRoute +import com.nativeapptemplate.nativeapptemplatefree.utils.Utility +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ItemTagEditUiState( + val itemTag: ItemTag = ItemTag(), + + val queueNumber: String = "", + val maximumQueueNumberLength: Int = -1, + val isUpdated: Boolean = false, + + val isLoading: Boolean = true, + val success: Boolean = false, + val message: String = "", +) + +@HiltViewModel +class ItemTagEditViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val loginRepository: LoginRepository, + private val itemTagRepository: ItemTagRepository +) : ViewModel() { + private val itemTagId = savedStateHandle.toRoute().id + + private val _uiState = MutableStateFlow(ItemTagEditUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun reload() { + fetchData(itemTagId) + } + + private fun fetchData(itemTagId: String) { + _uiState.update { + it.copy( + isLoading = true, + success = false, + isUpdated = false, + ) + } + + viewModelScope.launch { + val itemTagFlow: Flow = itemTagRepository.getItemTag(itemTagId) + val maximumQueueNumberLengthFlow = loginRepository.getMaximumQueueNumberLength() + + combine(itemTagFlow, maximumQueueNumberLengthFlow) { itemTag, maximumQueueNumberLength -> + _uiState.update { + it.copy( + itemTag = itemTag, + queueNumber = itemTag.getQueueNumber(), + maximumQueueNumberLength = maximumQueueNumberLength, + success = true, + isLoading = false, + ) + } + }.catch { exception -> + val message = exception.message + _uiState.update { + it.copy( + message = message ?: "Unknown Error", + isLoading = false, + ) + } + }.collect { + } + } + } + + fun updateItemTag() { + _uiState.update { + it.copy( + isLoading = true, + isUpdated = false, + ) + } + + viewModelScope.launch { + val itemTagBodyDetail = ItemTagBodyDetail( + queueNumber = uiState.value.queueNumber, + ) + val itemTagBody = ItemTagBody(itemTagBodyDetail) + + val itemTagStream: Flow = itemTagRepository.updateItemTag(itemTagId, itemTagBody) + + itemTagStream + .catch { exception -> + val message = exception.message + _uiState.update { + it.copy( + message = message ?: "Unknown Error", + isLoading = false, + ) + } + } + .collect { itemTag -> + _uiState.update { + it.copy( + itemTag = itemTag, + isUpdated = true, + isLoading = false, + ) + } + } + } + } + + fun hasInvalidData() : Boolean { + if (hasInvalidDataQueueNumber()) return true + + val itemTag = uiState.value.itemTag + return itemTag.getQueueNumber() == uiState.value.queueNumber + } + + fun hasInvalidDataQueueNumber() : Boolean { + val queueNumber = uiState.value.queueNumber + + if (queueNumber.isBlank()) return true + + if (!Utility.isAlphanumeric(queueNumber)) return true + + if (!(2 <= queueNumber.length && queueNumber.length <= uiState.value.maximumQueueNumberLength)) { + return true + } + + return false + } + + fun updateQueueNumber(newQueueNumber: String) { + if (newQueueNumber.length <= uiState.value.maximumQueueNumberLength) { + _uiState.update { + it.copy(queueNumber = newQueueNumber) + } + } + } + + fun snackbarMessageShown() { + _uiState.update { it.copy(message = "") } + _uiState.update {it.copy(isUpdated = false) } + _uiState.update { it.copy(success = false) } + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagWriteView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagWriteView.kt new file mode 100644 index 0000000..863e3d7 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagWriteView.kt @@ -0,0 +1,271 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_detail + +import android.nfc.NdefMessage +import android.nfc.NdefRecord +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.Ndef +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CrisisAlert +import androidx.compose.material.icons.outlined.Done +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.rememberLottieComposition +import com.nativeapptemplate.nativeapptemplatefree.BuildConfig +import com.nativeapptemplate.nativeapptemplatefree.R +import com.nativeapptemplate.nativeapptemplatefree.ui.common.MainButtonView +import com.nativeapptemplate.nativeapptemplatefree.utils.Utility.getActivity +import java.io.IOException + +@Composable +internal fun ItemTagWriteView( + viewModel: ItemTagWriteViewModel = hiltViewModel(), + onBackClick: () -> Unit, +) { + val uiState: ItemTagWriteUiState by viewModel.uiState.collectAsStateWithLifecycle() + + val context = LocalContext.current + val activity = context.getActivity() + val nfcAdapter = NfcAdapter.getDefaultAdapter(context) + + val holdYourAndroidNearTheItemMessage = stringResource(R.string.hold_your_android_near_the_item) + val tagIsNotWritableMessage = stringResource(R.string.tag_is_not_writable) + val tagCannotBeMadeReadOnlyMessage = stringResource(R.string.tag_cannot_be_made_read_only) + val updateSuccessMessage = stringResource(R.string.update_success) + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.updateMessage(holdYourAndroidNearTheItemMessage) + } + + DisposableEffect(nfcAdapter) { + val nfcCallback = object : NfcAdapter.ReaderCallback { + override fun onTagDiscovered(tag: Tag?) { + if (tag != null) { + val mNdef = Ndef.get(tag) + if (mNdef != null) { + val mRecord = NdefRecord.createUri(viewModel.scanUri) + + val mNdefMsg = if (viewModel.itemTagType == "server") { + NdefMessage( + arrayOf( + mRecord, + NdefRecord.createApplicationRecord(BuildConfig.APPLICATION_ID) + ) + ) + } else { + NdefMessage(mRecord) + } + + try { + mNdef.connect() + + if (!mNdef.isWritable) { + viewModel.updateMessage(tagIsNotWritableMessage) + viewModel.updateIsFailed(true) + mNdef.close() + return + } + + mNdef.writeNdefMessage(mNdefMsg) + + if (viewModel.isLock) { + if (!mNdef.canMakeReadOnly()) { + viewModel.updateMessage(tagCannotBeMadeReadOnlyMessage) + mNdef.close() + return + } + mNdef.makeReadOnly() + } + + viewModel.updateMessage(updateSuccessMessage) + viewModel.updateIsUpdated(true) + } catch (e: Exception) { + viewModel.updateIsFailed(true) + viewModel.updateMessage("Update tag failed. Please try again(${e.message})") + } finally { + try { + mNdef.close() + } catch (_: IOException) { + } + } + } else { + viewModel.updateIsFailed(true) + viewModel.updateMessage("Invalid Tag") + } + } + } + } + nfcAdapter.enableReaderMode( + activity, + nfcCallback, + NfcAdapter.FLAG_READER_NFC_A, + null + ) + + onDispose { + nfcAdapter.disableReaderMode(activity) + } + } + + ItemTagWriteView( + uiState = uiState, + onBackClick = onBackClick, + ) +} + +@Composable +fun ItemTagWriteView( + uiState: ItemTagWriteUiState, + onBackClick: () -> Unit, +) { + ContentView( + uiState = uiState, + onBackClick = onBackClick, + ) +} + +@Composable +private fun ContentView( + uiState: ItemTagWriteUiState, + onBackClick: () -> Unit, +) { + ItemTagWriteContentView( + uiState = uiState, + onBackClick = onBackClick, + ) +} + +@Composable +private fun ItemTagWriteContentView( + uiState: ItemTagWriteUiState, + onBackClick: () -> Unit, +) { + Scaffold( + modifier = Modifier + .widthIn(max = LocalConfiguration.current.screenWidthDp.dp), + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement + .spacedBy( + space = 16.dp, + alignment = Alignment.CenterVertically, + ), + modifier = Modifier + .padding(padding) + .padding(horizontal = 16.dp) + .align(Alignment.Center) + .verticalScroll(rememberScrollState()), + ) { + Card( + shape = RoundedCornerShape(16.dp), + border = BorderStroke( + width = 2.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + modifier = Modifier + .padding(24.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement + .spacedBy( + space = 16.dp, + alignment = Alignment.CenterVertically, + ), + modifier = Modifier + .padding(24.dp) + ) { + Text( + stringResource(R.string.ready_for_scanning), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) + + Text( + uiState.message, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) + + if (uiState.isUpdated) { + Icon( + Icons.Outlined.Done, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(128.dp) + ) + } else if (uiState.isFailed) { + Icon( + Icons.Outlined.CrisisAlert, + contentDescription = null, + tint = Color.Red, + modifier = Modifier.size(128.dp) + ) + } else { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.nfc_reader)) + LottieAnimation( + composition, + iterations = LottieConstants.IterateForever, + ) + } + + MainButtonView( + title = stringResource(R.string.cancel), + onClick = { onBackClick() }, + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 24.dp) + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagWriteViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagWriteViewModel.kt new file mode 100644 index 0000000..31963d1 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagWriteViewModel.kt @@ -0,0 +1,57 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.navigation.toRoute +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.ItemTagWriteRoute +import com.nativeapptemplate.nativeapptemplatefree.utils.Utility +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +data class ItemTagWriteUiState( + val isUpdated: Boolean = false, + val isFailed: Boolean = false, + + val message: String = "", +) + +@HiltViewModel +class ItemTagWriteViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + + ) : ViewModel() { + private val itemTagId = savedStateHandle.toRoute().id + val isLock = savedStateHandle.toRoute().isLock + val itemTagType = savedStateHandle.toRoute().itemTagType + + private val _uiState = MutableStateFlow(ItemTagWriteUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + val scanUri = Utility.scanUri( + itemTagId = itemTagId, + itemTagType = itemTagType + ) + + fun updateIsUpdated(newIsUpdated: Boolean) { + _uiState.update { + it.copy(isUpdated = newIsUpdated) + } + } + + fun updateIsFailed(newIsFailed: Boolean) { + _uiState.update { + it.copy(isFailed = newIsFailed) + } + } + + fun updateMessage(newMessage: String) { + _uiState.update { + it.copy(message = newMessage) + } + } +} + diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagCreateView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagCreateView.kt new file mode 100644 index 0000000..fec8624 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagCreateView.kt @@ -0,0 +1,243 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.Button +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.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nativeapptemplate.nativeapptemplatefree.R +import com.nativeapptemplate.nativeapptemplatefree.ui.common.ErrorView +import com.nativeapptemplate.nativeapptemplatefree.ui.common.LoadingView +import com.nativeapptemplate.nativeapptemplatefree.ui.common.NatAlertDialog + +@Composable +fun ItemTagCreateView( + viewModel: ItemTagCreateViewModel = hiltViewModel(), + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, + onBackClick: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.reload() + } + + LaunchedEffect(uiState.message) { + if (uiState.message.isNotBlank()) { + onShowSnackbar(uiState.message, "dismiss", SnackbarDuration.Indefinite) + viewModel.snackbarMessageShown() + } + } + + if (uiState.isCreated) { + NatAlertDialog( + dialogTitle= stringResource(R.string.message_item_tag_created), + onDismissRequest = { onBackClick() }, + ) + } + + ItemTagCreateView( + viewModel, + uiState, + onBackClick + ) +} + +@Composable +fun ItemTagCreateView( + viewModel: ItemTagCreateViewModel, + uiState: ItemTagCreateUiState, + onBackClick: () -> Unit, +) { + ContentView(viewModel, uiState, onBackClick) +} + +@Composable +private fun ContentView( + viewModel: ItemTagCreateViewModel, + uiState: ItemTagCreateUiState, + onBackClick: () -> Unit, +) { + if (uiState.isLoading) { + ItemTagCreateLoadingView(onBackClick) + } else if (uiState.success) { + ItemTagCreateContentView(viewModel, uiState, onBackClick) + } else { + ItemTagCreateErrorView(viewModel, onBackClick) + } +} + +@Composable +fun ItemTagCreateContentView( + viewModel: ItemTagCreateViewModel, + uiState: ItemTagCreateUiState, + onBackClick: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar(onBackClick) + }, + floatingActionButton = { + // FloatingActionButton doesn't support the enabled property + // https://stackoverflow.com/a/68853697/1160200 + Button( + onClick = { viewModel.createItemTag() }, + modifier = Modifier.defaultMinSize(minWidth = 64.dp, minHeight = 64.dp), + enabled = !viewModel.hasInvalidData(), + shape = CircleShape + + ){ + Icon(Icons.Filled.Done, contentDescription = stringResource(R.string.label_add_tag)) + } + }, + modifier = Modifier.fillMaxSize(), + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(horizontal = 16.dp, vertical = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedTextField( + label = { + Text( + text = stringResource(R.string.tag_number) + ) + }, + placeholder = { Text("A001") }, + value = uiState.queueNumber, + onValueChange = { viewModel.updateQueueNumber(it) }, + supportingText = { + Column { + Text( + text = "Tag Number must be a 2-${uiState.maximumQueueNumberLength} alphanumeric characters.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.zero_padding), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(id = R.string.tag_number_is_invalid), + style = MaterialTheme.typography.bodyLarge, + color = if (viewModel.hasInvalidDataQueueNumber()) Color.Red else Color.Transparent + ) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + modifier = Modifier + .fillMaxWidth(), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopAppBar( + onBackClick: () -> Unit, +) { + CenterAlignedTopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + ), + title = { Text(text = stringResource(id = R.string.label_add_tag)) }, + navigationIcon = { + IconButton(onClick = { + onBackClick() + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + }, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +private fun ItemTagCreateErrorView( + viewModel: ItemTagCreateViewModel, + onBackClick: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + onBackClick = onBackClick, + ) + }, + modifier = Modifier.fillMaxSize(), + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding), + contentAlignment = Alignment.Center + ) { + ErrorView( + onClick = { viewModel.reload() } + ) + } + } +} + +@Composable +private fun ItemTagCreateLoadingView( + onBackClick: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + onBackClick, + ) + }, + modifier = Modifier.fillMaxSize(), + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding), + contentAlignment = Alignment.Center + ) { + LoadingView() + } + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagCreateViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagCreateViewModel.kt new file mode 100644 index 0000000..5ca88d3 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagCreateViewModel.kt @@ -0,0 +1,148 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_list + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.nativeapptemplate.nativeapptemplatefree.data.item_tag.ItemTagRepository +import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagBody +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagBodyDetail +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.ItemTagCreateRoute +import com.nativeapptemplate.nativeapptemplatefree.utils.Utility +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ItemTagCreateUiState( + val queueNumber: String = "", + val maximumQueueNumberLength: Int = -1, + val isCreated: Boolean = false, + + val isLoading: Boolean = true, + val success: Boolean = false, + val message: String = "", +) + +@HiltViewModel +class ItemTagCreateViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val loginRepository: LoginRepository, + private val itemTagRepository: ItemTagRepository +) : ViewModel() { + private val shopId = savedStateHandle.toRoute().shopId + + private val _uiState = MutableStateFlow(ItemTagCreateUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun reload() { + fetchData() + } + + private fun fetchData() { + _uiState.update { + it.copy( + isLoading = true, + success = false, + isCreated = false, + ) + } + + viewModelScope.launch { + val maximumQueueNumberLengthFlow = loginRepository.getMaximumQueueNumberLength() + + maximumQueueNumberLengthFlow + .catch { exception -> + val message = exception.message + _uiState.update { + it.copy( + message = message ?: "Unknown Error", + isLoading = false, + ) + } + } + .collect { maximumQueueNumberLength -> + _uiState.update { + it.copy( + maximumQueueNumberLength = maximumQueueNumberLength, + success = true, + isLoading = false, + ) + } + } + } + } + + fun createItemTag() { + _uiState.update { + it.copy( + isLoading = true, + isCreated = false, + ) + } + + viewModelScope.launch { + val itemTagBodyDetail = ItemTagBodyDetail( + queueNumber = uiState.value.queueNumber, + ) + val itemTagBody = ItemTagBody(itemTagBodyDetail) + + val itemTagFlow: Flow = itemTagRepository.createItemTag(shopId, itemTagBody) + + itemTagFlow + .catch { exception -> + val message = exception.message + _uiState.update { + it.copy( + message = message ?: "Unknown Error", + isLoading = false, + ) + } + } + .collect { + _uiState.update { + it.copy( + isCreated = true, + ) + } + } + } + } + + fun hasInvalidData() : Boolean { + return hasInvalidDataQueueNumber() + } + + fun hasInvalidDataQueueNumber() : Boolean { + val queueNumber = uiState.value.queueNumber + + if (queueNumber.isBlank()) return true + + if (!Utility.isAlphanumeric(queueNumber)) return true + + if (!(2 <= queueNumber.length && queueNumber.length <= uiState.value.maximumQueueNumberLength)) { + return true + } + + return false + } + + fun updateQueueNumber(newQueueNumber: String) { + if (newQueueNumber.length <= uiState.value.maximumQueueNumberLength) { + _uiState.update { + it.copy(queueNumber = newQueueNumber) + } + } + } + + fun snackbarMessageShown() { + _uiState.update { it.copy(message = "") } + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListCardView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListCardView.kt new file mode 100644 index 0000000..96b83c8 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListCardView.kt @@ -0,0 +1,28 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_list + +import androidx.compose.foundation.clickable +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.nativeapptemplate.nativeapptemplatefree.model.Data + +@Composable +fun ItemTagListCardView( + data: Data, + onItemClick: (String) -> Unit, +) { + ListItem( + headlineContent = { + Text( + data.getQueueNumber(), + style = MaterialTheme.typography.titleMedium + ) + }, + modifier = Modifier + .clickable { + onItemClick(data.id!!) + }, + ) +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListView.kt new file mode 100644 index 0000000..1956afa --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListView.kt @@ -0,0 +1,338 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.outlined.Rectangle +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nativeapptemplate.nativeapptemplatefree.R +import com.nativeapptemplate.nativeapptemplatefree.ui.common.ActionIcon +import com.nativeapptemplate.nativeapptemplatefree.ui.common.ErrorView +import com.nativeapptemplate.nativeapptemplatefree.ui.common.LoadingView +import com.nativeapptemplate.nativeapptemplatefree.ui.common.MainButtonView +import com.nativeapptemplate.nativeapptemplatefree.ui.common.SwipeableItemWithActions + +@Composable +internal fun ItemTagListView( + viewModel: ItemTagListViewModel = hiltViewModel(), + onItemClick: (String) -> Unit, + onAddItemTagClick: (String) -> Unit, + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, + onBackClick: () -> Unit, +) { + val uiState: ItemTagListUiState by viewModel.uiState.collectAsStateWithLifecycle() + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.reload() + } + + LaunchedEffect(uiState.message) { + if (uiState.message.isNotBlank()) { + onShowSnackbar(uiState.message, "dismiss", SnackbarDuration.Indefinite) + viewModel.snackbarMessageShown() + } + } + + ItemTagListView( + viewModel = viewModel, + uiState = uiState, + onItemClick = onItemClick, + onAddItemTagClick = onAddItemTagClick, + onBackClick = onBackClick, + ) +} + +@Composable +fun ItemTagListView( + viewModel: ItemTagListViewModel, + uiState: ItemTagListUiState, + onItemClick: (String) -> Unit, + onAddItemTagClick: (String) -> Unit, + onBackClick: () -> Unit, +) { + ContentView( + viewModel = viewModel, + uiState = uiState, + onItemClick = onItemClick, + onAddItemTagClick = onAddItemTagClick, + onBackClick = onBackClick, + ) +} + +@Composable +private fun ContentView( + viewModel: ItemTagListViewModel, + uiState: ItemTagListUiState, + onItemClick: (String) -> Unit, + onAddItemTagClick: (String) -> Unit, + onBackClick: () -> Unit, +) { + if (uiState.isLoading) { + ItemTagListLoadingView( onBackClick) + } else if (uiState.success) { + ItemTagListContentView( + viewModel = viewModel, + uiState = uiState, + onItemClick = onItemClick, + onAddItemTagClick = onAddItemTagClick, + onBackClick = onBackClick, + ) + } else { + ItemTagListErrorView( + viewModel = viewModel, + onBackClick = onBackClick, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ItemTagListContentView( + viewModel: ItemTagListViewModel, + uiState: ItemTagListUiState, + onItemClick: (String) -> Unit, + onAddItemTagClick: (String) -> Unit, + onBackClick: () -> Unit, +) { + val isEmpty: Boolean by viewModel.isEmpty().collectAsStateWithLifecycle() + val itemTags = uiState.itemTags.getDatumWithRelationships().toMutableList() + + Scaffold( + topBar = { + TopAppBar( + onBackClick, + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { + onAddItemTagClick(viewModel.shopId) + }, + ) { + Icon(Icons.Filled.Add, stringResource(id = R.string.add_shop)) + } + }, + modifier = Modifier.fillMaxSize(), + ) { padding -> + val pullToRefreshState = rememberPullToRefreshState() + + if (isEmpty) { + NoResultsView( + viewModel = viewModel, + onAddItemTagClick = onAddItemTagClick, + padding = padding, + ) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .pullToRefresh( + state = pullToRefreshState, + isRefreshing = uiState.isLoading, + onRefresh = viewModel::reload, + ) + .padding(padding) + ) { + LazyColumn( + Modifier.padding(24.dp) + ) { + item { + Text( + uiState.shop.getName(), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + ) + } + itemsIndexed( + items = itemTags, + ) { index, itemTag -> + SwipeableItemWithActions( + isRevealed = itemTag.isOptionsRevealed, + onExpanded = { + itemTags[index] = itemTag.copy(isOptionsRevealed = true) + }, + onCollapsed = { + itemTags[index] = itemTag.copy(isOptionsRevealed = false) + }, + actions = { + ActionIcon( + onClick = { + viewModel.deleteItemTag(itemTag.id!!) + }, + backgroundColor = Color.Red, + icon = Icons.Default.Delete, + modifier = Modifier.fillMaxHeight() + ) + }, + ) { + ItemTagListCardView( + data = itemTag, + onItemClick = { onItemClick(itemTag.id!!) }, + ) + } + HorizontalDivider() + } + } + Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = uiState.isLoading, + state = pullToRefreshState, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopAppBar( + onBackClick: () -> Unit, +) { + CenterAlignedTopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + ), + title = { Text(stringResource(R.string.label_shop_settings_manage_number_tags)) }, + navigationIcon = { + IconButton(onClick = { + onBackClick() + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + }, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +private fun NoResultsView( + viewModel: ItemTagListViewModel, + onAddItemTagClick: (String) -> Unit, + padding: PaddingValues, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.padding(16.dp) + ) { + Icon( + Icons.Outlined.Rectangle, + contentDescription = null, + modifier = Modifier.size(128.dp) + ) + + Text( + stringResource(R.string.add_tag_description), + modifier = Modifier + .padding(horizontal = 16.dp) + ) + + MainButtonView( + title = stringResource(R.string.label_add_tag), + onClick = { onAddItemTagClick(viewModel.shopId) }, + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 24.dp) + ) + } + } +} + +@Composable +private fun ItemTagListErrorView( + viewModel: ItemTagListViewModel, + onBackClick: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + onBackClick = onBackClick, + ) + }, + modifier = Modifier.fillMaxSize(), + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding), + contentAlignment = Alignment.Center + ) { + ErrorView( + onClick = { viewModel.reload() } + ) + } + } +} + +@Composable +private fun ItemTagListLoadingView( + onBackClick: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + onBackClick, + ) + }, + modifier = Modifier.fillMaxSize(), + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(padding), + contentAlignment = Alignment.Center + ) { + LoadingView() + } + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModel.kt new file mode 100644 index 0000000..15ce2e1 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModel.kt @@ -0,0 +1,133 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_list + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.nativeapptemplate.nativeapptemplatefree.data.item_tag.ItemTagRepository +import com.nativeapptemplate.nativeapptemplatefree.data.shop.ShopRepository +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTags +import com.nativeapptemplate.nativeapptemplatefree.model.Shop +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.ItemTagListRoute +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ItemTagListUiState( + val shop: Shop = Shop(), + val itemTags: ItemTags = ItemTags(), + + val isLoading: Boolean = true, + val success: Boolean = false, + val message: String = "", +) + +/** + * ViewModel for library view + */ +@HiltViewModel +class ItemTagListViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val shopRepository: ShopRepository, + private val itemTagRepository: ItemTagRepository, +) : ViewModel() { + val shopId = savedStateHandle.toRoute().shopId + + private val _uiState = MutableStateFlow(ItemTagListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun reload() { + fetchData() + } + + fun isEmpty(): StateFlow = uiState.map { it.itemTags.datum.isEmpty() } + .stateIn( + scope = viewModelScope, + initialValue = false, + started = SharingStarted.WhileSubscribed(5_000), + ) + + private fun fetchData() { + val shopId = shopId + + _uiState.update { + it.copy( + isLoading = true, + success = false, + ) + } + + viewModelScope.launch { + val shopFlow: Flow = shopRepository.getShop(shopId) + val itemTagsFlow: Flow = itemTagRepository.getItemTags(shopId) + + combine( + shopFlow, itemTagsFlow + ) { shop, itemTags -> + _uiState.update { + it.copy( + shop = shop, + itemTags = itemTags, + success = true, + isLoading = false, + ) + } + }.catch { exception -> + val message = exception.message + _uiState.update { + it.copy( + message = message ?: "Unknown Error", + isLoading = false, + ) + } + }.collect { + } + } + } + + fun deleteItemTag(itemTagId: String) { + _uiState.update { + it.copy( + isLoading = true, + ) + } + + viewModelScope.launch { + val booleanFlow: Flow = itemTagRepository.deleteItemTag(itemTagId) + + booleanFlow + .catch { exception -> + val message = exception.message + _uiState.update { + it.copy( + message = message ?: "Unknown Error", + isLoading = false, + ) + } + } + .collect { + _uiState.update { + it.copy( + isLoading = false, + ) + } + reload() + } + } + } + + fun snackbarMessageShown() { + _uiState.update { it.copy(message = "") } + } +} + diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/navigation/shopSettingsNavigation.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/navigation/shopSettingsNavigation.kt index 82bb08e..01874a3 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/navigation/shopSettingsNavigation.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/navigation/shopSettingsNavigation.kt @@ -5,12 +5,25 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptionsBuilder import androidx.navigation.compose.composable +import androidx.navigation.compose.dialog +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.NumberTagsWebpageListView import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.ShopBasicSettingsView import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.ShopSettingsView +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_detail.ItemTagDetailView +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_detail.ItemTagEditView +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_detail.ItemTagWriteView +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_list.ItemTagCreateView +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_list.ItemTagListView import kotlinx.serialization.Serializable @Serializable data class ShopSettingsRoute(val id: String) @Serializable data class ShopBasicSettingsRoute(val id: String) +@Serializable data class NumberTagsWebpageListRoute(val id: String) +@Serializable data class ItemTagListRoute(val shopId: String) +@Serializable data class ItemTagCreateRoute(val shopId: String) +@Serializable data class ItemTagDetailRoute(val id: String) +@Serializable data class ItemTagEditRoute(val id: String) +@Serializable data class ItemTagWriteRoute(val id: String, val isLock: Boolean, val itemTagType: String) fun NavController.navigateToShopSettings(shopId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) { navigate(route = ShopSettingsRoute(shopId)) { @@ -20,12 +33,16 @@ fun NavController.navigateToShopSettings(shopId: String, navOptions: NavOptionsB fun NavGraphBuilder.shopSettingsView( onShowBasicSettingsClick: (String) -> Unit, + onShowItemTagListClick: (String) -> Unit, + onShowNumberTagsWebpageListClick: (String) -> Unit, onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, onBackClick: () -> Unit, ) { composable { ShopSettingsView( onShowBasicSettingsClick = onShowBasicSettingsClick, + onShowItemTagListClick = onShowItemTagListClick, + onShowNumberTagsWebpageListClick = onShowNumberTagsWebpageListClick, onShowSnackbar = onShowSnackbar, onBackClick = onBackClick, ) @@ -48,3 +65,122 @@ fun NavGraphBuilder.shopBasicSettingsView( ) } } + +fun NavController.navigateToNumberTagsWebpageList(shopId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) { + navigate(route = NumberTagsWebpageListRoute(shopId)) { + navOptions() + } +} + +fun NavGraphBuilder.numberTagsWebpageListView( + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, + onBackClick: () -> Unit, +) { + composable { + NumberTagsWebpageListView( + onShowSnackbar = onShowSnackbar, + onBackClick = onBackClick, + ) + } +} + +fun NavController.navigateToItemTagList(shopId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) { + navigate(route = ItemTagListRoute(shopId)) { + navOptions() + } +} + +fun NavGraphBuilder.itemTagListView( + onItemClick: (String) -> Unit, + onAddItemTagClick: (String) -> Unit, + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, + onBackClick: () -> Unit, +) { + composable { + ItemTagListView( + onItemClick = onItemClick, + onAddItemTagClick = onAddItemTagClick, + onShowSnackbar = onShowSnackbar, + onBackClick = onBackClick, + ) + } +} + +fun NavController.navigateToItemTagCreate(shopId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) { + navigate(route = ItemTagCreateRoute(shopId)) { + navOptions() + } +} + +fun NavGraphBuilder.itemTagCreateView( + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, + onBackClick: () -> Unit, +) { + composable { + ItemTagCreateView( + onShowSnackbar = onShowSnackbar, + onBackClick = onBackClick, + ) + } +} + +fun NavController.navigateToItemTagDetail(itemTagId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) { + navigate(route = ItemTagDetailRoute(itemTagId)) { + navOptions() + } +} + +fun NavGraphBuilder.itemTagDetailView( + onShowItemTagEditClick: (String) -> Unit, + onShowItemTagWriteClick: (String, Boolean, String) -> Unit, + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, + onBackClick: () -> Unit, +) { + composable { + ItemTagDetailView( + onShowItemTagEditClick = onShowItemTagEditClick, + onShowItemTagWriteClick = onShowItemTagWriteClick, + onShowSnackbar = onShowSnackbar, + onBackClick = onBackClick, + ) + } +} + +fun NavController.navigateToItemTagEdit(itemTagId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) { + navigate(route = ItemTagEditRoute(itemTagId)) { + navOptions() + } +} + +fun NavGraphBuilder.itemTagEditView( + onShowSnackbar: suspend (String, String?, SnackbarDuration?) -> Boolean, + onBackClick: () -> Unit, +) { + composable { + ItemTagEditView( + onShowSnackbar = onShowSnackbar, + onBackClick = onBackClick, + ) + } +} + +fun NavController.navigateToItemTagWrite( + itemTagId: String, + isLock: Boolean, + itemTagType: String, + navOptions: NavOptionsBuilder.() -> Unit = {} +) { + navigate(route = ItemTagWriteRoute(itemTagId, isLock, itemTagType)) { + navOptions() + } +} + +fun NavGraphBuilder.itemTagWriteView( + onBackClick: () -> Unit, +) { + dialog { + ItemTagWriteView( + onBackClick = onBackClick, + ) + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListCardView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListCardView.kt index 51e295d..7202b2b 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListCardView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListCardView.kt @@ -3,12 +3,22 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.shops import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Flag +import androidx.compose.material.icons.outlined.People +import androidx.compose.material.icons.outlined.Rectangle +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.nativeapptemplate.nativeapptemplatefree.model.Data @@ -34,6 +44,15 @@ fun ShopListCardView( modifier = Modifier.fillMaxWidth(), ) + Column( + modifier = Modifier + .padding(top = 16.dp), + ) { + CountRow(icon = Icons.Outlined.People, count = data.getScannedItemTagsCount(), countLabel = "tags scanned by customers") + CountRow(icon = Icons.Outlined.Flag, count = data.getCompletedItemTagsCount(), countLabel = "completed tags") + CountRow(icon = Icons.Outlined.Rectangle, count = data.getItemTagsCount(), countLabel = "all tags") + } + Text( data.getDescription(), color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -44,3 +63,35 @@ fun ShopListCardView( } } } + +@Composable +private fun CountRow( + icon: ImageVector, + count: Int, + countLabel: String, +) { + Column { + Row(verticalAlignment = Alignment.Bottom) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = count.toString(), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Right, + modifier = Modifier + .width(32.dp) + ) + + Text( + text = countLabel, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(start = 8.dp) + ) + } + } +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListView.kt index 0b7659a..74398f6 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListView.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.NoLuggage import androidx.compose.material.icons.outlined.Storefront import androidx.compose.material3.CenterAlignedTopAppBar @@ -21,6 +23,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip +import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration @@ -163,6 +167,16 @@ private fun ShopListContentView( LazyColumn( Modifier.padding(24.dp) ) { + item { + if (!uiState.didShowTapShopBelowTip) { + TapShopBelowTip( + stringResource(R.string.tap_shop_below), + ) { + viewModel.updateDidShowTapShopBelowTip(true) + } + } + } + items( items = uiState.shops.getDatumWithRelationships(), key = { it.id!! } @@ -217,6 +231,39 @@ private fun TopAppBar() { ) } +@Composable +fun TapShopBelowTip( + text: String, + onDismiss: () -> Unit, +) { + InputChip( + onClick = { + onDismiss() + }, + label = { Text( + text, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.tertiary, + ) }, + selected = false, + avatar = { + Icon( + Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(InputChipDefaults.AvatarSize) + ) + }, + trailingIcon = { + Icon( + Icons.Default.Close, + contentDescription = null, + Modifier.size(InputChipDefaults.AvatarSize) + ) + }, + ) +} + @Composable private fun ShopListErrorView( viewModel: ShopListViewModel, diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListViewModel.kt index aadfa8f..da95f1e 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -20,6 +21,7 @@ import javax.inject.Inject data class ShopListUiState( val shops: Shops = Shops(), + val didShowTapShopBelowTip: Boolean = false, val isLoading: Boolean = true, val success: Boolean = false, @@ -64,26 +66,37 @@ class ShopListViewModel @Inject constructor( viewModelScope.launch { val shopsFlow: Flow = shopRepository.getShops() + val didShowTapShopBelowTipFlow = loginRepository.didShowTapShopBelowTip() - shopsFlow - .catch { exception -> - val message = exception.message - _uiState.update { - it.copy( - message = message ?: "Unknown Error", - isLoading = false, - ) - } + combine( + shopsFlow, + didShowTapShopBelowTipFlow, + ) { shops, + didShowTapShopBelowTip -> + _uiState.update { + it.copy( + shops = shops, + didShowTapShopBelowTip = didShowTapShopBelowTip, + success = true, + isLoading = false, + ) } - .collect { shops -> - _uiState.update { - it.copy( - shops = shops, - success = true, - isLoading = false, - ) - } + }.catch { exception -> + val message = exception.message + _uiState.update { + it.copy( + message = message ?: "Unknown Error", + isLoading = false, + ) } + }.collect { + } + } + } + + fun updateDidShowTapShopBelowTip(didShowTapShopBelowTip: Boolean) { + viewModelScope.launch { + loginRepository.setDidShowTapShopBelowTip(didShowTapShopBelowTip) } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/utils/DateTimeFormatterUtility.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/utils/DateTimeFormatterUtility.kt new file mode 100644 index 0000000..d77f77e --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/utils/DateTimeFormatterUtility.kt @@ -0,0 +1,16 @@ +package com.nativeapptemplate.nativeapptemplatefree.utils + +import java.time.format.DateTimeFormatter + +object DateTimeFormatterUtility { + private const val CARD_DATE_STRING: String = "MMM dd yyyy" + private const val CARD_TIME_STRING: String = "HH:mm" + + fun cardDateFormatter(): DateTimeFormatter { + return DateTimeFormatter.ofPattern(CARD_DATE_STRING) + } + + fun cardTimeFormatter(): DateTimeFormatter { + return DateTimeFormatter.ofPattern(CARD_TIME_STRING) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/utils/DateUtility.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/utils/DateUtility.kt new file mode 100644 index 0000000..73e1536 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/utils/DateUtility.kt @@ -0,0 +1,42 @@ +package com.nativeapptemplate.nativeapptemplatefree.utils + +import android.text.format.DateUtils +import java.time.ZoneId +import java.time.ZonedDateTime + +object DateUtility { + fun ZonedDateTime.cardDateString(): String { + val dateTimeFormatter = DateTimeFormatterUtility.cardDateFormatter() + return this.format(dateTimeFormatter) + } + + fun String.cardDateString(zoneId: ZoneId = ZoneId.systemDefault()): String { + if (this.isBlank()) return "" + + val date = ZonedDateTime.parse(this).withZoneSameInstant(zoneId) + return date.cardDateString() + } + + fun ZonedDateTime.cardTimeString(): String { + val dateTimeFormatter = DateTimeFormatterUtility.cardTimeFormatter() + return this.format(dateTimeFormatter) + } + + fun String.cardTimeString(zoneId: ZoneId = ZoneId.systemDefault()): String { + if (this.isBlank()) return "" + + val date = ZonedDateTime.parse(this).withZoneSameInstant(zoneId) + return date.cardTimeString() + } + + fun ZonedDateTime.cardTimeAgoInWordsDateString(): String { + return DateUtils.getRelativeTimeSpanString(this.toInstant().toEpochMilli(), System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString() + } + + fun String.cardTimeAgoInWordsDateString(zoneId: ZoneId = ZoneId.systemDefault()): String { + if (this.isBlank()) return "" + + val date = ZonedDateTime.parse(this).withZoneSameInstant(zoneId) + return date.cardTimeAgoInWordsDateString() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/utils/Utility.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/utils/Utility.kt index ba638e8..ba8bb00 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/utils/Utility.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/utils/Utility.kt @@ -1,19 +1,104 @@ package com.nativeapptemplate.nativeapptemplatefree.utils import android.content.Context +import android.content.ContextWrapper import android.content.Intent +import android.graphics.Bitmap import android.net.Uri +import android.nfc.NdefMessage import android.os.Build +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.core.content.FileProvider import com.nativeapptemplate.nativeapptemplatefree.BuildConfig import com.nativeapptemplate.nativeapptemplatefree.NatConstants import com.nativeapptemplate.nativeapptemplatefree.R +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagInfoFromNdefMessage +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagType +import java.io.File +import java.io.FileOutputStream import java.util.Locale +private const val TAG = "Utility" + object Utility { fun String.validateEmail(): Boolean { return this.isNotEmpty() && android.util.Patterns.EMAIL_ADDRESS.matcher(this).matches() } + fun Context.getActivity(): ComponentActivity? = when (this) { + is ComponentActivity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null + } + + fun isAlphanumeric(text: String?): Boolean { + if (text.isNullOrBlank()) return false + + return text.matches("^[a-zA-Z0-9]*$".toRegex()) + } + + fun scanUri ( + itemTagId: String, + itemTagType: String, + ) : Uri { + val baseUri = Uri.parse(NatConstants.baseUrlString()) + val uriBuilder = baseUri.buildUpon() + val path = if (itemTagType == "server") NatConstants.SCAN_PATH else NatConstants.SCAN_PATH_CUSTOMER + uriBuilder.appendPath(path) + uriBuilder.appendQueryParameter("item_tag_id", itemTagId) + uriBuilder.appendQueryParameter("type", itemTagType) + + return uriBuilder.build() + } + + fun extractItemTagInfoFrom( + context: Context, + ndefMessage: NdefMessage, + isTest: Boolean = false + ): ItemTagInfoFromNdefMessage { + val itemTagInfo = ItemTagInfoFromNdefMessage() + itemTagInfo.message = context.getString(R.string.message_written_on_tag_is_wrong) + + val ndefRecords = ndefMessage.records + + if (ndefRecords.isEmpty()) return itemTagInfo + + val ndefRecord = ndefRecords.first() + val url = ndefRecord.toUri() ?: return itemTagInfo + + val itemTagId = url.getQueryParameter("item_tag_id") + val type = url.getQueryParameter("type") + + if (itemTagId.isNullOrBlank()) return itemTagInfo + + itemTagInfo.id = itemTagId + + if (type.isNullOrBlank()) return itemTagInfo + + if (type != ItemTagType.Customer.param && type != ItemTagType.Server.param) return itemTagInfo + + itemTagInfo.itemTagType = ItemTagType.fromParam(type)!! + + Log.d(TAG, "url: $url") + Log.d(TAG, "itemTagId: $itemTagId") + Log.d(TAG, "type: $type") + + if (isTest) { + itemTagInfo.success = true + } else { + if (itemTagInfo.itemTagType == ItemTagType.Customer) { + itemTagInfo.message = context.getString(R.string.message_this_tag_is_a_customer_tag) + } else { + itemTagInfo.success = true + } + } + + return itemTagInfo + } + fun marketUri(): Uri { val appId = BuildConfig.APPLICATION_ID return Uri.parse("market://details?id=$appId") @@ -24,6 +109,35 @@ object Utility { return Uri.parse("https://play.google.com/store/apps/details?id=$appId") } + // https://stackoverflow.com/a/75714502/1160200 + // https://qiita.com/irgaly/items/b942bd985a4647e372ea + fun Context.shareImage(title: String, image: ImageBitmap, filename: String) { + val file = try { + val outputFile = File(cacheDir, "$filename.png") + val outPutStream = FileOutputStream(outputFile) + image.asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, outPutStream) + outPutStream.flush() + outPutStream.close() + outputFile + } catch (e: Throwable) { + throw e + } + + val uri = file.toUriCompat(this) + + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "image/png" + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putExtra(Intent.EXTRA_STREAM, uri) + } + startActivity(Intent.createChooser(shareIntent, title)) + } + + private fun File.toUriCompat(context: Context): Uri { + return FileProvider.getUriForFile(context, context.packageName + ".fileprovider", this) + } + // https://stackoverflow.com/a/78039163/1160200 fun Context.restartApp() { val packageManager = packageManager @@ -51,4 +165,4 @@ object Utility { emailIntent.setData(Uri.parse(uriText)) ctx.startActivity(emailIntent) } -} \ No newline at end of file +} diff --git a/app/src/main/proto/item_tag_data.proto b/app/src/main/proto/item_tag_data.proto new file mode 100644 index 0000000..1ca2866 --- /dev/null +++ b/app/src/main/proto/item_tag_data.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +option java_package = "com.nativeapptemplate.nativeapptemplatefree"; +option java_multiple_files = true; + +message ItemTagDataProto { + string id = 1; + string shop_id = 2; + string queue_number = 3; + string state = 4; + string scan_state = 5; + string created_at = 6; + string customer_read_at = 7; + string completed_at = 9; + string shop_name = 10; + bool already_completed = 11; +} diff --git a/app/src/main/proto/item_tag_info_from_ndef_message.proto b/app/src/main/proto/item_tag_info_from_ndef_message.proto new file mode 100644 index 0000000..e595d5b --- /dev/null +++ b/app/src/main/proto/item_tag_info_from_ndef_message.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +option java_package = "com.nativeapptemplate.nativeapptemplatefree"; +option java_multiple_files = true; + +message ItemTagInfoFromNdefMessageProto { + string id = 1; + string item_tag_type = 2; + bool success = 3; + string message = 4; + bool is_read_only = 5; + string scanned_at = 6; +} + diff --git a/app/src/main/proto/scan_result.proto b/app/src/main/proto/scan_result.proto new file mode 100644 index 0000000..db33429 --- /dev/null +++ b/app/src/main/proto/scan_result.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +import "item_tag_info_from_ndef_message.proto"; +import "item_tag_data.proto"; + +option java_package = "com.nativeapptemplate.nativeapptemplatefree"; +option java_multiple_files = true; + +message ScanResultProto { + ItemTagInfoFromNdefMessageProto item_tag_info_from_ndef_message = 1; + ItemTagDataProto item_tag_data = 2; + string scan_result_type = 3; + string message = 4; +} diff --git a/app/src/main/proto/user_preferences.proto b/app/src/main/proto/user_preferences.proto index 11a0d12..5117362 100644 --- a/app/src/main/proto/user_preferences.proto +++ b/app/src/main/proto/user_preferences.proto @@ -1,6 +1,7 @@ syntax = "proto3"; import "dark_theme_config.proto"; +import "scan_result.proto"; option java_package = "com.nativeapptemplate.nativeapptemplatefree"; option java_multiple_files = true; @@ -24,9 +25,13 @@ message UserPreferences { bool is_logged_in = 17; + bool did_show_you_are_in_personal_account_alert = 18; + bool did_show_read_instructions_tip = 19; + int32 android_app_version = 20; bool should_update_privacy = 21; bool should_update_terms = 22; + int32 maximum_queue_number_length = 23; int32 shop_limit_count = 28; bool is_email_updated = 32; @@ -34,4 +39,14 @@ message UserPreferences { bool is_shop_deleted = 35; bool should_update_app = 39; + + ScanResultProto complete_scan_result = 42; + ScanResultProto show_tag_info_scan_result = 43; + + int32 scan_view_selected_tab_index = 44; + bool should_complete_item_tag_for_complete_scan = 45; + bool should_fetch_item_tag_for_show_tag_info_scan = 46; + bool should_navigate_to_scan_view = 47; + + bool did_show_tap_shop_below_tip = 48; } diff --git a/app/src/main/res/raw/nfc_reader.json b/app/src/main/res/raw/nfc_reader.json new file mode 100644 index 0000000..577c7b8 --- /dev/null +++ b/app/src/main/res/raw/nfc_reader.json @@ -0,0 +1 @@ +{"v":"5.6.3","fr":60,"ip":0,"op":120,"w":800,"h":600,"nm":"2-white-800x600","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"phone-speaker","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0.655,-23,0],"ix":2},"a":{"a":0,"k":[0.655,-22.5,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.34,"y":1},"o":{"x":0.884,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-20.621,-23.25],[21.93,-23.25]],"c":false}]},{"i":{"x":0.34,"y":1},"o":{"x":0.884,"y":0},"t":60,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-14.496,-0.5],[15.805,-0.5]],"c":false}]},{"t":120,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-20.621,-23.25],[21.93,-23.25]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.20392156862745098,0.6352941176470588,0.9882352941176471,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.34],"y":[1]},"o":{"x":[0.884],"y":[0]},"t":0,"s":[6]},{"i":{"x":[0.34],"y":[1]},"o":{"x":[0.884],"y":[0]},"t":60,"s":[4]},{"t":120,"s":[6]}],"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"phone-screen","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,-9.598,0],"ix":2},"a":{"a":0,"k":[0,-9.598,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.34,"y":1},"o":{"x":0.884,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-66.235,-9.598],[66.236,-9.598]],"c":false}]},{"i":{"x":0.34,"y":1},"o":{"x":0.884,"y":0},"t":60,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-61.86,12.277],[61.861,12.277]],"c":false}]},{"t":120,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-66.235,-9.598],[66.236,-9.598]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.20392156862745098,0.6352941176470588,0.9882352941176471,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.34],"y":[1]},"o":{"x":[0.884],"y":[0]},"t":0,"s":[8]},{"i":{"x":[0.34],"y":[1]},"o":{"x":[0.884],"y":[0]},"t":60,"s":[4]},{"t":120,"s":[8]}],"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-2.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"phone-mask","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[200,200,0],"ix":2},"a":{"a":0,"k":[-467.836,-2.947,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-82.843,0],[0,-82.843],[82.843,0],[0,82.843]],"o":[[82.843,0],[0,82.843],[-82.843,0],[0,-82.843]],"v":[[-467.836,-152.947],[-317.836,-2.947],[-467.836,147.053],[-617.836,-2.947]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.20392156862745098,0.6352941176470588,0.9882352941176471,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Oval","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"phone-outline","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[200,414,0],"ix":2},"a":{"a":0,"k":[0,214,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[-82.843,0],[0,-82.843],[82.843,0],[0,82.843]],"o":[[82.843,0],[0,82.843],[-82.843,0],[0,-82.843]],"v":[[0.354,-150.342],[150.354,-0.342],[0.354,149.658],[-149.646,-0.342]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.34,"y":1},"o":{"x":0.884,"y":0},"t":0,"s":[{"i":[[8.455,0],[0,0],[0,8.455],[0,0],[-8.455,0],[0,0],[0,-8.455],[0,0]],"o":[[0,0],[-8.455,0],[0,0],[0,-8.455],[0,0],[8.455,0],[0,0],[0,8.455]],"v":[[53.691,210],[-53.691,210],[-69,194.691],[-69,-20.691],[-53.691,-36],[53.691,-36],[69,-20.691],[69,194.691]],"c":true}]},{"i":{"x":0.34,"y":1},"o":{"x":0.884,"y":0},"t":60,"s":[{"i":[[8.455,0],[0,0],[0,8.455],[0,0],[-8.455,0],[0,0],[0,-8.455],[0,0]],"o":[[0,0],[-8.455,0],[0,0],[0,-8.455],[0,0],[8.455,0],[0,0],[0,8.455]],"v":[[53.691,210],[-53.691,210],[-69,194.691],[-59,6.121],[-43.691,-9.188],[43.691,-9.188],[59,6.121],[69,194.691]],"c":true}]},{"t":120,"s":[{"i":[[8.455,0],[0,0],[0,8.455],[0,0],[-8.455,0],[0,0],[0,-8.455],[0,0]],"o":[[0,0],[-8.455,0],[0,0],[0,-8.455],[0,0],[8.455,0],[0,0],[0,8.455]],"v":[[53.691,210],[-53.691,210],[-69,194.691],[-69,-20.691],[-53.691,-36],[53.691,-36],[69,-20.691],[69,194.691]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.20392156862745098,0.6352941176470588,0.9882352941176471,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":8,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"phone-shine","sr":1,"ks":{"o":{"a":0,"k":32,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[207,286.5,0],"ix":2},"a":{"a":0,"k":[7,86.5,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[-82.843,0],[0,-82.843],[82.843,0],[0,82.843]],"o":[[82.843,0],[0,82.843],[-82.843,0],[0,-82.843]],"v":[[0.354,-150.342],[150.354,-0.342],[0.354,149.658],[-149.646,-0.342]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.884,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[66,-12],[67,64.5],[68.75,-12.5],[66.164,-12.03]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-62.682,4.808],[67,138],[64.808,5.91],[-57.847,5.279]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.389,30.725],[67,138],[61.751,7.934],[-62.269,6.757]],"c":true}]},{"i":{"x":0.34,"y":1},"o":{"x":0.167,"y":0.167},"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-60.527,48.003],[67,138],[59.712,9.283],[-60.05,7.584]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.884,"y":0},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-60.25,53],[67,138],[59,11],[-59.749,9.399]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-60.527,48.003],[67,138],[59.712,9.283],[-60.05,7.584]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.389,30.725],[67,138],[61.751,7.934],[-62.269,6.757]],"c":true}]},{"i":{"x":0.34,"y":1},"o":{"x":0.167,"y":0.167},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-62.682,4.808],[67,138],[64.808,5.91],[-57.847,5.279]],"c":true}]},{"t":120,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[66,-12],[67,138],[68.75,-12.5],[66.164,-12.03]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.20392156862745098,0.6352941176470588,0.9882352941176471,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"outline","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = transform.rotation;"},"p":{"a":0,"k":[200,200,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[308,308],"ix":2,"x":"var $bm_rt;\n$bm_rt = content('Group 1').content('Ellipse Path 1').size;"},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.20392156862745098,0.6352941176470588,0.9882352941176471,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":8,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":120,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"1-white","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,300,0],"ix":2},"a":{"a":0,"k":[200,200,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":400,"h":400,"ip":0,"op":120,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 52830c3..167ed36 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,7 @@ NativeAppTemplate Free Shops + Scan Settings @@ -22,6 +23,10 @@ Update Time Zone ⚠️ You aren’t connected to the internet + Instructions + Open + Server Number Tags Webpage + Learn More Are you sure? Full Name Full name is required. @@ -33,6 +38,7 @@ You are not authorized to perform this action. Cancel OK + This device doesn’t support tag scanning. Accept @@ -84,17 +90,65 @@ Shop Name Shop name is required. Add a new shop. + Tap a shop below Shop added. Shop removed. Shop updated. + Swipe a number tag below. + Tap the displayed button. + The Server Number Tags Webpage will be updated. + Read Instructions + Reset Number Tags Delete Shop Shop Settings Basic Settings + Manage Number Tags + Number Tags Webpage + Reset all number tag statuses. + All number tags reset. + + + Tag Number + Add Tag + Add a new number tag and start changing the tag status. + Tag number is invalid. + Write Server Tag + Write Customer Tag + Zero padding(e.g. 07). + + Tag created successfully. + Tag updated successfully. + Tag deleted successfully. + + + Edit Tag + Delete Tag + You cannot undo. After locking the tag, you can no longer write data to it. + + + Tag cannot be made read-only. + Ready for scanning. + Hold your Android near the item to learn more about it. + + + Message written on tag is wrong. + This tag is a “CUSTOMER” tag. Scan a “SERVER” tag! + + Complete Scan + Show Tag Info Scan + Tag info + Read Only + Writable + Reset + Read a NFC Number Tag for changing the Number Tag status. + Read a NFC Number Tag for showing the Number Tag information. + Tag is not writable. + Update success! Mode diff --git a/app/src/main/res/xml/filepaths.xml b/app/src/main/res/xml/filepaths.xml new file mode 100644 index 0000000..53f48e4 --- /dev/null +++ b/app/src/main/res/xml/filepaths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/item_tag/DemoItemTagRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/item_tag/DemoItemTagRepository.kt new file mode 100644 index 0000000..228fa50 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/item_tag/DemoItemTagRepository.kt @@ -0,0 +1,66 @@ +package com.nativeapptemplate.nativeapptemplatefree.demo.item_tag + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.nativeapptemplate.nativeapptemplatefree.data.item_tag.ItemTagRepository +import com.nativeapptemplate.nativeapptemplatefree.demo.DemoAssetManager +import com.nativeapptemplate.nativeapptemplatefree.demo.DemoAssetManagerImpl +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagBody +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTags +import com.nativeapptemplate.nativeapptemplatefree.network.Dispatcher +import com.nativeapptemplate.nativeapptemplatefree.network.NatDispatchers +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import javax.inject.Inject + +class DemoItemTagRepository @Inject constructor( + @Dispatcher(NatDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, + private val networkJson: Json, + private val assets: DemoAssetManager = DemoAssetManagerImpl, +) : ItemTagRepository { + private val itemTagsFlow: Flow = flow { + val itemTags = getDataFromJsonFile(ITEM_TAGS_ASSET) + emit(itemTags) + }.flowOn(ioDispatcher) + + private val itemTagFlow: Flow = flow { + val itemTag = getDataFromJsonFile(ITEM_TAG_ASSET) + emit(itemTag) + }.flowOn(ioDispatcher) + + override fun getItemTags(shopId: String): Flow = itemTagsFlow + + override fun getItemTag(id: String): Flow = itemTagFlow + + override fun createItemTag(shopId: String, itemTagBody: ItemTagBody): Flow = itemTagFlow + + override fun updateItemTag(id: String, itemTagBody: ItemTagBody): Flow = itemTagFlow + + override fun deleteItemTag(id: String): Flow = MutableStateFlow(true) + + override fun completeItemTag(id: String): Flow = itemTagFlow + + override fun resetItemTag(id: String): Flow = itemTagFlow + + @OptIn(ExperimentalSerializationApi::class) + private suspend inline fun getDataFromJsonFile(fileName: String): T = + withContext(ioDispatcher) { + val context = ApplicationProvider.getApplicationContext() + assets.open(context, fileName).use { inputStream -> + networkJson.decodeFromStream(inputStream) + } + } + + companion object { + private const val ITEM_TAGS_ASSET = "item_tags.json" + private const val ITEM_TAG_ASSET = "item_tag.json" + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/item_tag/DemoItemTagRepositoryTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/item_tag/DemoItemTagRepositoryTest.kt new file mode 100644 index 0000000..ec586d5 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/item_tag/DemoItemTagRepositoryTest.kt @@ -0,0 +1,100 @@ +package com.nativeapptemplate.nativeapptemplatefree.demo.item_tag + +import com.nativeapptemplate.nativeapptemplatefree.demo.DemoAssetManagerImpl +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagBody +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagBodyDetail +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class DemoItemTagRepositoryTest { + private lateinit var subject: DemoItemTagRepository + private val testDispatcher = StandardTestDispatcher() + + private val itemTagData = Data( + id = "9712F2DF-DFC7-A3AA-66BC-191203654A1A", + type = "item_tag", + attributes = Attributes( + shopId = "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + queueNumber = "A001", + state = "idled", + scanState = "unscanned", + createdAt = "2025-01-02T12:00:00.000Z", + shopName = "8th & Townsend", + customerReadAt = "2025-01-02T12:00:01.000Z", + completedAt = "2025-01-02T12:00:03.000Z", + alreadyCompleted = false + ), + ) + + @Before + fun setUp() { + subject = DemoItemTagRepository( + ioDispatcher = testDispatcher, + networkJson = Json { ignoreUnknownKeys = true }, + assets = DemoAssetManagerImpl, + ) + } + + @Test + fun testDeserializationOfItemTags() = runTest(testDispatcher) { + assertEquals( + itemTagData, + subject.getItemTags(itemTagData.getShopId()!!).first().datum.first(), + ) + } + + @Test + fun testDeserializationOfItemTag() = runTest(testDispatcher) { + assertEquals( + ItemTag(datum = itemTagData), + subject.getItemTag(id = itemTagData.id!!).first(), + ) + } + + @Test + fun testDeserializationOfItemTagFromCreating() = runTest(testDispatcher) { + assertEquals( + ItemTag(datum = itemTagData), + subject.createItemTag( + itemTagData.getShopId()!!, + ItemTagBody( + itemTagBodyDetail = ItemTagBodyDetail( + queueNumber = itemTagData.getQueueNumber() + ) + ) + ).first(), + ) + } + + @Test + fun testDeserializationOfItemTagFromUpdating() = runTest(testDispatcher) { + assertEquals( + ItemTag(datum = itemTagData), + subject.updateItemTag( + id = itemTagData.id!!, + itemTagBody = ItemTagBody( + itemTagBodyDetail = ItemTagBodyDetail( + queueNumber = itemTagData.getQueueNumber() + ), + ), + ).first(), + ) + } + + @Test + fun testDeleteOfItemTag() = runTest(testDispatcher) { + assertTrue(subject.deleteItemTag(itemTagData.id!!).first()) + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoLoginRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoLoginRepository.kt index ac9a59a..d0e3556 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoLoginRepository.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoLoginRepository.kt @@ -5,10 +5,12 @@ import androidx.test.core.app.ApplicationProvider import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository import com.nativeapptemplate.nativeapptemplatefree.demo.DemoAssetManager import com.nativeapptemplate.nativeapptemplatefree.demo.DemoAssetManagerImpl +import com.nativeapptemplate.nativeapptemplatefree.model.CompleteScanResult import com.nativeapptemplate.nativeapptemplatefree.model.DarkThemeConfig import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper import com.nativeapptemplate.nativeapptemplatefree.model.Login import com.nativeapptemplate.nativeapptemplatefree.model.Permissions +import com.nativeapptemplate.nativeapptemplatefree.model.ShowTagInfoScanResult import com.nativeapptemplate.nativeapptemplatefree.model.UserData import com.nativeapptemplate.nativeapptemplatefree.network.Dispatcher import com.nativeapptemplate.nativeapptemplatefree.network.NatDispatchers @@ -54,6 +56,24 @@ class DemoLoginRepository @Inject constructor( override fun updateConfirmedTermsVersion(): Flow = MutableStateFlow(true) + override suspend fun setShouldFetchItemTagForShowTagInfoScan(shouldFetchItemTagForShowTagInfoScan: Boolean) { + } + + override suspend fun setShouldCompleteItemTagForCompleteScan(shouldCompleteItemTagForCompleteScan: Boolean) { + } + + override suspend fun setShouldNavigateToScanView(shouldNavigateToScanView: Boolean) { + } + + override suspend fun setScanViewSelectedTabIndex(scanViewSelectedTabIndex: Int) { + } + + override suspend fun setCompleteScanResult(completeScanResult: CompleteScanResult) { + } + + override suspend fun setShowTagInfoScanResult(showTagInfoScanResult: ShowTagInfoScanResult) { + } + override suspend fun setAccountId(accountId: String) { } @@ -69,6 +89,12 @@ class DemoLoginRepository @Inject constructor( override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { } + override suspend fun setDidShowTapShopBelowTip(didShowTapShopBelowTip: Boolean) { + } + + override suspend fun setDidShowReadInstructionsTip(didShowReadInstructionsTip: Boolean) { + } + override suspend fun setIsEmailUpdated(isEmailUpdated: Boolean) { } @@ -95,6 +121,24 @@ class DemoLoginRepository @Inject constructor( override fun isShopDeleted(): Flow = MutableStateFlow(true) + override fun didShowTapShopBelowTip(): Flow =MutableStateFlow(true) + + override fun didShowReadInstructionsTip(): Flow =MutableStateFlow(true) + + override fun getMaximumQueueNumberLength(): Flow = MutableStateFlow(5) + + override fun shouldFetchItemTagForShowTagInfoScan(): Flow = MutableStateFlow(true) + + override fun shouldCompleteItemTagForCompleteScan(): Flow = MutableStateFlow(true) + + override fun shouldNavigateToScanView(): Flow = MutableStateFlow(true) + + override fun scanViewSelectedTabIndex(): Flow = MutableStateFlow(0) + + override fun completeScanResult(): Flow = MutableStateFlow(CompleteScanResult()) + + override fun showTagInfoScanResult(): Flow = MutableStateFlow(ShowTagInfoScanResult()) + @OptIn(ExperimentalSerializationApi::class) private suspend inline fun getDataFromJsonFile(fileName: String): T = withContext(ioDispatcher) { diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/shop/DemoShopRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/shop/DemoShopRepository.kt index f259100..106b013 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/shop/DemoShopRepository.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/shop/DemoShopRepository.kt @@ -47,6 +47,8 @@ class DemoShopRepository @Inject constructor( override fun deleteShop(id: String): Flow = MutableStateFlow(true) + override fun resetShop(id: String): Flow = MutableStateFlow(true) + @OptIn(ExperimentalSerializationApi::class) private suspend inline fun getDataFromJsonFile(fileName: String): T = withContext(ioDispatcher) { diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestItemTagRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestItemTagRepository.kt new file mode 100644 index 0000000..5d12a0c --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestItemTagRepository.kt @@ -0,0 +1,44 @@ +package com.nativeapptemplate.nativeapptemplatefree.testing.repository + +import com.nativeapptemplate.nativeapptemplatefree.data.item_tag.ItemTagRepository +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagBody +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTags +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow + +class TestItemTagRepository : ItemTagRepository { + private val itemTagsFlow: MutableSharedFlow = + MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + private val itemTagFlow: MutableSharedFlow = + MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + + override fun getItemTags(shopId: String): Flow = itemTagsFlow + + override fun getItemTag(id: String): Flow = itemTagFlow + + override fun createItemTag(shopId: String, itemTagBody: ItemTagBody): Flow = itemTagFlow + + override fun updateItemTag(id: String, itemTagBody: ItemTagBody): Flow = itemTagFlow + + override fun deleteItemTag(id: String): Flow = MutableStateFlow(true) + + override fun completeItemTag(id: String): Flow = itemTagFlow + + override fun resetItemTag(id: String): Flow = itemTagFlow + + /** + * A test-only API. + */ + fun sendItemTags(itemTags: ItemTags) { + itemTagsFlow.tryEmit(itemTags) + } + + fun sendItemTag(itemTag: ItemTag) { + itemTagFlow.tryEmit(itemTag) + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt index 0cbe77b..26f88f0 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt @@ -1,16 +1,19 @@ package com.nativeapptemplate.nativeapptemplatefree.testing.repository import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository +import com.nativeapptemplate.nativeapptemplatefree.model.CompleteScanResult import com.nativeapptemplate.nativeapptemplatefree.model.DarkThemeConfig import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper import com.nativeapptemplate.nativeapptemplatefree.model.Login import com.nativeapptemplate.nativeapptemplatefree.model.Permissions +import com.nativeapptemplate.nativeapptemplatefree.model.ShowTagInfoScanResult import com.nativeapptemplate.nativeapptemplatefree.model.UserData import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map val emptyUserData = UserData( ) @@ -41,6 +44,33 @@ class TestLoginRepository : LoginRepository { override fun updateConfirmedTermsVersion(): Flow = MutableStateFlow(true) + override suspend fun setShouldFetchItemTagForShowTagInfoScan(shouldFetchItemTagForShowTagInfoScan: Boolean) { + currentUserData.let { current -> + _userData.tryEmit(current.copy(shouldFetchItemTagForShowTagInfoScan = shouldFetchItemTagForShowTagInfoScan)) + } + } + + override suspend fun setShouldCompleteItemTagForCompleteScan(shouldCompleteItemTagForCompleteScan: Boolean) { + currentUserData.let { current -> + _userData.tryEmit(current.copy(shouldCompleteItemTagForCompleteScan = shouldCompleteItemTagForCompleteScan)) + } + } + + override suspend fun setShouldNavigateToScanView(shouldNavigateToScanView: Boolean) { + } + + override suspend fun setScanViewSelectedTabIndex(scanViewSelectedTabIndex: Int) { + currentUserData.let { current -> + _userData.tryEmit(current.copy(scanViewSelectedTabIndex = scanViewSelectedTabIndex)) + } + } + + override suspend fun setCompleteScanResult(completeScanResult: CompleteScanResult) { + } + + override suspend fun setShowTagInfoScanResult(showTagInfoScanResult: ShowTagInfoScanResult) { + } + override suspend fun setAccountId(accountId: String) { } @@ -97,6 +127,15 @@ class TestLoginRepository : LoginRepository { } } + override suspend fun setDidShowTapShopBelowTip(didShowTapShopBelowTip: Boolean) { + } + + override suspend fun setDidShowReadInstructionsTip(didShowReadInstructionsTip: Boolean) { + currentUserData.let { current -> + _userData.tryEmit(current.copy(didShowReadInstructionsTip = didShowReadInstructionsTip)) + } + } + override suspend fun setIsEmailUpdated(isEmailUpdated: Boolean) { currentUserData.let { current -> _userData.tryEmit(current.copy(isEmailUpdated = isEmailUpdated)) @@ -129,6 +168,25 @@ class TestLoginRepository : LoginRepository { override fun isShopDeleted(): Flow = MutableStateFlow(false) + + override fun didShowTapShopBelowTip(): Flow = MutableStateFlow(true) + + override fun didShowReadInstructionsTip(): Flow = userData.map { it.didShowReadInstructionsTip } + + override fun getMaximumQueueNumberLength(): Flow = userData.map { it.maximumQueueNumberLength } + + override fun shouldFetchItemTagForShowTagInfoScan(): Flow = MutableStateFlow(true) + + override fun shouldCompleteItemTagForCompleteScan(): Flow = MutableStateFlow(true) + + override fun shouldNavigateToScanView(): Flow = MutableStateFlow(true) + + override fun scanViewSelectedTabIndex(): Flow = MutableStateFlow(0) + + override fun completeScanResult(): Flow = MutableStateFlow(CompleteScanResult()) + + override fun showTagInfoScanResult(): Flow = MutableStateFlow(ShowTagInfoScanResult()) + /** * A test-only API. */ diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestShopRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestShopRepository.kt index 25b1b63..c4e2495 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestShopRepository.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestShopRepository.kt @@ -27,6 +27,8 @@ class TestShopRepository : ShopRepository { override fun deleteShop(id: String): Flow = MutableStateFlow(true) + override fun resetShop(id: String): Flow = MutableStateFlow(true) + /** * A test-only API. */ diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/DoScanViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/DoScanViewModelTest.kt new file mode 100644 index 0000000..77d068a --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/DoScanViewModelTest.kt @@ -0,0 +1,122 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.scan + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.testing.invoke +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.emptyUserData +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import com.nativeapptemplate.nativeapptemplatefree.ui.scan.navigation.DoScanRoute +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`. + * + * TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency. + * * See b/340966212. + */ +@RunWith(RobolectricTestRunner::class) +class DoScanViewModelTestIsTestTrue { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val loginRepository = TestLoginRepository() + + private lateinit var viewModel: DoScanViewModel + + @Before + fun setUp() { + viewModel = DoScanViewModel( + savedStateHandle = SavedStateHandle( + route = DoScanRoute(isTest = true), + ), + loginRepository = loginRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun stateIsScanned_updated() = runTest { + loginRepository.sendUserData(emptyUserData) + + viewModel.updateIsScanned(true) + + assertTrue(viewModel.uiState.value.isScanned) + } + + @Test + fun scanViewSelectedTabIndex_isSavedInPreference() = runTest { + loginRepository.sendUserData(emptyUserData) + + viewModel.updateScanViewSelectedTabIndex() + + assertEquals(loginRepository.userData.first().scanViewSelectedTabIndex, 1) + } + + @Test + fun shouldFetchItemTagForShowTagInfoScan_isSavedInPreference() = runTest { + loginRepository.sendUserData(emptyUserData) + + viewModel.updateExecFlagAfterScanning(true) + + assertTrue(loginRepository.userData.first().shouldFetchItemTagForShowTagInfoScan) + } +} + +/** + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`. + * + * TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency. + * * See b/340966212. + */ +@RunWith(RobolectricTestRunner::class) +class DoScanViewModelTestIsTestFalse { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val loginRepository = TestLoginRepository() + + private lateinit var viewModel: DoScanViewModel + + @Before + fun setUp() { + viewModel = DoScanViewModel( + savedStateHandle = SavedStateHandle( + route = DoScanRoute(isTest = false), + ), + loginRepository = loginRepository, + ) + } + + @Test + fun scanViewSelectedTabIndex_isSavedInPreference() = runTest { + loginRepository.sendUserData(emptyUserData) + + viewModel.updateScanViewSelectedTabIndex() + + assertEquals(loginRepository.userData.first().scanViewSelectedTabIndex, 0) + } + + @Test + fun shouldCompleteItemTagForCompleteScan_isSavedInPreference() = runTest { + loginRepository.sendUserData(emptyUserData) + + viewModel.updateExecFlagAfterScanning(true) + + assertTrue(loginRepository.userData.first().shouldCompleteItemTagForCompleteScan) + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/ScanViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/ScanViewModelTest.kt new file mode 100644 index 0000000..fd809d3 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/scan/ScanViewModelTest.kt @@ -0,0 +1,312 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.scan + +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.CompleteScanResultType +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagData +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagInfoFromNdefMessage +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagType +import com.nativeapptemplate.nativeapptemplatefree.model.ShowTagInfoScanResultType +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestItemTagRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.emptyUserData +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class ScanViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val loginRepository = TestLoginRepository() + private val itemTagRepository = TestItemTagRepository() + + private lateinit var viewModel: ScanViewModel + + @Before + fun setUp() { + viewModel = ScanViewModel( + loginRepository = loginRepository, + itemTagRepository = itemTagRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertTrue(viewModel.uiState.value.isLoading) + } + + @Test + fun stateUserData_whenSuccess_matchesUserDataFromRepository() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + + viewModel.reload() + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.success) + assertFalse(uiStateValue.isLoading) + + assertEquals(emptyUserData, uiStateValue.userData) + } + + @Test + fun itemTag_whenFetchingWithItemTagInfoFromNdefMessage_isSetInPreference() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + itemTagRepository.sendItemTag(testInputItemTag) + + viewModel.reload() + viewModel.fetchItemTagForShowTagInfoScan(testInputItemTagInfoFromNdefMessage) + + val uiStateValue = viewModel.uiState.value + val showTagInfoScanResult = uiStateValue.showTagInfoScanResult + assertEquals( + showTagInfoScanResult.itemTagInfoFromNdefMessage, + testInputItemTagInfoFromNdefMessage + ) + + assertEquals( + showTagInfoScanResult.itemTagData, + ItemTagData(testInputItemTag) + ) + + assertEquals( + showTagInfoScanResult.showTagInfoScanResultType, + ShowTagInfoScanResultType.Succeeded + ) + } + + @Test + fun itemTag_whenCompletingWithItemTagInfoFromNdefMessage_isSetInPreference() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + itemTagRepository.sendItemTag(testInputItemTag) + + viewModel.reload() + viewModel.completeItemTag(testInputItemTagInfoFromNdefMessage) + + val uiStateValue = viewModel.uiState.value + val completeScanResult = uiStateValue.completeScanResult + assertEquals( + completeScanResult.itemTagInfoFromNdefMessage, + testInputItemTagInfoFromNdefMessage + ) + + assertEquals( + completeScanResult.itemTagData, + ItemTagData(testInputItemTag) + ) + + assertEquals( + completeScanResult.completeScanResultType, + CompleteScanResultType.Completed + ) + + assertEquals( + uiStateValue.isAlreadyCompleted, + false + ) + } + + @Test + fun stateIsAlreadyCompleted_whenCompletingAlreadyCompletedItemTag_becomesTrue() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + + val newTestInputItemTag = ItemTag( + datum = Data( + id = ITEM_TAG_ID, + type = ITEM_TAG_TYPE, + attributes = testInputItemTag.datum!!.attributes!!.copy( + alreadyCompleted = true, + ) + ) + ) + + itemTagRepository.sendItemTag(newTestInputItemTag) + + viewModel.reload() + viewModel.completeItemTag(testInputItemTagInfoFromNdefMessage) + + val uiStateValue = viewModel.uiState.value + val completeScanResult = uiStateValue.completeScanResult + assertEquals( + completeScanResult.itemTagInfoFromNdefMessage, + testInputItemTagInfoFromNdefMessage + ) + + assertEquals( + completeScanResult.itemTagData, + ItemTagData(newTestInputItemTag) + ) + + assertEquals( + completeScanResult.completeScanResultType, + CompleteScanResultType.Completed + ) + + assertEquals( + uiStateValue.isAlreadyCompleted, + true + ) + } + + @Test + fun itemTag_whenResettingWithItemTagInfoFromNdefMessage_isSetInPreference() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + itemTagRepository.sendItemTag(testInputItemTag) + + viewModel.reload() + viewModel.resetItemTag(testInputItemTagInfoFromNdefMessage) + + val uiStateValue = viewModel.uiState.value + val completeScanResult = uiStateValue.completeScanResult + assertEquals( + completeScanResult.itemTagInfoFromNdefMessage, + testInputItemTagInfoFromNdefMessage + ) + + assertEquals( + completeScanResult.itemTagData, + ItemTagData(testInputItemTag) + ) + + assertEquals( + completeScanResult.completeScanResultType, + CompleteScanResultType.Reset + ) + + assertEquals( + uiStateValue.isAlreadyCompleted, + false + ) + } + + @Test + fun stateMessage_isUpdated() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + itemTagRepository.sendItemTag(testInputItemTag) + + viewModel.reload() + val newMessage = "new message" + viewModel.updateMessage(newMessage) + + val uiStateValue = viewModel.uiState.value + assertEquals(uiStateValue.message, newMessage) + } + + @Test + fun stateIsAlreadyCompleted_isUpdated() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + itemTagRepository.sendItemTag(testInputItemTag) + + viewModel.reload() + assertFalse(viewModel.uiState.value.isAlreadyCompleted) + + viewModel.updateIsAlreadyCompleted(true) + + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.isAlreadyCompleted) + } + + @Test + fun shouldFetchItemTagForShowTagInfoScan_isSavedInPreference() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + itemTagRepository.sendItemTag(testInputItemTag) + + viewModel.reload() + assertFalse(viewModel.uiState.value.userData.shouldFetchItemTagForShowTagInfoScan) + + viewModel.updateShouldFetchItemTagForShowTagInfoScan(true) + + viewModel.reload() + assertTrue(viewModel.uiState.value.userData.shouldFetchItemTagForShowTagInfoScan) + } + + @Test + fun shouldCompleteItemTagForCompleteScan_isSavedInPreference() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + itemTagRepository.sendItemTag(testInputItemTag) + + viewModel.reload() + assertFalse(viewModel.uiState.value.userData.shouldCompleteItemTagForCompleteScan) + + viewModel.updateShouldCompleteItemTagForCompleteScan(true) + + viewModel.reload() + assertTrue(viewModel.uiState.value.userData.shouldCompleteItemTagForCompleteScan) + } +} + +private const val SHOP_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val SHOP_NAME = "8th & Townsend" + +private const val ITEM_TAG_TYPE = "item_tag" +private const val ITEM_TAG_ID = "9712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val ITEM_TAG_QUEUE_NUMBER = "A001" +private const val ITEM_TAG_STATE = "idled" +private const val ITEM_TAG_SCAN_STATE = "unscanned" +private const val ITEM_TAG_CREATED_AT = "2025-01-02T12:00:00.000Z" +private const val ITEM_TAG_CUSTOMER_READ_AT = "2025-01-02T12:00:01.000Z" +private const val ITEM_TAG_COMPLETED_AT = "2025-01-02T12:00:03.000Z" +private const val ITEM_TAG_ALREADY_COMPLETED = false + +private val testInputItemTagData = + Data( + id = ITEM_TAG_ID, + type = ITEM_TAG_TYPE, + attributes = Attributes( + shopId = SHOP_ID, + queueNumber = ITEM_TAG_QUEUE_NUMBER, + state = ITEM_TAG_STATE, + scanState = ITEM_TAG_SCAN_STATE, + createdAt = ITEM_TAG_CREATED_AT, + shopName = SHOP_NAME, + customerReadAt = ITEM_TAG_CUSTOMER_READ_AT, + completedAt = ITEM_TAG_COMPLETED_AT, + alreadyCompleted = ITEM_TAG_ALREADY_COMPLETED + ) + ) + +private val testInputItemTag = ItemTag( + datum = testInputItemTagData, +) + +private const val ITEM_TAG_INFO_FROM_NDEF_MESSAGE_ID = "9712F2DF-DFC7-A3AA-66BC-191203654A1A" +private val ITEM_TAG_INFO_FROM_NDEF_MESSAGE_ITEM_TAG_TYPE = ItemTagType.Server +private const val ITEM_TAG_INFO_FROM_NDEF_MESSAGE_SUCCESS = true +private const val ITEM_TAG_INFO_FROM_NDEF_MESSAGE_MESSAGE = "message" +private const val ITEM_TAG_INFO_FROM_NDEF_MESSAGE_IS_READ_ONLY = false +private const val ITEM_TAG_INFO_FROM_NDEF_MESSAGE_SCANNED_AT = "" + +private val testInputItemTagInfoFromNdefMessage = ItemTagInfoFromNdefMessage( + id = ITEM_TAG_INFO_FROM_NDEF_MESSAGE_ID, + itemTagType = ITEM_TAG_INFO_FROM_NDEF_MESSAGE_ITEM_TAG_TYPE, + success = ITEM_TAG_INFO_FROM_NDEF_MESSAGE_SUCCESS, + message = ITEM_TAG_INFO_FROM_NDEF_MESSAGE_MESSAGE, + isReadOnly = ITEM_TAG_INFO_FROM_NDEF_MESSAGE_IS_READ_ONLY, + scannedAt = ITEM_TAG_INFO_FROM_NDEF_MESSAGE_SCANNED_AT, +) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailViewModelTest.kt index 35da090..dcab73e 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailViewModelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailViewModelTest.kt @@ -4,8 +4,13 @@ import androidx.lifecycle.SavedStateHandle import androidx.navigation.testing.invoke import com.nativeapptemplate.nativeapptemplatefree.model.Attributes import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTags import com.nativeapptemplate.nativeapptemplatefree.model.Shop +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestItemTagRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestShopRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.emptyUserData import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule import com.nativeapptemplate.nativeapptemplatefree.ui.shop_detail.navigation.ShopDetailRoute import kotlinx.coroutines.flow.collect @@ -35,6 +40,8 @@ class ShopDetailViewModelTest { val dispatcherRule = MainDispatcherRule() private val shopRepository = TestShopRepository() + private val loginRepository = TestLoginRepository() + private val itemTagRepository = TestItemTagRepository() private lateinit var viewModel: ShopDetailViewModel @@ -44,7 +51,9 @@ class ShopDetailViewModelTest { savedStateHandle = SavedStateHandle( route = ShopDetailRoute(id = testInputShop.datum!!.id!!), ), + loginRepository = loginRepository, shopRepository = shopRepository, + itemTagRepository = itemTagRepository, ) } @@ -57,7 +66,9 @@ class ShopDetailViewModelTest { fun stateShop_whenSuccess_matchesShopFromRepository() = runTest { backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + loginRepository.sendUserData(emptyUserData) shopRepository.sendShop(testInputShop) + itemTagRepository.sendItemTags(testInputItemTags) viewModel.reload() val uiStateValue = viewModel.uiState.value @@ -68,6 +79,72 @@ class ShopDetailViewModelTest { assertEquals(shopFromRepository, uiStateValue.shop) } + + @Test + fun stateIsLoading_whenCompletingItemTag_becomesFalse() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + shopRepository.sendShop(testInputShop) + itemTagRepository.sendItemTags(testInputItemTags) + itemTagRepository.sendItemTag(testInputItemTag) + + viewModel.reload() + viewModel.completeItemTag(testInputItemTags.datum.first().id!!) + + val uiStateValue = viewModel.uiState.value + assertFalse(uiStateValue.isLoading) + } + + @Test + fun stateIsLoading_whenResettingItemTag_becomesFalse() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + shopRepository.sendShop(testInputShop) + itemTagRepository.sendItemTags(testInputItemTags) + itemTagRepository.sendItemTag(testInputItemTag) + + viewModel.reload() + viewModel.resetItemTag(testInputItemTags.datum.first().id!!) + + val uiStateValue = viewModel.uiState.value + assertFalse(uiStateValue.isLoading) + } + + @Test + fun didShowReadInstructionsTip_whenUpdatingDidShowReadInstructionsTip_isSavedInPreference() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + val userData = emptyUserData.copy(didShowReadInstructionsTip = false) + loginRepository.sendUserData(userData) + shopRepository.sendShop(testInputShop) + itemTagRepository.sendItemTags(testInputItemTags) + + viewModel.reload() + assertFalse(viewModel.uiState.value.didShowReadInstructionsTip) + + viewModel.updateDidShowReadInstructionsTip(true) + + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.didShowReadInstructionsTip) + } + + @Test + fun stateMessage_isUpdated() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + shopRepository.sendShop(testInputShop) + itemTagRepository.sendItemTags(testInputItemTags) + + viewModel.reload() + val newMessage = "new message" + viewModel.updateMessage(newMessage) + + val uiStateValue = viewModel.uiState.value + assertEquals(uiStateValue.message, newMessage) + } } private const val SHOP_TYPE = "shop" @@ -76,7 +153,7 @@ private const val SHOP_NAME = "8th & Townsend" private const val SHOP_DESCRIPTION = "This is a shop." private const val SHOP_TIME_ZONE = "Pacific Time (US & Canada)" -private val testInputShopsData = +private var testInputShopsData = Data( id = SHOP_ID, type = SHOP_TYPE, @@ -84,9 +161,80 @@ private val testInputShopsData = name = SHOP_NAME, description = SHOP_DESCRIPTION, timeZone = SHOP_TIME_ZONE, + completedItemTagsCount = 3, ) ) -private val testInputShop = Shop( +private var testInputShop = Shop( datum = testInputShopsData, ) + +private const val ITEM_TAG_TYPE = "item_tag" +private const val ITEM_TAG_1_ID = "9712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val ITEM_TAG_2_ID = "9712F2DF-DFC7-A3AA-66BC-191203654A1B" +private const val ITEM_TAG_3_ID = "9712F2DF-DFC7-A3AA-66BC-191203654A1C" +private const val ITEM_TAG_1_QUEUE_NUMBER = "A001" +private const val ITEM_TAG_2_QUEUE_NUMBER = "A002" +private const val ITEM_TAG_3_QUEUE_NUMBER = "A003" +private const val ITEM_TAG_STATE = "idled" +private const val ITEM_TAG_SCAN_STATE = "unscanned" +private const val ITEM_TAG_CREATED_AT = "2025-01-02T12:00:00.000Z" +private const val ITEM_TAG_CUSTOMER_READ_AT = "2025-01-02T12:00:01.000Z" +private const val ITEM_TAG_COMPLETED_AT = "2025-01-02T12:00:03.000Z" +private const val ITEM_TAG_ALREADY_COMPLETED = false + +private val testInputItemTagsData = listOf( + Data( + id = ITEM_TAG_1_ID, + type = ITEM_TAG_TYPE, + attributes = Attributes( + shopId = SHOP_ID, + queueNumber = ITEM_TAG_1_QUEUE_NUMBER, + state = ITEM_TAG_STATE, + scanState = ITEM_TAG_SCAN_STATE, + createdAt = ITEM_TAG_CREATED_AT, + shopName = SHOP_NAME, + customerReadAt = ITEM_TAG_CUSTOMER_READ_AT, + completedAt = ITEM_TAG_COMPLETED_AT, + alreadyCompleted = ITEM_TAG_ALREADY_COMPLETED + ) + ), + Data( + id = ITEM_TAG_2_ID, + type = ITEM_TAG_TYPE, + attributes = Attributes( + shopId = SHOP_ID, + queueNumber = ITEM_TAG_2_QUEUE_NUMBER, + state = ITEM_TAG_STATE, + scanState = ITEM_TAG_SCAN_STATE, + createdAt = ITEM_TAG_CREATED_AT, + shopName = SHOP_NAME, + customerReadAt = ITEM_TAG_CUSTOMER_READ_AT, + completedAt = ITEM_TAG_COMPLETED_AT, + alreadyCompleted = ITEM_TAG_ALREADY_COMPLETED + ) + ), + Data( + id = ITEM_TAG_3_ID, + type = ITEM_TAG_TYPE, + attributes = Attributes( + shopId = SHOP_ID, + queueNumber = ITEM_TAG_3_QUEUE_NUMBER, + state = ITEM_TAG_STATE, + scanState = ITEM_TAG_SCAN_STATE, + createdAt = ITEM_TAG_CREATED_AT, + shopName = SHOP_NAME, + customerReadAt = ITEM_TAG_CUSTOMER_READ_AT, + completedAt = ITEM_TAG_COMPLETED_AT, + alreadyCompleted = ITEM_TAG_ALREADY_COMPLETED + ) + ), +) + +private val testInputItemTags = ItemTags( + datum = testInputItemTagsData, +) + +private val testInputItemTag = ItemTag( + datum = testInputItemTagsData.first(), +) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/NumberTagsWebpageListViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/NumberTagsWebpageListViewModelTest.kt new file mode 100644 index 0000000..b1a3a6d --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/NumberTagsWebpageListViewModelTest.kt @@ -0,0 +1,95 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.testing.invoke +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.Shop +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestShopRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.NumberTagsWebpageListRoute +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * To learn more about how this test handles Flows created with stateIn, see + * https://developer.android.com/kotlin/flow/test#statein + * + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`. + * + * TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency. + * * See b/340966212. + */ +@RunWith(RobolectricTestRunner::class) +class NumberTagsWebpageListViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val shopRepository = TestShopRepository() + + private lateinit var viewModel: NumberTagsWebpageListViewModel + + @Before + fun setUp() { + viewModel = NumberTagsWebpageListViewModel( + savedStateHandle = SavedStateHandle( + route = NumberTagsWebpageListRoute(id = testInputShop.datum!!.id!!), + ), + shopRepository = shopRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertTrue(viewModel.uiState.value.isLoading) + } + + @Test + fun stateShop_whenSuccess_matchesShopFromRepository() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + + viewModel.reload() + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.success) + assertFalse(uiStateValue.isLoading) + + val shopFromRepository = shopRepository.getShop(testInputShop.datum!!.id!!).first() + + assertEquals(shopFromRepository, uiStateValue.shop) + } +} + +private const val SHOP_TYPE = "shop" +private const val SHOP_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val SHOP_NAME = "8th & Townsend" +private const val SHOP_DESCRIPTION = "This is a shop." +private const val SHOP_TIME_ZONE = "Pacific Time (US & Canada)" + +private val testInputShopsData = + Data( + id = SHOP_ID, + type = SHOP_TYPE, + attributes = Attributes( + name = SHOP_NAME, + description = SHOP_DESCRIPTION, + timeZone = SHOP_TIME_ZONE, + ) + ) + +private val testInputShop = Shop( + datum = testInputShopsData, +) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailViewModelTest.kt new file mode 100644 index 0000000..4c26789 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailViewModelTest.kt @@ -0,0 +1,147 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_detail + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.testing.invoke +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestItemTagRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.ItemTagDetailRoute +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`. + * + * TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency. + * * See b/340966212. + */ +@RunWith(RobolectricTestRunner::class) +class ItemTagDetailViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val itemTagRepository = TestItemTagRepository() + + private lateinit var viewModel: ItemTagDetailViewModel + + @Before + fun setUp() { + viewModel = ItemTagDetailViewModel( + savedStateHandle = SavedStateHandle( + route = ItemTagDetailRoute(id = testInputItemTag.datum!!.id!!), + ), + itemTagRepository = itemTagRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertTrue(viewModel.uiState.value.isLoading) + } + + @Test + fun stateItemTag_whenSuccess_matchesItemTagFromRepository() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + itemTagRepository.sendItemTag((testInputItemTag)) + + viewModel.reload() + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.success) + assertFalse(uiStateValue.isLoading) + + val itemTagFromRepository = itemTagRepository.getItemTag(testInputItemTag.datum!!.id!!).first() + + assertEquals(itemTagFromRepository, uiStateValue.itemTag) + } + + @Test + fun stateIsDeleted_whenDeletingItemTag_becomesTrue() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + itemTagRepository.sendItemTag(testInputItemTag) + + viewModel.reload() + viewModel.deleteItemTag() + + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.isDeleted) + } + + @Test + fun stateIsLock_isUpdated() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + itemTagRepository.sendItemTag(testInputItemTag) + + viewModel.reload() + + viewModel.updateIsLock(true) + assertTrue(viewModel.uiState.value.isLock) + + viewModel.updateIsLock(false) + assertFalse(viewModel.uiState.value.isLock) + } + + @Test + fun stateMessage_isUpdated() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + itemTagRepository.sendItemTag(testInputItemTag) + + viewModel.reload() + val newMessage = "new message" + viewModel.updateMessage(newMessage) + + val uiStateValue = viewModel.uiState.value + assertEquals(uiStateValue.message, newMessage) + } +} + +private const val SHOP_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val SHOP_NAME = "8th & Townsend" + +private const val ITEM_TAG_TYPE = "item_tag" +private const val ITEM_TAG_ID = "9712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val ITEM_TAG_QUEUE_NUMBER = "A001" +private const val ITEM_TAG_STATE = "idled" +private const val ITEM_TAG_SCAN_STATE = "unscanned" +private const val ITEM_TAG_CREATED_AT = "2025-01-02T12:00:00.000Z" +private const val ITEM_TAG_CUSTOMER_READ_AT = "2025-01-02T12:00:01.000Z" +private const val ITEM_TAG_COMPLETED_AT = "2025-01-02T12:00:03.000Z" +private const val ITEM_TAG_ALREADY_COMPLETED = false + +private val testInputItemTagData = + Data( + id = ITEM_TAG_ID, + type = ITEM_TAG_TYPE, + attributes = Attributes( + shopId = SHOP_ID, + queueNumber = ITEM_TAG_QUEUE_NUMBER, + state = ITEM_TAG_STATE, + scanState = ITEM_TAG_SCAN_STATE, + createdAt = ITEM_TAG_CREATED_AT, + shopName = SHOP_NAME, + customerReadAt = ITEM_TAG_CUSTOMER_READ_AT, + completedAt = ITEM_TAG_COMPLETED_AT, + alreadyCompleted = ITEM_TAG_ALREADY_COMPLETED + ) + ) + +private val testInputItemTag = ItemTag( + datum = testInputItemTagData, +) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagEditViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagEditViewModelTest.kt new file mode 100644 index 0000000..affd308 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagEditViewModelTest.kt @@ -0,0 +1,164 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_detail + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.testing.invoke +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestItemTagRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.emptyUserData +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.ItemTagEditRoute +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`. + * + * TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency. + * * See b/340966212. + */ +@RunWith(RobolectricTestRunner::class) +class ItemTagEditViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val loginRepository = TestLoginRepository() + private val itemTagRepository = TestItemTagRepository() + + private lateinit var viewModel: ItemTagEditViewModel + + @Before + fun setUp() { + viewModel = ItemTagEditViewModel( + savedStateHandle = SavedStateHandle( + route = ItemTagEditRoute(id = testInputItemTag.datum!!.id!!), + ), + loginRepository = loginRepository, + itemTagRepository = itemTagRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertTrue(viewModel.uiState.value.isLoading) + } + + @Test + fun stateItemTag_whenSuccess_matchesItemTagFromRepository() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + itemTagRepository.sendItemTag((testInputItemTag)) + + viewModel.reload() + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.success) + assertFalse(uiStateValue.isLoading) + + val itemTagFromRepository = itemTagRepository.getItemTag(testInputItemTag.datum!!.id!!).first() + + assertEquals(itemTagFromRepository, uiStateValue.itemTag) + } + + @Test + fun stateIsUpdated_whenUpdatingItemTag_becomesTrue() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + val maximumQueueNumberLength = 5 + val userData = emptyUserData.copy( + maximumQueueNumberLength = maximumQueueNumberLength + ) + + loginRepository.sendUserData(userData) + itemTagRepository.sendItemTag(testInputItemTag) + + viewModel.reload() + val newQueueNumber = "Z0001" + viewModel.updateQueueNumber(newQueueNumber) + assertEquals(viewModel.uiState.value.queueNumber, newQueueNumber) + assertFalse(viewModel.hasInvalidData()) + + viewModel.updateItemTag() + + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.isUpdated) + } + + @Test + fun blankQueueNumber_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateQueueNumber("") + + assertTrue(viewModel.hasInvalidDataQueueNumber()) + assertTrue(viewModel.hasInvalidData()) + } + + @Test + fun queueNumberWithIncorrectLength_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateQueueNumber("123456") + + assertTrue(viewModel.hasInvalidDataQueueNumber()) + assertTrue(viewModel.hasInvalidData()) + } + + @Test + fun wrongFormatQueueNumber_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateQueueNumber("@1234") + + assertTrue(viewModel.hasInvalidDataQueueNumber()) + assertTrue(viewModel.hasInvalidData()) + } +} + +private const val SHOP_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val SHOP_NAME = "8th & Townsend" + +private const val ITEM_TAG_TYPE = "item_tag" +private const val ITEM_TAG_ID = "9712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val ITEM_TAG_QUEUE_NUMBER = "A001" +private const val ITEM_TAG_STATE = "idled" +private const val ITEM_TAG_SCAN_STATE = "unscanned" +private const val ITEM_TAG_CREATED_AT = "2025-01-02T12:00:00.000Z" +private const val ITEM_TAG_CUSTOMER_READ_AT = "2025-01-02T12:00:01.000Z" +private const val ITEM_TAG_COMPLETED_AT = "2025-01-02T12:00:03.000Z" +private const val ITEM_TAG_ALREADY_COMPLETED = false + +private val testInputItemTagData = + Data( + id = ITEM_TAG_ID, + type = ITEM_TAG_TYPE, + attributes = Attributes( + shopId = SHOP_ID, + queueNumber = ITEM_TAG_QUEUE_NUMBER, + state = ITEM_TAG_STATE, + scanState = ITEM_TAG_SCAN_STATE, + createdAt = ITEM_TAG_CREATED_AT, + shopName = SHOP_NAME, + customerReadAt = ITEM_TAG_CUSTOMER_READ_AT, + completedAt = ITEM_TAG_COMPLETED_AT, + alreadyCompleted = ITEM_TAG_ALREADY_COMPLETED + ) + ) + +private val testInputItemTag = ItemTag( + datum = testInputItemTagData, +) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagWriteViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagWriteViewModelTest.kt new file mode 100644 index 0000000..1702b62 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagWriteViewModelTest.kt @@ -0,0 +1,93 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_detail + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.testing.invoke +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.ItemTagWriteRoute +import com.nativeapptemplate.nativeapptemplatefree.utils.Utility +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`. + * + * TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency. + * * See b/340966212. + */ +@RunWith(RobolectricTestRunner::class) +class ItemTagWriteViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private lateinit var viewModel: ItemTagWriteViewModel + + @Before + fun setUp() { + viewModel = ItemTagWriteViewModel( + savedStateHandle = SavedStateHandle( + route = ItemTagWriteRoute( + id = testInputItemTagId, + isLock = testInputIsLock, + itemTagType = testInputItemTagType + ), + ), + ) + } + + @Test + fun returnScanUri() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + assertEquals( + viewModel.scanUri, + Utility.scanUri(itemTagId = testInputItemTagId, itemTagType = testInputItemTagType) + ) + } + + @Test + fun stateIsUpdated_isUpdated() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateIsUpdated(true) + assertTrue(viewModel.uiState.value.isUpdated) + + viewModel.updateIsUpdated(false) + assertFalse(viewModel.uiState.value.isUpdated) + } + + @Test + fun stateIsFailed_isUpdated() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateIsFailed(true) + assertTrue(viewModel.uiState.value.isFailed) + + viewModel.updateIsFailed(false) + assertFalse(viewModel.uiState.value.isFailed) + } + + @Test + fun stateMessage_isUpdated() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + val newMessage = "new message" + viewModel.updateMessage(newMessage) + + val uiStateValue = viewModel.uiState.value + assertEquals(uiStateValue.message, newMessage) + } +} + +private const val testInputItemTagId = "9712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val testInputIsLock = false +private const val testInputItemTagType = "server" diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagCreateViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagCreateViewModelTest.kt new file mode 100644 index 0000000..46c8ed0 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagCreateViewModelTest.kt @@ -0,0 +1,188 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_list + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.testing.invoke +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag +import com.nativeapptemplate.nativeapptemplatefree.model.Shop +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestItemTagRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.emptyUserData +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.ItemTagCreateRoute +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`. + * + * TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency. + * * See b/340966212. + */ +@RunWith(RobolectricTestRunner::class) +class ItemTagCreateViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val loginRepository = TestLoginRepository() + private val itemTagRepository = TestItemTagRepository() + + private lateinit var viewModel: ItemTagCreateViewModel + + @Before + fun setUp() { + viewModel = ItemTagCreateViewModel( + savedStateHandle = SavedStateHandle( + route = ItemTagCreateRoute(shopId = testInputShop.datum!!.id!!), + ), + loginRepository = loginRepository, + itemTagRepository = itemTagRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertTrue(viewModel.uiState.value.isLoading) + } + + @Test + fun stateMaximumQueueNumberLength_whenSuccess_matchesMaximumQueueNumberLengthFromRepository() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + val maximumQueueNumberLength = 5 + + val userData = emptyUserData.copy( + maximumQueueNumberLength = maximumQueueNumberLength + ) + + loginRepository.sendUserData(userData) + + viewModel.reload() + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.success) + assertFalse(uiStateValue.isLoading) + + assertEquals(loginRepository.getMaximumQueueNumberLength().first(), maximumQueueNumberLength) + } + + @Test + fun stateIsCreated_whenCreatingItemTag_becomesTrue() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + val maximumQueueNumberLength = 5 + val userData = emptyUserData.copy( + maximumQueueNumberLength = maximumQueueNumberLength + ) + + loginRepository.sendUserData(userData) + itemTagRepository.sendItemTag(testInputItemTag) + + viewModel.reload() + val newQueueNumber = "A0001" + viewModel.updateQueueNumber(newQueueNumber) + assertEquals(viewModel.uiState.value.queueNumber, newQueueNumber) + assertFalse(viewModel.hasInvalidData()) + + viewModel.createItemTag() + + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.isCreated) + } + + @Test + fun blankQueueNumber_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateQueueNumber("") + + assertTrue(viewModel.hasInvalidDataQueueNumber()) + assertTrue(viewModel.hasInvalidData()) + } + + @Test + fun queueNumberWithIncorrectLength_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateQueueNumber("123456") + + assertTrue(viewModel.hasInvalidDataQueueNumber()) + assertTrue(viewModel.hasInvalidData()) + } + + @Test + fun wrongFormatQueueNumber_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateQueueNumber("@1234") + + assertTrue(viewModel.hasInvalidDataQueueNumber()) + assertTrue(viewModel.hasInvalidData()) + } +} + +private const val SHOP_TYPE = "shop" +private const val SHOP_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val SHOP_NAME = "8th & Townsend" +private const val SHOP_DESCRIPTION = "This is a shop." +private const val SHOP_TIME_ZONE = "Pacific Time (US & Canada)" + +private var testInputShopsData = + Data( + id = SHOP_ID, + type = SHOP_TYPE, + attributes = Attributes( + name = SHOP_NAME, + description = SHOP_DESCRIPTION, + timeZone = SHOP_TIME_ZONE, + completedItemTagsCount = 3, + ) + ) + +private var testInputShop = Shop( + datum = testInputShopsData, +) + +private const val ITEM_TAG_TYPE = "item_tag" +private const val ITEM_TAG_ID = "9712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val ITEM_TAG_QUEUE_NUMBER = "A001" +private const val ITEM_TAG_STATE = "idled" +private const val ITEM_TAG_SCAN_STATE = "unscanned" +private const val ITEM_TAG_CREATED_AT = "2025-01-02T12:00:00.000Z" +private const val ITEM_TAG_CUSTOMER_READ_AT = "2025-01-02T12:00:01.000Z" +private const val ITEM_TAG_COMPLETED_AT = "2025-01-02T12:00:03.000Z" +private const val ITEM_TAG_ALREADY_COMPLETED = false + +private val testInputItemTagsData = + Data( + id = ITEM_TAG_ID, + type = ITEM_TAG_TYPE, + attributes = Attributes( + shopId = SHOP_ID, + queueNumber = ITEM_TAG_QUEUE_NUMBER, + state = ITEM_TAG_STATE, + scanState = ITEM_TAG_SCAN_STATE, + createdAt = ITEM_TAG_CREATED_AT, + shopName = SHOP_NAME, + customerReadAt = ITEM_TAG_CUSTOMER_READ_AT, + completedAt = ITEM_TAG_COMPLETED_AT, + alreadyCompleted = ITEM_TAG_ALREADY_COMPLETED + ) + ) + + +private val testInputItemTag = ItemTag( + datum = testInputItemTagsData, +) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModelTest.kt new file mode 100644 index 0000000..fdb4d3b --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModelTest.kt @@ -0,0 +1,199 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.item_tag_list + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.testing.invoke +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTag +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTags +import com.nativeapptemplate.nativeapptemplatefree.model.Shop +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestItemTagRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestShopRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.ItemTagListRoute +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`. + * + * TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency. + * * See b/340966212. + */ +@RunWith(RobolectricTestRunner::class) +class ItemTagListViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val shopRepository = TestShopRepository() + private val itemTagRepository = TestItemTagRepository() + + private lateinit var viewModel: ItemTagListViewModel + + @Before + fun setUp() { + viewModel = ItemTagListViewModel( + savedStateHandle = SavedStateHandle( + route = ItemTagListRoute(shopId = testInputShop.datum!!.id!!), + ), + shopRepository = shopRepository, + itemTagRepository = itemTagRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertTrue(viewModel.uiState.value.isLoading) + } + + @Test + fun isEmpty_whenItemTagsIsMissing_isTrue() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + itemTagRepository.sendItemTags(ItemTags()) + + viewModel.reload() + + assertTrue(viewModel.isEmpty().first()) + } + + @Test + fun stateShop_whenSuccess_matchesShopFromRepository() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + itemTagRepository.sendItemTags(testInputItemTags) + + viewModel.reload() + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.success) + assertFalse(uiStateValue.isLoading) + + val shopId = testInputShop.datum!!.id!! + + val shopFromRepository = shopRepository.getShop(shopId).first() + val itemTagsFromRepository = itemTagRepository.getItemTags(shopId).first() + + assertEquals(shopFromRepository, uiStateValue.shop) + assertEquals(itemTagsFromRepository, uiStateValue.itemTags) + } + + @Test + fun stateIsLoading_whenDeletingItemTag_becomesFalse() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + itemTagRepository.sendItemTags(testInputItemTags) + itemTagRepository.sendItemTag(testInputItemTag) + + viewModel.reload() + viewModel.deleteItemTag(testInputItemTags.datum.first().id!!) + + val uiStateValue = viewModel.uiState.value + assertFalse(uiStateValue.isLoading) + } +} + +private const val SHOP_TYPE = "shop" +private const val SHOP_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val SHOP_NAME = "8th & Townsend" +private const val SHOP_DESCRIPTION = "This is a shop." +private const val SHOP_TIME_ZONE = "Pacific Time (US & Canada)" + +private var testInputShopsData = + Data( + id = SHOP_ID, + type = SHOP_TYPE, + attributes = Attributes( + name = SHOP_NAME, + description = SHOP_DESCRIPTION, + timeZone = SHOP_TIME_ZONE, + completedItemTagsCount = 3, + ) + ) + +private var testInputShop = Shop( + datum = testInputShopsData, +) + +private const val ITEM_TAG_TYPE = "item_tag" +private const val ITEM_TAG_1_ID = "9712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val ITEM_TAG_2_ID = "9712F2DF-DFC7-A3AA-66BC-191203654A1B" +private const val ITEM_TAG_3_ID = "9712F2DF-DFC7-A3AA-66BC-191203654A1C" +private const val ITEM_TAG_1_QUEUE_NUMBER = "A001" +private const val ITEM_TAG_2_QUEUE_NUMBER = "A002" +private const val ITEM_TAG_3_QUEUE_NUMBER = "A003" +private const val ITEM_TAG_STATE = "idled" +private const val ITEM_TAG_SCAN_STATE = "unscanned" +private const val ITEM_TAG_CREATED_AT = "2025-01-02T12:00:00.000Z" +private const val ITEM_TAG_CUSTOMER_READ_AT = "2025-01-02T12:00:01.000Z" +private const val ITEM_TAG_COMPLETED_AT = "2025-01-02T12:00:03.000Z" +private const val ITEM_TAG_ALREADY_COMPLETED = false + +private val testInputItemTagsData = listOf( + Data( + id = ITEM_TAG_1_ID, + type = ITEM_TAG_TYPE, + attributes = Attributes( + shopId = SHOP_ID, + queueNumber = ITEM_TAG_1_QUEUE_NUMBER, + state = ITEM_TAG_STATE, + scanState = ITEM_TAG_SCAN_STATE, + createdAt = ITEM_TAG_CREATED_AT, + shopName = SHOP_NAME, + customerReadAt = ITEM_TAG_CUSTOMER_READ_AT, + completedAt = ITEM_TAG_COMPLETED_AT, + alreadyCompleted = ITEM_TAG_ALREADY_COMPLETED + ) + ), + Data( + id = ITEM_TAG_2_ID, + type = ITEM_TAG_TYPE, + attributes = Attributes( + shopId = SHOP_ID, + queueNumber = ITEM_TAG_2_QUEUE_NUMBER, + state = ITEM_TAG_STATE, + scanState = ITEM_TAG_SCAN_STATE, + createdAt = ITEM_TAG_CREATED_AT, + shopName = SHOP_NAME, + customerReadAt = ITEM_TAG_CUSTOMER_READ_AT, + alreadyCompleted = ITEM_TAG_ALREADY_COMPLETED + ) + ), + Data( + id = ITEM_TAG_3_ID, + type = ITEM_TAG_TYPE, + attributes = Attributes( + shopId = SHOP_ID, + queueNumber = ITEM_TAG_3_QUEUE_NUMBER, + state = ITEM_TAG_STATE, + scanState = ITEM_TAG_SCAN_STATE, + createdAt = ITEM_TAG_CREATED_AT, + shopName = SHOP_NAME, + customerReadAt = ITEM_TAG_CUSTOMER_READ_AT, + completedAt = ITEM_TAG_COMPLETED_AT, + alreadyCompleted = ITEM_TAG_ALREADY_COMPLETED + ) + ), +) + +private val testInputItemTags = ItemTags( + datum = testInputItemTagsData, +) + +private val testInputItemTag = ItemTag( + datum = testInputItemTagsData.first(), +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 089e032..011dbeb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,8 @@ androidxLifecycle = "2.8.7" androidxNavigation = "2.8.5" androidxProfileinstaller = "1.4.1" androidxTracing = "1.3.0-alpha02" +capturable = "3.0.1" +composeQrCode = "1.0.1" dependencyAnalysis = "2.8.0" gmsPlugin = "4.4.2" googleOssPlugin = "0.10.6" @@ -20,6 +22,7 @@ kotlin = "2.1.0" kotlinxCoroutines = "1.10.1" kotlinxSerializationJson = "1.8.0" ksp = "2.1.0-1.0.29" +lottie = "6.6.2" okHttp = "4.12.0" protobuf = "4.29.2" protobufPlugin = "0.9.4" @@ -50,6 +53,8 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "androidxNavigation" } androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "androidxProfileinstaller" } androidx-tracing-ktx = { group = "androidx.tracing", name = "tracing-ktx", version.ref = "androidxTracing" } +capturable = { group = "dev.shreyaspatil", name = "capturable", version.ref = "capturable" } +compose-qr-code = { group = "com.lightspark", name = "compose-qr-code", version.ref = "composeQrCode" } google-oss-licenses-plugin = { group = "com.google.android.gms", name = "oss-licenses-plugin", version.ref = "googleOssPlugin" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } @@ -59,6 +64,7 @@ kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.re kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottie" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okHttp" } okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okHttp" } protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" } diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Attributes.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Attributes.kt index 3cdc9b4..a1f33ae 100644 --- a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Attributes.kt +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Attributes.kt @@ -24,6 +24,21 @@ data class Attributes( val state: String? = null, + @SerialName("queue_number") + val queueNumber: String? = null, + + @SerialName("scan_state") + val scanState: String? = null, + + @SerialName("customer_read_at") + val customerReadAt: String? = null, + + @SerialName("completed_at") + val completedAt: String? = null, + + @SerialName("already_completed") + val alreadyCompleted: Boolean? = null, + @SerialName("account_id") val accountId: String? = null, @@ -60,6 +75,18 @@ data class Attributes( @SerialName("time_zone") val timeZone: String? = null, + @SerialName("display_shop_server_path") + val displayShopServerPath: String? = null, + + @SerialName("item_tags_count") + val itemTagsCount: Int? = null, + + @SerialName("scanned_item_tags_count") + val scannedItemTagsCount: Int? = null, + + @SerialName("completed_item_tags_count") + val completedItemTagsCount: Int? = null, + val email: String? = null, val token: String? = null, @@ -69,4 +96,4 @@ data class Attributes( val uid: String? = null, val expiry: String? = null -) : Parcelable \ No newline at end of file +) : Parcelable diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/CompleteScanResult.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/CompleteScanResult.kt new file mode 100644 index 0000000..d20ca3e --- /dev/null +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/CompleteScanResult.kt @@ -0,0 +1,8 @@ +package com.nativeapptemplate.nativeapptemplatefree.model + +data class CompleteScanResult( + var itemTagInfoFromNdefMessage: ItemTagInfoFromNdefMessage = ItemTagInfoFromNdefMessage(), + var itemTagData: ItemTagData = ItemTagData(), + var completeScanResultType: CompleteScanResultType = CompleteScanResultType.Idled, + var message: String = "", +) diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/CompleteScanResultType.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/CompleteScanResultType.kt new file mode 100644 index 0000000..b2b74a7 --- /dev/null +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/CompleteScanResultType.kt @@ -0,0 +1,20 @@ +package com.nativeapptemplate.nativeapptemplatefree.model + +enum class CompleteScanResultType(val param: String, val title: String) { + Idled("idled","Idling"), + + Completed("completed","Completed!"), + + Reset("reset","Reset!"), + + Failed("failed","Failed"); + + companion object { + val titles: List = entries.map { it.title } + fun fromParam(param: String?): CompleteScanResultType? = param?.let { paramMap[it] } + fun fromTitle(title: String): CompleteScanResultType? = titleMap[title] + + private val paramMap = entries.associateBy(CompleteScanResultType::param) + private val titleMap = entries.associateBy(CompleteScanResultType::title) + } +} diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Data.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Data.kt index 933d216..3ea51fa 100644 --- a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Data.kt +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Data.kt @@ -13,9 +13,14 @@ data class Data( val relationships: Relationships? = null, val meta: Meta? = null, val included: Shops? = null, + val isOptionsRevealed: Boolean = false, ) : Parcelable { + fun getCreatedAt(): String = attributes?.createdAt ?: "" + fun getName(): String = attributes?.name ?: "" + fun getState(): String? = attributes?.state + fun getEmail(): String = attributes?.email ?: "" fun getDescription(): String = attributes?.description ?: "" @@ -30,6 +35,14 @@ data class Data( return this.copy(relationships = updatedRelationships) } + fun getItemTagState(): ItemTagState? { + return ItemTagState.fromParam(getState()) + } + + fun getScanState(): ScanState? { + return ScanState.fromParam(attributes?.scanState) + } + fun getToken(): String? = attributes?.token fun getClient(): String? = attributes?.client @@ -46,5 +59,25 @@ data class Data( fun getCurrentAccountName(): String? = attributes?.accountName + fun getQueueNumber(): String = attributes?.queueNumber ?: "" + + fun getCustomerReadAt(): String = attributes?.customerReadAt ?: "" + + fun getAlreadyCompleted(): Boolean = attributes?.alreadyCompleted ?: false + + fun getCompletedAt(): String = attributes?.completedAt ?: "" + + fun getShopId(): String? = attributes?.shopId + + fun getShopName(): String? = attributes?.shopName + fun getTimeZone(): String = attributes?.timeZone ?: TimeZones.DEFAULT_TIME_ZONE + + fun getItemTagsCount(): Int = attributes?.itemTagsCount ?: 0 + + fun getScannedItemTagsCount(): Int = attributes?.scannedItemTagsCount ?: 0 + + fun getCompletedItemTagsCount(): Int = attributes?.completedItemTagsCount ?: 0 + + fun getDisplayShopServerPath(): String = attributes?.displayShopServerPath ?: "" } diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTag.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTag.kt new file mode 100644 index 0000000..605389c --- /dev/null +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTag.kt @@ -0,0 +1,43 @@ +package com.nativeapptemplate.nativeapptemplatefree.model + +import android.os.Parcelable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.parcelize.Parcelize + +@Serializable +@Parcelize +data class ItemTag( + @SerialName("data") + var datum: Data? = null, + val meta: Meta? = null, + val included: List? = null +) : Parcelable { + fun getData(): Data? { + if (included.isNullOrEmpty()) { + return datum + } + + return datum?.updateRelationships(included) + } + + fun getId(): String = getData()?.id ?: "" + + fun getQueueNumber(): String = getData()?.getQueueNumber() ?: "" + + fun getState(): String = getData()?.getState() ?: "" + + fun getScanState(): ScanState = getData()?.getScanState() ?: ScanState.Unscanned + + fun getShopId(): String = getData()?.getShopId() ?: "" + + fun getShopName(): String = getData()?.getShopName() ?: "" + + fun getCustomerReadAt(): String? = getData()?.getCustomerReadAt() + + fun getCompletedAt(): String? = getData()?.getCompletedAt() + + fun getAlreadyCompleted(): Boolean = getData()?.getAlreadyCompleted() ?: false + + fun getCreatedAt(): String? = getData()?.getCreatedAt() +} diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagBody.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagBody.kt new file mode 100644 index 0000000..9096ffc --- /dev/null +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagBody.kt @@ -0,0 +1,14 @@ +package com.nativeapptemplate.nativeapptemplatefree.model + +import android.os.Parcelable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.parcelize.Parcelize + +@Serializable +@Parcelize +data class ItemTagBody( + @SerialName("item_tag") + var itemTagBodyDetail: ItemTagBodyDetail +) : Parcelable + diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagBodyDetail.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagBodyDetail.kt new file mode 100644 index 0000000..f11d39c --- /dev/null +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagBodyDetail.kt @@ -0,0 +1,13 @@ +package com.nativeapptemplate.nativeapptemplatefree.model + +import android.os.Parcelable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.parcelize.Parcelize + +@Serializable +@Parcelize +data class ItemTagBodyDetail( + @SerialName("queue_number") + val queueNumber: String +) : Parcelable diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagData.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagData.kt new file mode 100644 index 0000000..39408e6 --- /dev/null +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagData.kt @@ -0,0 +1,27 @@ +package com.nativeapptemplate.nativeapptemplatefree.model + +data class ItemTagData( + var id: String = "", + var shopId: String = "", + var queueNumber: String = "", + var state: ItemTagState = ItemTagState.Idled, + var scanState: ScanState = ScanState.Unscanned, + var createdAt: String = "", + var customerReadAt: String= "", + var completedAt: String= "", + var shopName: String = "", + var alreadyCompleted: Boolean = false, +) { + constructor(itemTag: ItemTag) : this( + id = itemTag.getId(), + shopId = itemTag.getShopId(), + queueNumber = itemTag.getQueueNumber(), + state = ItemTagState.fromParam(itemTag.getState())!!, + scanState = itemTag.getScanState(), + createdAt = itemTag.getCreatedAt()!!, + customerReadAt = itemTag.getCustomerReadAt() ?: "", + completedAt = itemTag.getCompletedAt() ?: "", + shopName = itemTag.getShopName(), + alreadyCompleted = itemTag.getAlreadyCompleted(), + ) +} diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagInfoFromNdefMessage.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagInfoFromNdefMessage.kt new file mode 100644 index 0000000..013c8c8 --- /dev/null +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagInfoFromNdefMessage.kt @@ -0,0 +1,11 @@ +package com.nativeapptemplate.nativeapptemplatefree.model + +data class ItemTagInfoFromNdefMessage( + var id: String = "", + var itemTagType: ItemTagType = ItemTagType.Server, + var success: Boolean = false, + var message: String = "", + + var isReadOnly : Boolean = false, + var scannedAt: String = "", +) diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagState.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagState.kt new file mode 100644 index 0000000..a1a0b42 --- /dev/null +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagState.kt @@ -0,0 +1,13 @@ +package com.nativeapptemplate.nativeapptemplatefree.model + +enum class ItemTagState(val param: String, val title: String) { + Idled("idled", "Idling"), + + Completed("completed", "Completed"); + + companion object { + fun fromParam(param: String?): ItemTagState? = param?.let { paramMap[it] } + + private val paramMap = entries.associateBy(ItemTagState::param) + } +} diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagType.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagType.kt new file mode 100644 index 0000000..01359ba --- /dev/null +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTagType.kt @@ -0,0 +1,16 @@ +package com.nativeapptemplate.nativeapptemplatefree.model + +enum class ItemTagType(val param: String, val title: String) { + Server("server","Server"), + + Customer("customer","Customer"); + + companion object { + val titles: List = entries.map { it.title } + fun fromParam(param: String?): ItemTagType? = param?.let { paramMap[it] } + fun fromTitle(title: String): ItemTagType? = titleMap[title] + + private val paramMap = entries.associateBy(ItemTagType::param) + private val titleMap = entries.associateBy(ItemTagType::title) + } +} diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTags.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTags.kt new file mode 100644 index 0000000..8e10a0b --- /dev/null +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTags.kt @@ -0,0 +1,22 @@ +package com.nativeapptemplate.nativeapptemplatefree.model + +import android.os.Parcelable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.parcelize.Parcelize + +/** + * Model for list response of Bookmark, Content, Progressions + */ +@Serializable +@Parcelize +data class ItemTags( + @SerialName("data") + val datum: List = emptyList(), + val included: List? = null, + val meta: Meta? = null +) : Parcelable { + fun getDatumWithRelationships(): List = datum.map { + it.updateRelationships(included) + } +} diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Meta.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Meta.kt index 09c1a33..e1a921c 100644 --- a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Meta.kt +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Meta.kt @@ -23,6 +23,9 @@ data class Meta( @SerialName("should_update_terms") var shouldUpdateTerms: Boolean? = null, + @SerialName("maximum_queue_number_length") + var maximumQueueNumberLength: Int = 0, + @SerialName("shop_limit_count") var shopLimitCount: Int = 0, diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Permissions.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Permissions.kt index 777c108..f1f34f6 100644 --- a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Permissions.kt +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Permissions.kt @@ -21,5 +21,7 @@ data class Permissions( fun getShouldUpdateTerms(): Boolean? = meta?.shouldUpdateTerms + fun getMaximumQueueNumberLength(): Int? = meta?.maximumQueueNumberLength + fun getShopLimitCount(): Int? = meta?.shopLimitCount } diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ScanState.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ScanState.kt new file mode 100644 index 0000000..48fa0f2 --- /dev/null +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ScanState.kt @@ -0,0 +1,11 @@ +package com.nativeapptemplate.nativeapptemplatefree.model +enum class ScanState(val param: String, val title: String) { + Unscanned("unscanned", "Unscanned"), + Scanned("scanned", "Scanned"); + + companion object { + fun fromParam(param: String?): ScanState? = param?.let { paramMap[it] } + + internal val paramMap = entries.associateBy(ScanState::param) + } +} diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Shop.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Shop.kt index a7e6570..6863a3e 100644 --- a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Shop.kt +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Shop.kt @@ -25,4 +25,8 @@ data class Shop( fun getDescription(): String = getData()?.getDescription() ?: "" fun getTimeZone(): String = getData()?.getTimeZone() ?: TimeZones.DEFAULT_TIME_ZONE + + fun getCompletedItemTagsCount(): Int = getData()?.getCompletedItemTagsCount() ?: 0 + + fun displayShopServerUrlString(baseUrlString: String): String = "${baseUrlString}/${getData()?.getDisplayShopServerPath()}" } diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ShowTagInfoScanResult.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ShowTagInfoScanResult.kt new file mode 100644 index 0000000..c296b1e --- /dev/null +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ShowTagInfoScanResult.kt @@ -0,0 +1,8 @@ +package com.nativeapptemplate.nativeapptemplatefree.model + +data class ShowTagInfoScanResult( + var itemTagInfoFromNdefMessage: ItemTagInfoFromNdefMessage = ItemTagInfoFromNdefMessage(), + var itemTagData: ItemTagData = ItemTagData(), + var showTagInfoScanResultType: ShowTagInfoScanResultType = ShowTagInfoScanResultType.Idled, + var message: String = "", +) diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ShowTagInfoScanResultType.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ShowTagInfoScanResultType.kt new file mode 100644 index 0000000..e9e0d55 --- /dev/null +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ShowTagInfoScanResultType.kt @@ -0,0 +1,18 @@ +package com.nativeapptemplate.nativeapptemplatefree.model + +enum class ShowTagInfoScanResultType(val param: String, val title: String) { + Idled("idled","Idling"), + + Succeeded("succeeded","Succeeded"), + + Failed("failed","Failed"); + + companion object { + val titles: List = entries.map { it.title } + fun fromParam(param: String?): ShowTagInfoScanResultType? = param?.let { paramMap[it] } + fun fromTitle(title: String): ShowTagInfoScanResultType? = titleMap[title] + + private val paramMap = entries.associateBy(ShowTagInfoScanResultType::param) + private val titleMap = entries.associateBy(ShowTagInfoScanResultType::title) + } +} diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/UserData.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/UserData.kt index 4653e11..a8b5f0d 100644 --- a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/UserData.kt +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/UserData.kt @@ -37,11 +37,20 @@ data class UserData( val isLoggedIn: Boolean = false, + val didShowReadInstructionsTip: Boolean = false, + var androidAppVersion: Int = -1, var shouldUpdatePrivacy: Boolean= false, var shouldUpdateTerms: Boolean= false, + var maximumQueueNumberLength: Int = -1, var shopLimitCount: Int = -1, var isEmailUpdated: Boolean = false, var isMyAccountDeleted: Boolean = false, + + var scanViewSelectedTabIndex: Int = 0, + var shouldFetchItemTagForShowTagInfoScan: Boolean = false, + var shouldCompleteItemTagForCompleteScan: Boolean = false, + + val didShowTapShopBelowTip: Boolean = false, ) \ No newline at end of file