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