Compare commits

...

2 Commits

Author SHA1 Message Date
a0e3461c48 feat(android-terminal): Phase B — setup screen with QR scanner
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
End-to-end pairing flow:

1. SetupScreen renders a CameraX preview on the left, a manual-entry
   form on the right (dev fallback). Camera permission is requested
   in-place — no accompanist dep.
2. QrScannerView uses ML Kit's barcode scanner (QR format only),
   single-shot fires the decoded JSON to the ViewModel and stops
   analysing.
3. SetupViewModel.pairFromQr decodes via Moshi, persists the pairing
   in DataStore, then verifies by hitting /api/v1/store/loyalty/program
   through the AuthInterceptor (which now sees the new url + token).
   On 200 it warms the staff PIN and category caches and emits Success;
   on failure it rolls back via DeviceConfigRepository.resetDevice() so
   the user is back at a clean Setup with an error.
4. The NavHost watches is_device_set_up and forwards to PIN once Success
   fires. The DataStore key was aligned to "is_device_set_up" so this
   reactive switch keeps working.

Backend: the QR payload generated by POST /merchants/loyalty/devices
now includes store_id and store_name in addition to api_url, store_code
and auth_token, so the tablet doesn't have to resolve them later via a
separate call. Old QRs (which only had three fields) won't decode — the
merchant has to revoke and re-pair, which is the same flow they'd run
anyway after losing a tablet.

Files:
- ui/scanner/QrScannerView.kt    (new) — CameraX + ML Kit composable
- ui/setup/SetupViewModel.kt     (rewrite) — pair flow + state machine
- ui/setup/SetupScreen.kt        (rewrite) — two-pane layout, status overlay
- data/model/ApiModels.kt        — SetupPayload model
- data/repository/DeviceConfigRepository.kt — IS_SET_UP key alignment
- app/modules/loyalty/services/terminal_device_service.py — richer QR payload

Verified by ./gradlew assembleDebug — clean build, all warnings address
in this commit (LocalLifecycleOwner moved to lifecycle.compose, OptIn on
ExperimentalGetImage removed since it's no longer @RequiresOptIn).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:32:25 +02:00
3531ab8405 feat(android-terminal): Phase A — core infrastructure
Foundation work for the Android tablet POS app. Adds the singletons that
every screen needs (config persistence, network awareness, auth) without
touching the existing screen stubs yet — Phases B–F will build on these.

- gradle: bcrypt 0.10.2 pinned (used in Phase C for offline PIN verify)
- LoyaltyApi: + GET /api/v1/store/loyalty/categories endpoint
- ApiModels: + category_ids on StampRequest / PointsEarnRequest,
  + CategoryItem / CategoryListResponse
- DeviceConfigRepository: DataStore wrapper for the paired-tablet state
  (api_url, auth_token, store_id/code/name, cached program/pins/categories
  JSON, per-seller language, resetDevice())
- AuthInterceptor: rewrites every request's host to the paired api_url
  and adds Bearer auth — Retrofit keeps a placeholder baseUrl since the
  real URL only exists post-pair
- NetworkMonitor: Flow<Boolean> isOnline from ConnectivityManager
- StaffPinRepository / CategoryRepository: cache-or-refresh pattern,
  Moshi-serialized to DataStore
- AppModule: wires AuthInterceptor before the logging interceptor
- strings.xml: ~50 strings × 4 locales (en authoritative; fr/de/lb
  translated, mirroring the loyalty backend's i18n approach)

Verified by ./gradlew assembleDebug — clean build, only pre-existing
warnings (Theme statusBarColor deprecation, Moshi-kapt deprecation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:11:15 +02:00
18 changed files with 1233 additions and 38 deletions

View File

@@ -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))

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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())
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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() }
}
}

View File

@@ -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)

View File

@@ -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()
}
}
}

View File

@@ -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))
}
}
}

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" }