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>
This commit is contained in:
2026-05-05 22:11:15 +02:00
parent 90b5b3d135
commit 3531ab8405
14 changed files with 695 additions and 1 deletions

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,22 @@ data class PinItem(
val is_active: Boolean,
val is_locked: Boolean,
)
// ── 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,112 @@
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")
private val KEY_IS_SET_UP = booleanPreferencesKey("IS_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,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" }