Compare commits
2 Commits
90b5b3d135
...
a0e3461c48
| Author | SHA1 | Date | |
|---|---|---|---|
| a0e3461c48 | |||
| 3531ab8405 |
@@ -144,7 +144,9 @@ class TerminalDeviceService:
|
||||
|
||||
payload = {
|
||||
"api_url": settings.app_base_url,
|
||||
"store_id": store.id,
|
||||
"store_code": store.store_code,
|
||||
"store_name": store.name,
|
||||
"auth_token": token,
|
||||
}
|
||||
qr_png_base64 = self._render_qr_png_base64(json.dumps(payload))
|
||||
|
||||
@@ -101,6 +101,9 @@ dependencies {
|
||||
// DataStore (device config persistence)
|
||||
implementation(libs.datastore.preferences)
|
||||
|
||||
// bcrypt (verify staff PIN hashes locally — pure Java, no native deps)
|
||||
implementation(libs.bcrypt)
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.junit.android)
|
||||
|
||||
@@ -44,6 +44,10 @@ interface LoyaltyApi {
|
||||
@GET("api/v1/store/loyalty/pins")
|
||||
suspend fun listPins(): PinListResponse
|
||||
|
||||
// Categories (for the action sheets — multi-select pills)
|
||||
@GET("api/v1/store/loyalty/categories")
|
||||
suspend fun listCategories(): CategoryListResponse
|
||||
|
||||
// Auth
|
||||
@POST("api/v1/store/auth/login")
|
||||
suspend fun login(@Body request: LoginRequest): LoginResponse
|
||||
|
||||
@@ -111,6 +111,7 @@ data class StampRequest(
|
||||
val qr_code: String? = null,
|
||||
val card_number: String? = null,
|
||||
val staff_pin: String? = null,
|
||||
val category_ids: List<Int>? = null,
|
||||
val notes: String? = null,
|
||||
)
|
||||
|
||||
@@ -147,6 +148,7 @@ data class PointsEarnRequest(
|
||||
val purchase_amount_cents: Int,
|
||||
val order_reference: String? = null,
|
||||
val staff_pin: String? = null,
|
||||
val category_ids: List<Int>? = null,
|
||||
val notes: String? = null,
|
||||
)
|
||||
|
||||
@@ -187,3 +189,40 @@ data class PinItem(
|
||||
val is_active: Boolean,
|
||||
val is_locked: Boolean,
|
||||
)
|
||||
|
||||
// ── Pairing ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* JSON payload encoded in the pairing QR generated by
|
||||
* ``POST /api/v1/merchants/loyalty/devices`` on the backend.
|
||||
*
|
||||
* The tablet decodes this once at the setup screen, persists the three
|
||||
* fields via DeviceConfigRepository, and then never sees the QR again.
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SetupPayload(
|
||||
val api_url: String,
|
||||
val store_id: Int,
|
||||
val store_code: String,
|
||||
val store_name: String? = null,
|
||||
val auth_token: String,
|
||||
)
|
||||
|
||||
// ── Categories ──────────────────────────────────────────────────────────
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CategoryListResponse(
|
||||
val categories: List<CategoryItem>,
|
||||
val total: Int,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CategoryItem(
|
||||
val id: Int,
|
||||
val store_id: Int,
|
||||
val name: String,
|
||||
/** Per-language overrides keyed by language code (en/fr/de/lb). Null = use `name`. */
|
||||
val name_translations: Map<String, String>? = null,
|
||||
val display_order: Int = 0,
|
||||
val is_active: Boolean = true,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package lu.rewardflow.terminal.data.network
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import lu.rewardflow.terminal.data.repository.DeviceConfigRepository
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Per-request rewrite that pins the host to the device's paired ``api_url``
|
||||
* and adds the long-lived ``Authorization: Bearer <auth_token>`` header.
|
||||
*
|
||||
* Retrofit is built with a placeholder baseUrl because the real URL only
|
||||
* exists after pairing — this interceptor is what makes every call actually
|
||||
* go to the merchant's chosen server. When the device hasn't been paired
|
||||
* yet, requests pass through unchanged and will fail at the network layer
|
||||
* (which is fine: the only flows running pre-pairing are the setup-screen
|
||||
* verification call, which fills in the URL itself before invoking the API).
|
||||
*
|
||||
* `runBlocking` is intentional: OkHttp interceptors run on the IO dispatcher
|
||||
* pool and DataStore reads are cheap. The alternative (a non-suspend cache)
|
||||
* is more code for negligible gain.
|
||||
*/
|
||||
@Singleton
|
||||
class AuthInterceptor @Inject constructor(
|
||||
private val configRepository: DeviceConfigRepository,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val original = chain.request()
|
||||
val (apiUrl, authToken) = runBlocking {
|
||||
configRepository.currentApiUrl() to configRepository.currentAuthToken()
|
||||
}
|
||||
|
||||
val builder = original.newBuilder()
|
||||
|
||||
if (apiUrl != null) {
|
||||
val configuredHost = apiUrl.toHttpUrlOrNull()
|
||||
if (configuredHost != null) {
|
||||
val rewritten = original.url.newBuilder()
|
||||
.scheme(configuredHost.scheme)
|
||||
.host(configuredHost.host)
|
||||
.port(configuredHost.port)
|
||||
.build()
|
||||
builder.url(rewritten)
|
||||
}
|
||||
}
|
||||
|
||||
if (!authToken.isNullOrBlank()) {
|
||||
builder.header("Authorization", "Bearer $authToken")
|
||||
}
|
||||
|
||||
return chain.proceed(builder.build())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package lu.rewardflow.terminal.data.network
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Reactive view of "do we have a usable internet connection right now".
|
||||
*
|
||||
* The terminal screen reads this to show the offline badge and to gate
|
||||
* redemption (which can't be queued — points/stamps can be redeemed only
|
||||
* online, see Phase E of the implementation plan). The sync worker also
|
||||
* uses it indirectly via WorkManager network constraints.
|
||||
*/
|
||||
@Singleton
|
||||
class NetworkMonitor @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
private val connectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
val isOnline: Flow<Boolean> = callbackFlow {
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(true)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(currentlyOnline())
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
capabilities: NetworkCapabilities,
|
||||
) {
|
||||
trySend(currentlyOnline())
|
||||
}
|
||||
}
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
.build()
|
||||
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
// Emit the current state so collectors don't sit waiting for the
|
||||
// first transition.
|
||||
trySend(currentlyOnline())
|
||||
|
||||
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, initialValue = currentlyOnline())
|
||||
|
||||
private fun currentlyOnline(): Boolean {
|
||||
val active = connectivityManager.activeNetwork ?: return false
|
||||
val caps = connectivityManager.getNetworkCapabilities(active) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package lu.rewardflow.terminal.data.repository
|
||||
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import kotlinx.coroutines.flow.first
|
||||
import lu.rewardflow.terminal.data.api.LoyaltyApi
|
||||
import lu.rewardflow.terminal.data.model.CategoryItem
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Cached transaction categories for the store the device is paired to.
|
||||
*
|
||||
* The terminal action sheets (add stamp / earn points) show category pills
|
||||
* that the staff member multi-selects. Categories rarely change, so the
|
||||
* tablet caches the list and refreshes opportunistically (on app launch
|
||||
* and after any successful network round-trip).
|
||||
*/
|
||||
@Singleton
|
||||
class CategoryRepository @Inject constructor(
|
||||
private val api: LoyaltyApi,
|
||||
private val configRepository: DeviceConfigRepository,
|
||||
moshi: Moshi,
|
||||
) {
|
||||
|
||||
private val listAdapter: JsonAdapter<List<CategoryItem>> = moshi.adapter(
|
||||
Types.newParameterizedType(List::class.java, CategoryItem::class.java)
|
||||
)
|
||||
|
||||
suspend fun refresh(): List<CategoryItem> {
|
||||
val response = api.listCategories()
|
||||
val categories = response.categories.filter { it.is_active }
|
||||
configRepository.saveCategories(listAdapter.toJson(categories))
|
||||
return categories
|
||||
}
|
||||
|
||||
suspend fun cached(): List<CategoryItem> {
|
||||
val raw = configRepository.categoriesJson.first() ?: return emptyList()
|
||||
return runCatching { listAdapter.fromJson(raw) ?: emptyList() }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
suspend fun listOrRefresh(): List<CategoryItem> {
|
||||
val cached = cached()
|
||||
return cached.ifEmpty { refresh() }
|
||||
}
|
||||
|
||||
/** Pick the right display label for a category given the staff member's
|
||||
* language preference, falling back to the canonical `name`. */
|
||||
fun localizedName(category: CategoryItem, languageCode: String): String =
|
||||
category.name_translations?.get(languageCode) ?: category.name
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package lu.rewardflow.terminal.data.repository
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Persisted device configuration.
|
||||
*
|
||||
* Once a tablet is paired (via QR scan on the setup screen) the merchant
|
||||
* server hands the device three pieces of data: an api_url, a store_code
|
||||
* and a long-lived auth_token. They live here for the entire lifetime of
|
||||
* the pairing — the token expires after 1 year and isn't refreshed.
|
||||
*
|
||||
* The cached *_json fields are populated after pairing so the PIN screen
|
||||
* and the terminal can render before the network round-trips on each
|
||||
* launch.
|
||||
*/
|
||||
@Singleton
|
||||
class DeviceConfigRepository @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
// ── Flows (suspend collectors)
|
||||
val apiUrl: Flow<String?> = dataStore.data.map { it[KEY_API_URL] }
|
||||
val authToken: Flow<String?> = dataStore.data.map { it[KEY_AUTH_TOKEN] }
|
||||
val storeId: Flow<Int?> = dataStore.data.map { it[KEY_STORE_ID] }
|
||||
val storeCode: Flow<String?> = dataStore.data.map { it[KEY_STORE_CODE] }
|
||||
val storeName: Flow<String?> = dataStore.data.map { it[KEY_STORE_NAME] }
|
||||
val isDeviceSetUp: Flow<Boolean> = dataStore.data.map { it[KEY_IS_SET_UP] ?: false }
|
||||
|
||||
val programJson: Flow<String?> = dataStore.data.map { it[KEY_PROGRAM_JSON] }
|
||||
val staffPinsJson: Flow<String?> = dataStore.data.map { it[KEY_STAFF_PINS_JSON] }
|
||||
val categoriesJson: Flow<String?> = dataStore.data.map { it[KEY_CATEGORIES_JSON] }
|
||||
|
||||
// ── One-shot reads (when the call site is suspending and just needs
|
||||
// the current value, e.g. an OkHttp interceptor or a repository fetch).
|
||||
suspend fun currentAuthToken(): String? = dataStore.data.first()[KEY_AUTH_TOKEN]
|
||||
suspend fun currentApiUrl(): String? = dataStore.data.first()[KEY_API_URL]
|
||||
suspend fun currentStoreId(): Int? = dataStore.data.first()[KEY_STORE_ID]
|
||||
suspend fun currentStoreCode(): String? = dataStore.data.first()[KEY_STORE_CODE]
|
||||
|
||||
// ── Pairing: persist setup payload + cached config in a single transaction.
|
||||
suspend fun savePairing(
|
||||
apiUrl: String,
|
||||
authToken: String,
|
||||
storeId: Int,
|
||||
storeCode: String,
|
||||
storeName: String?,
|
||||
) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY_API_URL] = apiUrl
|
||||
prefs[KEY_AUTH_TOKEN] = authToken
|
||||
prefs[KEY_STORE_ID] = storeId
|
||||
prefs[KEY_STORE_CODE] = storeCode
|
||||
if (storeName != null) prefs[KEY_STORE_NAME] = storeName
|
||||
prefs[KEY_IS_SET_UP] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveProgram(json: String) {
|
||||
dataStore.edit { it[KEY_PROGRAM_JSON] = json }
|
||||
}
|
||||
|
||||
suspend fun saveStaffPins(json: String) {
|
||||
dataStore.edit { it[KEY_STAFF_PINS_JSON] = json }
|
||||
}
|
||||
|
||||
suspend fun saveCategories(json: String) {
|
||||
dataStore.edit { it[KEY_CATEGORIES_JSON] = json }
|
||||
}
|
||||
|
||||
/** Per-seller language preference, keyed by staff PIN id. */
|
||||
fun sellerLanguage(pinId: Int): Flow<String?> =
|
||||
dataStore.data.map { it[sellerLanguageKey(pinId)] }
|
||||
|
||||
suspend fun saveSellerLanguage(pinId: Int, lang: String) {
|
||||
dataStore.edit { it[sellerLanguageKey(pinId)] = lang }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe the device back to factory state. The merchant has to re-pair
|
||||
* after this — the auth_token is gone, so the tablet can't call any
|
||||
* authenticated endpoint until a new QR is scanned.
|
||||
*/
|
||||
suspend fun resetDevice() {
|
||||
dataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
private fun sellerLanguageKey(pinId: Int) =
|
||||
stringPreferencesKey("seller_language_$pinId")
|
||||
|
||||
companion object {
|
||||
private val KEY_API_URL = stringPreferencesKey("api_url")
|
||||
private val KEY_AUTH_TOKEN = stringPreferencesKey("auth_token")
|
||||
private val KEY_STORE_ID = intPreferencesKey("store_id")
|
||||
private val KEY_STORE_CODE = stringPreferencesKey("store_code")
|
||||
private val KEY_STORE_NAME = stringPreferencesKey("store_name")
|
||||
// Matches the key the existing SetupViewModel observed; keep this
|
||||
// exact string so the NavHost's start-destination switch works.
|
||||
private val KEY_IS_SET_UP = booleanPreferencesKey("is_device_set_up")
|
||||
private val KEY_PROGRAM_JSON = stringPreferencesKey("program_json")
|
||||
private val KEY_STAFF_PINS_JSON = stringPreferencesKey("staff_pins_json")
|
||||
private val KEY_CATEGORIES_JSON = stringPreferencesKey("categories_json")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package lu.rewardflow.terminal.data.repository
|
||||
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import kotlinx.coroutines.flow.first
|
||||
import lu.rewardflow.terminal.data.api.LoyaltyApi
|
||||
import lu.rewardflow.terminal.data.model.PinItem
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Source of truth for staff PINs on the device.
|
||||
*
|
||||
* Refreshes from ``GET /api/v1/store/loyalty/pins`` and caches the
|
||||
* serialized list in DataStore so the PIN screen can render before the
|
||||
* network call completes (and stay usable for the few seconds where the
|
||||
* tablet has no signal between transactions).
|
||||
*
|
||||
* The PIN-entry verification flow itself is wired in Phase C — this
|
||||
* repository is just the cache primitive.
|
||||
*/
|
||||
@Singleton
|
||||
class StaffPinRepository @Inject constructor(
|
||||
private val api: LoyaltyApi,
|
||||
private val configRepository: DeviceConfigRepository,
|
||||
moshi: Moshi,
|
||||
) {
|
||||
|
||||
private val listAdapter: JsonAdapter<List<PinItem>> = moshi.adapter(
|
||||
Types.newParameterizedType(List::class.java, PinItem::class.java)
|
||||
)
|
||||
|
||||
/** Hit the server, persist the result, return the freshly fetched list. */
|
||||
suspend fun refresh(): List<PinItem> {
|
||||
val response = api.listPins()
|
||||
val pins = response.pins
|
||||
configRepository.saveStaffPins(listAdapter.toJson(pins))
|
||||
return pins
|
||||
}
|
||||
|
||||
/** Last cached list. Empty if the device has never synced. */
|
||||
suspend fun cached(): List<PinItem> {
|
||||
val raw = configRepository.staffPinsJson.first() ?: return emptyList()
|
||||
return runCatching { listAdapter.fromJson(raw) ?: emptyList() }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
/** Cached if available, otherwise hit the network. */
|
||||
suspend fun listOrRefresh(): List<PinItem> {
|
||||
val cached = cached()
|
||||
return cached.ifEmpty { refresh() }
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import lu.rewardflow.terminal.BuildConfig
|
||||
import lu.rewardflow.terminal.data.api.LoyaltyApi
|
||||
import lu.rewardflow.terminal.data.db.AppDatabase
|
||||
import lu.rewardflow.terminal.data.db.dao.PendingTransactionDao
|
||||
import lu.rewardflow.terminal.data.network.AuthInterceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
@@ -32,11 +33,14 @@ object AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(): OkHttpClient {
|
||||
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
// AuthInterceptor must run BEFORE logging so the rewritten URL
|
||||
// and the Authorization header show up in the log line.
|
||||
.addInterceptor(authInterceptor)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
val logging = HttpLoggingInterceptor()
|
||||
@@ -50,6 +54,11 @@ object AppModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit {
|
||||
// Placeholder baseUrl: AuthInterceptor rewrites the host on every
|
||||
// request to the device's paired api_url. Retrofit just needs SOME
|
||||
// valid URL at construction. Keeping DEFAULT_API_URL here so dev
|
||||
// builds work out-of-the-box pointing at 10.0.2.2:8000 before the
|
||||
// device is paired.
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.DEFAULT_API_URL + "/")
|
||||
.client(okHttpClient)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package lu.rewardflow.terminal.ui.scanner
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanner
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import lu.rewardflow.terminal.R
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* CameraX preview that scans QR codes via ML Kit.
|
||||
*
|
||||
* Calls [onQrScanned] exactly once with the decoded raw value, then stops
|
||||
* analysing. Re-mount the composable (or have the caller key it on a state)
|
||||
* to scan again.
|
||||
*
|
||||
* Camera permission is requested in-place — when the user has not granted
|
||||
* it, the preview area is replaced by a permission prompt. Manifest already
|
||||
* declares `android.permission.CAMERA` as optional.
|
||||
*/
|
||||
@Composable
|
||||
fun QrScannerView(
|
||||
modifier: Modifier = Modifier,
|
||||
onQrScanned: (String) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var hasCameraPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.CAMERA
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
hasCameraPermission = granted
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
if (hasCameraPermission) {
|
||||
CameraXPreview(onQrScanned = onQrScanned)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.setup_camera_permission_required),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Button(
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||
) {
|
||||
Text(text = stringResource(R.string.setup_connect))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger the permission prompt the first time the composable is shown.
|
||||
DisposableEffect(Unit) {
|
||||
if (!hasCameraPermission) {
|
||||
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
onDispose { }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraXPreview(
|
||||
onQrScanned: (String) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val executor = remember { Executors.newSingleThreadExecutor() }
|
||||
val alreadyScanned = remember { AtomicBoolean(false) }
|
||||
|
||||
val barcodeScanner: BarcodeScanner = remember {
|
||||
BarcodeScanning.getClient(
|
||||
BarcodeScannerOptions.Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
val previewView = PreviewView(ctx)
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||
|
||||
cameraProviderFuture.addListener({
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
analysis.setAnalyzer(executor) { imageProxy ->
|
||||
val mediaImage = imageProxy.image
|
||||
if (mediaImage == null || alreadyScanned.get()) {
|
||||
imageProxy.close()
|
||||
return@setAnalyzer
|
||||
}
|
||||
val image = InputImage.fromMediaImage(
|
||||
mediaImage,
|
||||
imageProxy.imageInfo.rotationDegrees,
|
||||
)
|
||||
barcodeScanner.process(image)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
val raw = barcodes.firstOrNull()?.rawValue
|
||||
if (!raw.isNullOrBlank() && alreadyScanned.compareAndSet(false, true)) {
|
||||
onQrScanned(raw)
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { e ->
|
||||
Log.w("QrScannerView", "Barcode scan failed", e)
|
||||
}
|
||||
.addOnCompleteListener { imageProxy.close() }
|
||||
}
|
||||
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
analysis,
|
||||
)
|
||||
}, ContextCompat.getMainExecutor(ctx))
|
||||
|
||||
previewView
|
||||
},
|
||||
)
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
executor.shutdown()
|
||||
barcodeScanner.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,237 @@
|
||||
package lu.rewardflow.terminal.ui.setup
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import lu.rewardflow.terminal.R
|
||||
import lu.rewardflow.terminal.ui.scanner.QrScannerView
|
||||
|
||||
/**
|
||||
* First-time device setup screen.
|
||||
* First-time pairing screen.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Show "Scan QR Code" prompt
|
||||
* 2. Merchant owner scans QR from web settings page
|
||||
* 3. QR contains: API URL + store auth token + store_id + store_code
|
||||
* 4. App downloads store config and enters kiosk mode
|
||||
* Left half: live QR scanner. Decoded value is fed to [SetupViewModel.pairFromQr]
|
||||
* which verifies with the server and persists the configuration.
|
||||
*
|
||||
* Right half: manual entry form (dev fallback). Same downstream flow.
|
||||
*
|
||||
* On successful pair, [onSetupComplete] is invoked once — the NavHost reacts
|
||||
* to the persisted ``is_device_set_up`` flag and forwards to the PIN screen.
|
||||
*/
|
||||
@Composable
|
||||
fun SetupScreen(
|
||||
onSetupComplete: () -> Unit,
|
||||
viewModel: SetupViewModel = hiltViewModel(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(48.dp),
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(state) {
|
||||
if (state is SetupState.Success) onSetupComplete()
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Left half: QR scanner
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
Text(
|
||||
text = "RewardFlow Terminal",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
QrScannerView(
|
||||
onQrScanned = { raw -> viewModel.pairFromQr(raw) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Scan the setup QR code from your store settings page to configure this device.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
// TODO: CameraX QR scanner for setup code
|
||||
Button(onClick = onSetupComplete) {
|
||||
Text("Setup Complete (placeholder)")
|
||||
|
||||
// Status overlay on top of the camera preview
|
||||
StatusOverlay(state = state, onDismissError = viewModel::resetError)
|
||||
}
|
||||
|
||||
// ── Right half: manual entry + instruction
|
||||
ManualEntryPanel(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(32.dp),
|
||||
isConnecting = state is SetupState.Connecting,
|
||||
onConnect = { apiUrl, storeId, storeCode, token ->
|
||||
viewModel.pairManually(apiUrl, storeId, storeCode, token)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusOverlay(
|
||||
state: SetupState,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is SetupState.Connecting -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.55f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.setup_connecting),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SetupState.Error -> {
|
||||
val message = when (val e = state.reason) {
|
||||
SetupError.InvalidQr -> stringResource(R.string.setup_invalid_qr)
|
||||
is SetupError.ConnectionFailed ->
|
||||
e.detail ?: stringResource(R.string.setup_connection_failed)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = onDismissError) {
|
||||
Text(stringResource(R.string.error_try_again))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManualEntryPanel(
|
||||
modifier: Modifier = Modifier,
|
||||
isConnecting: Boolean,
|
||||
onConnect: (apiUrl: String, storeId: Int, storeCode: String, authToken: String) -> Unit,
|
||||
) {
|
||||
var apiUrl by rememberSaveable { mutableStateOf("") }
|
||||
var storeIdText by rememberSaveable { mutableStateOf("") }
|
||||
var storeCode by rememberSaveable { mutableStateOf("") }
|
||||
var authToken by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.setup_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.setup_instruction),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.setup_or_manual),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = apiUrl,
|
||||
onValueChange = { apiUrl = it },
|
||||
label = { Text(stringResource(R.string.setup_api_url)) },
|
||||
singleLine = true,
|
||||
placeholder = { Text("http://10.0.2.2:8000") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = storeCode,
|
||||
onValueChange = { storeCode = it },
|
||||
label = { Text(stringResource(R.string.setup_store_code)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = storeIdText,
|
||||
onValueChange = { storeIdText = it.filter(Char::isDigit) },
|
||||
label = { Text("Store ID") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = authToken,
|
||||
onValueChange = { authToken = it },
|
||||
label = { Text(stringResource(R.string.setup_auth_token)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
enabled = !isConnecting,
|
||||
onClick = {
|
||||
onConnect(
|
||||
apiUrl.trim(),
|
||||
storeIdText.toIntOrNull() ?: 0,
|
||||
storeCode.trim(),
|
||||
authToken.trim(),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.setup_connect))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,131 @@
|
||||
package lu.rewardflow.terminal.ui.setup
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.JsonDataException
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import lu.rewardflow.terminal.data.api.LoyaltyApi
|
||||
import lu.rewardflow.terminal.data.model.ProgramResponse
|
||||
import lu.rewardflow.terminal.data.model.SetupPayload
|
||||
import lu.rewardflow.terminal.data.repository.CategoryRepository
|
||||
import lu.rewardflow.terminal.data.repository.DeviceConfigRepository
|
||||
import lu.rewardflow.terminal.data.repository.StaffPinRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SetupViewModel @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val configRepository: DeviceConfigRepository,
|
||||
private val api: LoyaltyApi,
|
||||
private val staffPinRepository: StaffPinRepository,
|
||||
private val categoryRepository: CategoryRepository,
|
||||
moshi: Moshi,
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
val IS_SET_UP = booleanPreferencesKey("is_device_set_up")
|
||||
/** Observed by [RewardFlowNavHost] to pick the start destination. */
|
||||
val isDeviceSetUp: Flow<Boolean> = configRepository.isDeviceSetUp
|
||||
|
||||
private val _state = MutableStateFlow<SetupState>(SetupState.Idle)
|
||||
val state: StateFlow<SetupState> = _state.asStateFlow()
|
||||
|
||||
private val payloadAdapter: JsonAdapter<SetupPayload> =
|
||||
moshi.adapter(SetupPayload::class.java)
|
||||
|
||||
private val programAdapter: JsonAdapter<ProgramResponse> =
|
||||
moshi.adapter(ProgramResponse::class.java)
|
||||
|
||||
/** Decode the JSON encoded in the pairing QR and run the pair flow. */
|
||||
fun pairFromQr(qrRawValue: String) {
|
||||
val payload = try {
|
||||
payloadAdapter.fromJson(qrRawValue)
|
||||
} catch (_: JsonDataException) {
|
||||
null
|
||||
} catch (_: java.io.IOException) {
|
||||
null
|
||||
}
|
||||
if (payload == null) {
|
||||
_state.value = SetupState.Error(SetupError.InvalidQr)
|
||||
return
|
||||
}
|
||||
pairWith(payload)
|
||||
}
|
||||
|
||||
val isDeviceSetUp: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||
prefs[IS_SET_UP] ?: false
|
||||
/** Manual entry path — same flow as [pairFromQr] but with a hand-typed payload.
|
||||
*
|
||||
* Used during dev/testing when there is no QR generator handy. Production
|
||||
* users always pair via the QR. ``storeId`` is required because the device
|
||||
* needs the numeric id alongside the code (some endpoints use one, some
|
||||
* use the other).
|
||||
*/
|
||||
fun pairManually(apiUrl: String, storeId: Int, storeCode: String, authToken: String) {
|
||||
if (apiUrl.isBlank() || storeCode.isBlank() || authToken.isBlank() || storeId <= 0) {
|
||||
_state.value = SetupState.Error(SetupError.InvalidQr)
|
||||
return
|
||||
}
|
||||
pairWith(
|
||||
SetupPayload(
|
||||
api_url = apiUrl,
|
||||
store_id = storeId,
|
||||
store_code = storeCode,
|
||||
store_name = null,
|
||||
auth_token = authToken,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun resetError() {
|
||||
if (_state.value is SetupState.Error) _state.value = SetupState.Idle
|
||||
}
|
||||
|
||||
private fun pairWith(payload: SetupPayload) {
|
||||
_state.value = SetupState.Connecting
|
||||
viewModelScope.launch {
|
||||
// 1. Persist the pairing FIRST so the AuthInterceptor will use the new
|
||||
// api_url and bearer token on the verification call below.
|
||||
configRepository.savePairing(
|
||||
apiUrl = payload.api_url,
|
||||
authToken = payload.auth_token,
|
||||
storeId = payload.store_id,
|
||||
storeCode = payload.store_code,
|
||||
storeName = payload.store_name,
|
||||
)
|
||||
|
||||
// 2. Verify by hitting /program. Failure → roll back the pairing
|
||||
// so the user is back at a clean Setup screen with an error.
|
||||
val program: ProgramResponse = try {
|
||||
api.getProgram()
|
||||
} catch (e: Exception) {
|
||||
configRepository.resetDevice()
|
||||
_state.value = SetupState.Error(SetupError.ConnectionFailed(e.message))
|
||||
return@launch
|
||||
}
|
||||
|
||||
configRepository.saveProgram(programAdapter.toJson(program))
|
||||
|
||||
// 4. Warm caches. Failures here are non-fatal — the screens will
|
||||
// refresh on their own when needed.
|
||||
runCatching { staffPinRepository.refresh() }
|
||||
runCatching { categoryRepository.refresh() }
|
||||
|
||||
_state.value = SetupState.Success
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface SetupState {
|
||||
data object Idle : SetupState
|
||||
data object Connecting : SetupState
|
||||
data object Success : SetupState
|
||||
data class Error(val reason: SetupError) : SetupState
|
||||
}
|
||||
|
||||
sealed interface SetupError {
|
||||
data object InvalidQr : SetupError
|
||||
data class ConnectionFailed(val detail: String?) : SetupError
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<resources>
|
||||
<string name="app_name">RewardFlow Terminal</string>
|
||||
|
||||
<!-- Setup screen -->
|
||||
<string name="setup_title">RewardFlow Terminal</string>
|
||||
<string name="setup_instruction">Scanne den Pairing-QR aus den Filialeinstellungen</string>
|
||||
<string name="setup_or_manual">oder manuell eingeben</string>
|
||||
<string name="setup_api_url">API-URL</string>
|
||||
<string name="setup_store_code">Filialcode</string>
|
||||
<string name="setup_auth_token">Auth-Token</string>
|
||||
<string name="setup_connect">Verbinden</string>
|
||||
<string name="setup_connecting">Verbindung…</string>
|
||||
<string name="setup_invalid_qr">Ungültiger Pairing-QR</string>
|
||||
<string name="setup_connection_failed">Verbindung zum Server nicht möglich</string>
|
||||
<string name="setup_camera_permission_required">Kamera-Berechtigung wird benötigt, um den Pairing-QR zu scannen</string>
|
||||
|
||||
<!-- PIN screen -->
|
||||
<string name="pin_select_staff">Wähle deinen Namen</string>
|
||||
<string name="pin_enter">PIN eingeben</string>
|
||||
<string name="pin_wrong">Falscher PIN</string>
|
||||
<string name="pin_locked">PIN gesperrt</string>
|
||||
<string name="pin_pending_sync">%1$d ausstehende Synchronisationen</string>
|
||||
<string name="pin_all_synced">Alles synchronisiert</string>
|
||||
<string name="pin_clear">Löschen</string>
|
||||
<string name="pin_backspace">Zurück</string>
|
||||
<string name="pin_no_staff">Kein Personal für diese Filiale konfiguriert</string>
|
||||
|
||||
<!-- Terminal screen -->
|
||||
<string name="terminal_search_hint">Kartennummer oder E-Mail</string>
|
||||
<string name="terminal_scan_qr">QR scannen</string>
|
||||
<string name="terminal_enroll_customer">Kunde anmelden</string>
|
||||
<string name="terminal_no_customer_title">Kein Kunde ausgewählt</string>
|
||||
<string name="terminal_no_customer_hint">QR scannen oder per Karte / E-Mail suchen</string>
|
||||
<string name="terminal_recent_transactions">Letzte Transaktionen</string>
|
||||
<string name="terminal_lock">Sperren</string>
|
||||
<string name="terminal_offline">Offline</string>
|
||||
<string name="terminal_online">Online</string>
|
||||
<string name="terminal_card_label">Karte</string>
|
||||
<string name="terminal_close_customer">Kunde abwählen</string>
|
||||
|
||||
<!-- Actions -->
|
||||
<string name="action_add_stamp">Stempel hinzufügen</string>
|
||||
<string name="action_earn_points">Punkte gutschreiben</string>
|
||||
<string name="action_redeem_stamps">Stempel einlösen</string>
|
||||
<string name="action_redeem_reward">Prämie einlösen</string>
|
||||
<string name="action_purchase_amount">Kaufbetrag (€)</string>
|
||||
<string name="action_select_category">Kategorie wählen</string>
|
||||
<string name="action_confirm">Bestätigen</string>
|
||||
<string name="action_cancel">Abbrechen</string>
|
||||
|
||||
<!-- Enrollment -->
|
||||
<string name="enroll_title">Neuen Kunden anmelden</string>
|
||||
<string name="enroll_name">Name</string>
|
||||
<string name="enroll_email">E-Mail</string>
|
||||
<string name="enroll_phone">Telefon (optional)</string>
|
||||
<string name="enroll_birthday">Geburtstag (optional)</string>
|
||||
<string name="enroll_submit">Anmelden</string>
|
||||
|
||||
<!-- Status / units -->
|
||||
<string name="balance_points">%1$d Punkte</string>
|
||||
<string name="balance_stamps">%1$d / %2$d Stempel</string>
|
||||
|
||||
<!-- Errors / toasts -->
|
||||
<string name="error_connection">Verbindung fehlgeschlagen</string>
|
||||
<string name="error_no_internet">Keine Internetverbindung</string>
|
||||
<string name="error_try_again">Erneut versuchen</string>
|
||||
<string name="error_card_not_found">Karte nicht gefunden</string>
|
||||
<string name="error_offline_redeem">Einlösen erfordert eine Internetverbindung</string>
|
||||
<string name="error_unknown">Etwas ist schiefgelaufen</string>
|
||||
|
||||
<!-- Generic -->
|
||||
<string name="generic_loading">Wird geladen…</string>
|
||||
<string name="generic_yes">Ja</string>
|
||||
<string name="generic_no">Nein</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,75 @@
|
||||
<resources>
|
||||
<string name="app_name">RewardFlow Terminal</string>
|
||||
|
||||
<!-- Setup screen -->
|
||||
<string name="setup_title">Terminal RewardFlow</string>
|
||||
<string name="setup_instruction">Scannez le QR d\'appairage depuis la page paramètres du magasin</string>
|
||||
<string name="setup_or_manual">ou saisie manuelle</string>
|
||||
<string name="setup_api_url">URL de l\'API</string>
|
||||
<string name="setup_store_code">Code magasin</string>
|
||||
<string name="setup_auth_token">Jeton d\'authentification</string>
|
||||
<string name="setup_connect">Connecter</string>
|
||||
<string name="setup_connecting">Connexion…</string>
|
||||
<string name="setup_invalid_qr">QR d\'appairage invalide</string>
|
||||
<string name="setup_connection_failed">Connexion au serveur impossible</string>
|
||||
<string name="setup_camera_permission_required">L\'accès à la caméra est requis pour scanner le QR d\'appairage</string>
|
||||
|
||||
<!-- PIN screen -->
|
||||
<string name="pin_select_staff">Sélectionnez votre nom</string>
|
||||
<string name="pin_enter">Saisissez votre PIN</string>
|
||||
<string name="pin_wrong">PIN incorrect</string>
|
||||
<string name="pin_locked">PIN verrouillé</string>
|
||||
<string name="pin_pending_sync">%1$d en attente de synchronisation</string>
|
||||
<string name="pin_all_synced">Tout est synchronisé</string>
|
||||
<string name="pin_clear">Effacer</string>
|
||||
<string name="pin_backspace">Retour</string>
|
||||
<string name="pin_no_staff">Aucun personnel configuré pour ce magasin</string>
|
||||
|
||||
<!-- Terminal screen -->
|
||||
<string name="terminal_search_hint">Numéro de carte ou e-mail</string>
|
||||
<string name="terminal_scan_qr">Scanner un QR</string>
|
||||
<string name="terminal_enroll_customer">Inscrire un client</string>
|
||||
<string name="terminal_no_customer_title">Aucun client sélectionné</string>
|
||||
<string name="terminal_no_customer_hint">Scannez un QR ou recherchez par carte / e-mail</string>
|
||||
<string name="terminal_recent_transactions">Transactions récentes</string>
|
||||
<string name="terminal_lock">Verrouiller</string>
|
||||
<string name="terminal_offline">Hors ligne</string>
|
||||
<string name="terminal_online">En ligne</string>
|
||||
<string name="terminal_card_label">Carte</string>
|
||||
<string name="terminal_close_customer">Désélectionner le client</string>
|
||||
|
||||
<!-- Actions -->
|
||||
<string name="action_add_stamp">Ajouter un tampon</string>
|
||||
<string name="action_earn_points">Gagner des points</string>
|
||||
<string name="action_redeem_stamps">Échanger les tampons</string>
|
||||
<string name="action_redeem_reward">Échanger une récompense</string>
|
||||
<string name="action_purchase_amount">Montant de l\'achat (€)</string>
|
||||
<string name="action_select_category">Choisir la catégorie</string>
|
||||
<string name="action_confirm">Confirmer</string>
|
||||
<string name="action_cancel">Annuler</string>
|
||||
|
||||
<!-- Enrollment -->
|
||||
<string name="enroll_title">Inscrire un nouveau client</string>
|
||||
<string name="enroll_name">Nom</string>
|
||||
<string name="enroll_email">E-mail</string>
|
||||
<string name="enroll_phone">Téléphone (facultatif)</string>
|
||||
<string name="enroll_birthday">Anniversaire (facultatif)</string>
|
||||
<string name="enroll_submit">Inscrire</string>
|
||||
|
||||
<!-- Status / units -->
|
||||
<string name="balance_points">%1$d points</string>
|
||||
<string name="balance_stamps">%1$d / %2$d tampons</string>
|
||||
|
||||
<!-- Errors / toasts -->
|
||||
<string name="error_connection">Échec de la connexion</string>
|
||||
<string name="error_no_internet">Aucune connexion internet</string>
|
||||
<string name="error_try_again">Réessayer</string>
|
||||
<string name="error_card_not_found">Carte introuvable</string>
|
||||
<string name="error_offline_redeem">L\'échange nécessite une connexion internet</string>
|
||||
<string name="error_unknown">Une erreur est survenue</string>
|
||||
|
||||
<!-- Generic -->
|
||||
<string name="generic_loading">Chargement…</string>
|
||||
<string name="generic_yes">Oui</string>
|
||||
<string name="generic_no">Non</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,75 @@
|
||||
<resources>
|
||||
<string name="app_name">RewardFlow Terminal</string>
|
||||
|
||||
<!-- Setup screen -->
|
||||
<string name="setup_title">RewardFlow Terminal</string>
|
||||
<string name="setup_instruction">Scannt de Pairing-QR aus de Geschäftsastellungen</string>
|
||||
<string name="setup_or_manual">oder manuell aginn</string>
|
||||
<string name="setup_api_url">API-URL</string>
|
||||
<string name="setup_store_code">Geschäftscode</string>
|
||||
<string name="setup_auth_token">Auth-Token</string>
|
||||
<string name="setup_connect">Verbannen</string>
|
||||
<string name="setup_connecting">Verbindung…</string>
|
||||
<string name="setup_invalid_qr">Ongëltege Pairing-QR</string>
|
||||
<string name="setup_connection_failed">Server-Verbindung net méiglech</string>
|
||||
<string name="setup_camera_permission_required">Camera-Erlabnis gëtt gebraucht fir de Pairing-QR ze scannen</string>
|
||||
|
||||
<!-- PIN screen -->
|
||||
<string name="pin_select_staff">Wielt Ären Numm</string>
|
||||
<string name="pin_enter">PIN aginn</string>
|
||||
<string name="pin_wrong">Falsche PIN</string>
|
||||
<string name="pin_locked">PIN gespaart</string>
|
||||
<string name="pin_pending_sync">%1$d an der Sync-Schlaang</string>
|
||||
<string name="pin_all_synced">Alles synchroniséiert</string>
|
||||
<string name="pin_clear">Läschen</string>
|
||||
<string name="pin_backspace">Zréck</string>
|
||||
<string name="pin_no_staff">Keng Mataarbechter fir dëst Geschäft konfiguréiert</string>
|
||||
|
||||
<!-- Terminal screen -->
|
||||
<string name="terminal_search_hint">Kaartennummer oder E-Mail</string>
|
||||
<string name="terminal_scan_qr">QR scannen</string>
|
||||
<string name="terminal_enroll_customer">Client umellen</string>
|
||||
<string name="terminal_no_customer_title">Kee Client gewielt</string>
|
||||
<string name="terminal_no_customer_hint">Scannt e QR oder sicht no Kaart / E-Mail</string>
|
||||
<string name="terminal_recent_transactions">Lescht Transaktiounen</string>
|
||||
<string name="terminal_lock">Spären</string>
|
||||
<string name="terminal_offline">Offline</string>
|
||||
<string name="terminal_online">Online</string>
|
||||
<string name="terminal_card_label">Kaart</string>
|
||||
<string name="terminal_close_customer">Client ofwielen</string>
|
||||
|
||||
<!-- Actions -->
|
||||
<string name="action_add_stamp">Stempel derbäisetzen</string>
|
||||
<string name="action_earn_points">Punkten verdéngen</string>
|
||||
<string name="action_redeem_stamps">Stempel anléisen</string>
|
||||
<string name="action_redeem_reward">Prime anléisen</string>
|
||||
<string name="action_purchase_amount">Akafsbetrag (€)</string>
|
||||
<string name="action_select_category">Kategorie wielen</string>
|
||||
<string name="action_confirm">Bestätegen</string>
|
||||
<string name="action_cancel">Ofbriechen</string>
|
||||
|
||||
<!-- Enrollment -->
|
||||
<string name="enroll_title">Neie Client umellen</string>
|
||||
<string name="enroll_name">Numm</string>
|
||||
<string name="enroll_email">E-Mail</string>
|
||||
<string name="enroll_phone">Telefon (fakultativ)</string>
|
||||
<string name="enroll_birthday">Gebuertsdag (fakultativ)</string>
|
||||
<string name="enroll_submit">Umellen</string>
|
||||
|
||||
<!-- Status / units -->
|
||||
<string name="balance_points">%1$d Punkten</string>
|
||||
<string name="balance_stamps">%1$d / %2$d Stempel</string>
|
||||
|
||||
<!-- Errors / toasts -->
|
||||
<string name="error_connection">Verbindung huet net geklappt</string>
|
||||
<string name="error_no_internet">Keng Internet-Verbindung</string>
|
||||
<string name="error_try_again">Nees probéieren</string>
|
||||
<string name="error_card_not_found">Kaart net fonnt</string>
|
||||
<string name="error_offline_redeem">Anléise brauch eng Internet-Verbindung</string>
|
||||
<string name="error_unknown">Eppes ass schif gaangen</string>
|
||||
|
||||
<!-- Generic -->
|
||||
<string name="generic_loading">Lueden…</string>
|
||||
<string name="generic_yes">Jo</string>
|
||||
<string name="generic_no">Nee</string>
|
||||
</resources>
|
||||
@@ -1,3 +1,75 @@
|
||||
<resources>
|
||||
<string name="app_name">RewardFlow Terminal</string>
|
||||
|
||||
<!-- Setup screen -->
|
||||
<string name="setup_title">RewardFlow Terminal</string>
|
||||
<string name="setup_instruction">Scan the setup QR from your store settings page</string>
|
||||
<string name="setup_or_manual">or enter manually</string>
|
||||
<string name="setup_api_url">API URL</string>
|
||||
<string name="setup_store_code">Store Code</string>
|
||||
<string name="setup_auth_token">Auth Token</string>
|
||||
<string name="setup_connect">Connect</string>
|
||||
<string name="setup_connecting">Connecting…</string>
|
||||
<string name="setup_invalid_qr">Invalid pairing QR code</string>
|
||||
<string name="setup_connection_failed">Could not connect to server</string>
|
||||
<string name="setup_camera_permission_required">Camera permission is required to scan the pairing QR</string>
|
||||
|
||||
<!-- PIN screen -->
|
||||
<string name="pin_select_staff">Select your name</string>
|
||||
<string name="pin_enter">Enter your PIN</string>
|
||||
<string name="pin_wrong">Wrong PIN</string>
|
||||
<string name="pin_locked">PIN locked</string>
|
||||
<string name="pin_pending_sync">%1$d pending sync</string>
|
||||
<string name="pin_all_synced">All synced</string>
|
||||
<string name="pin_clear">Clear</string>
|
||||
<string name="pin_backspace">Backspace</string>
|
||||
<string name="pin_no_staff">No staff configured for this store</string>
|
||||
|
||||
<!-- Terminal screen -->
|
||||
<string name="terminal_search_hint">Card number or email</string>
|
||||
<string name="terminal_scan_qr">Scan QR Code</string>
|
||||
<string name="terminal_enroll_customer">Enroll Customer</string>
|
||||
<string name="terminal_no_customer_title">No customer selected</string>
|
||||
<string name="terminal_no_customer_hint">Scan a QR or search by card / email</string>
|
||||
<string name="terminal_recent_transactions">Recent Transactions</string>
|
||||
<string name="terminal_lock">Lock</string>
|
||||
<string name="terminal_offline">Offline</string>
|
||||
<string name="terminal_online">Online</string>
|
||||
<string name="terminal_card_label">Card</string>
|
||||
<string name="terminal_close_customer">Clear customer</string>
|
||||
|
||||
<!-- Actions -->
|
||||
<string name="action_add_stamp">Add Stamp</string>
|
||||
<string name="action_earn_points">Earn Points</string>
|
||||
<string name="action_redeem_stamps">Redeem Stamps</string>
|
||||
<string name="action_redeem_reward">Redeem Reward</string>
|
||||
<string name="action_purchase_amount">Purchase amount (€)</string>
|
||||
<string name="action_select_category">Select category</string>
|
||||
<string name="action_confirm">Confirm</string>
|
||||
<string name="action_cancel">Cancel</string>
|
||||
|
||||
<!-- Enrollment -->
|
||||
<string name="enroll_title">Enroll New Customer</string>
|
||||
<string name="enroll_name">Name</string>
|
||||
<string name="enroll_email">Email</string>
|
||||
<string name="enroll_phone">Phone (optional)</string>
|
||||
<string name="enroll_birthday">Birthday (optional)</string>
|
||||
<string name="enroll_submit">Enroll</string>
|
||||
|
||||
<!-- Status / units -->
|
||||
<string name="balance_points">%1$d Points</string>
|
||||
<string name="balance_stamps">%1$d / %2$d Stamps</string>
|
||||
|
||||
<!-- Errors / toasts -->
|
||||
<string name="error_connection">Connection failed</string>
|
||||
<string name="error_no_internet">No internet</string>
|
||||
<string name="error_try_again">Try again</string>
|
||||
<string name="error_card_not_found">Card not found</string>
|
||||
<string name="error_offline_redeem">Redemption requires an internet connection</string>
|
||||
<string name="error_unknown">Something went wrong</string>
|
||||
|
||||
<!-- Generic -->
|
||||
<string name="generic_loading">Loading…</string>
|
||||
<string name="generic_yes">Yes</string>
|
||||
<string name="generic_no">No</string>
|
||||
</resources>
|
||||
|
||||
@@ -44,6 +44,9 @@ mlkitBarcode = "17.3.0"
|
||||
# DataStore (preferences)
|
||||
datastore = "1.1.2"
|
||||
|
||||
# bcrypt — pure-Java, used to verify staff PIN hashes locally on the tablet
|
||||
bcrypt = "0.10.2"
|
||||
|
||||
# Testing
|
||||
junit = "4.13.2"
|
||||
junitAndroid = "1.2.1"
|
||||
@@ -100,6 +103,9 @@ mlkit-barcode = { group = "com.google.mlkit", name = "barcode-scanning", version
|
||||
# DataStore
|
||||
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
|
||||
# bcrypt
|
||||
bcrypt = { group = "at.favre.lib", name = "bcrypt", version.ref = "bcrypt" }
|
||||
|
||||
# Testing
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
junit-android = { group = "androidx.test.ext", name = "junit", version.ref = "junitAndroid" }
|
||||
|
||||
Reference in New Issue
Block a user