diff --git a/clients/terminal-android/app/build.gradle.kts b/clients/terminal-android/app/build.gradle.kts index dc639381..bb19101d 100644 --- a/clients/terminal-android/app/build.gradle.kts +++ b/clients/terminal-android/app/build.gradle.kts @@ -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) diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt index 148d4279..827a2b23 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt @@ -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 diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt index d8e85201..1a67b9da 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt @@ -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? = 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? = 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, + 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? = null, + val display_order: Int = 0, + val is_active: Boolean = true, +) diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/network/AuthInterceptor.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/network/AuthInterceptor.kt new file mode 100644 index 00000000..afbf6582 --- /dev/null +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/network/AuthInterceptor.kt @@ -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 `` 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()) + } +} diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/network/NetworkMonitor.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/network/NetworkMonitor.kt new file mode 100644 index 00000000..40d5c44c --- /dev/null +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/network/NetworkMonitor.kt @@ -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 = 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) + } +} diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/CategoryRepository.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/CategoryRepository.kt new file mode 100644 index 00000000..6124e480 --- /dev/null +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/CategoryRepository.kt @@ -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> = moshi.adapter( + Types.newParameterizedType(List::class.java, CategoryItem::class.java) + ) + + suspend fun refresh(): List { + val response = api.listCategories() + val categories = response.categories.filter { it.is_active } + configRepository.saveCategories(listAdapter.toJson(categories)) + return categories + } + + suspend fun cached(): List { + val raw = configRepository.categoriesJson.first() ?: return emptyList() + return runCatching { listAdapter.fromJson(raw) ?: emptyList() } + .getOrDefault(emptyList()) + } + + suspend fun listOrRefresh(): List { + 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 +} diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/DeviceConfigRepository.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/DeviceConfigRepository.kt new file mode 100644 index 00000000..7570e7ce --- /dev/null +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/DeviceConfigRepository.kt @@ -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, +) { + + // ── Flows (suspend collectors) + val apiUrl: Flow = dataStore.data.map { it[KEY_API_URL] } + val authToken: Flow = dataStore.data.map { it[KEY_AUTH_TOKEN] } + val storeId: Flow = dataStore.data.map { it[KEY_STORE_ID] } + val storeCode: Flow = dataStore.data.map { it[KEY_STORE_CODE] } + val storeName: Flow = dataStore.data.map { it[KEY_STORE_NAME] } + val isDeviceSetUp: Flow = dataStore.data.map { it[KEY_IS_SET_UP] ?: false } + + val programJson: Flow = dataStore.data.map { it[KEY_PROGRAM_JSON] } + val staffPinsJson: Flow = dataStore.data.map { it[KEY_STAFF_PINS_JSON] } + val categoriesJson: Flow = 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 = + 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") + } +} diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/StaffPinRepository.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/StaffPinRepository.kt new file mode 100644 index 00000000..674fe376 --- /dev/null +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/StaffPinRepository.kt @@ -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> = 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 { + 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 { + 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 { + val cached = cached() + return cached.ifEmpty { refresh() } + } +} diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/AppModule.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/AppModule.kt index 6e7204c1..81d8ed81 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/AppModule.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/AppModule.kt @@ -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) diff --git a/clients/terminal-android/app/src/main/res/values-de/strings.xml b/clients/terminal-android/app/src/main/res/values-de/strings.xml new file mode 100644 index 00000000..fce460d5 --- /dev/null +++ b/clients/terminal-android/app/src/main/res/values-de/strings.xml @@ -0,0 +1,75 @@ + + RewardFlow Terminal + + + RewardFlow Terminal + Scanne den Pairing-QR aus den Filialeinstellungen + oder manuell eingeben + API-URL + Filialcode + Auth-Token + Verbinden + Verbindung… + Ungültiger Pairing-QR + Verbindung zum Server nicht möglich + Kamera-Berechtigung wird benötigt, um den Pairing-QR zu scannen + + + Wähle deinen Namen + PIN eingeben + Falscher PIN + PIN gesperrt + %1$d ausstehende Synchronisationen + Alles synchronisiert + Löschen + Zurück + Kein Personal für diese Filiale konfiguriert + + + Kartennummer oder E-Mail + QR scannen + Kunde anmelden + Kein Kunde ausgewählt + QR scannen oder per Karte / E-Mail suchen + Letzte Transaktionen + Sperren + Offline + Online + Karte + Kunde abwählen + + + Stempel hinzufügen + Punkte gutschreiben + Stempel einlösen + Prämie einlösen + Kaufbetrag (€) + Kategorie wählen + Bestätigen + Abbrechen + + + Neuen Kunden anmelden + Name + E-Mail + Telefon (optional) + Geburtstag (optional) + Anmelden + + + %1$d Punkte + %1$d / %2$d Stempel + + + Verbindung fehlgeschlagen + Keine Internetverbindung + Erneut versuchen + Karte nicht gefunden + Einlösen erfordert eine Internetverbindung + Etwas ist schiefgelaufen + + + Wird geladen… + Ja + Nein + diff --git a/clients/terminal-android/app/src/main/res/values-fr/strings.xml b/clients/terminal-android/app/src/main/res/values-fr/strings.xml new file mode 100644 index 00000000..34510efe --- /dev/null +++ b/clients/terminal-android/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,75 @@ + + RewardFlow Terminal + + + Terminal RewardFlow + Scannez le QR d\'appairage depuis la page paramètres du magasin + ou saisie manuelle + URL de l\'API + Code magasin + Jeton d\'authentification + Connecter + Connexion… + QR d\'appairage invalide + Connexion au serveur impossible + L\'accès à la caméra est requis pour scanner le QR d\'appairage + + + Sélectionnez votre nom + Saisissez votre PIN + PIN incorrect + PIN verrouillé + %1$d en attente de synchronisation + Tout est synchronisé + Effacer + Retour + Aucun personnel configuré pour ce magasin + + + Numéro de carte ou e-mail + Scanner un QR + Inscrire un client + Aucun client sélectionné + Scannez un QR ou recherchez par carte / e-mail + Transactions récentes + Verrouiller + Hors ligne + En ligne + Carte + Désélectionner le client + + + Ajouter un tampon + Gagner des points + Échanger les tampons + Échanger une récompense + Montant de l\'achat (€) + Choisir la catégorie + Confirmer + Annuler + + + Inscrire un nouveau client + Nom + E-mail + Téléphone (facultatif) + Anniversaire (facultatif) + Inscrire + + + %1$d points + %1$d / %2$d tampons + + + Échec de la connexion + Aucune connexion internet + Réessayer + Carte introuvable + L\'échange nécessite une connexion internet + Une erreur est survenue + + + Chargement… + Oui + Non + diff --git a/clients/terminal-android/app/src/main/res/values-lb/strings.xml b/clients/terminal-android/app/src/main/res/values-lb/strings.xml new file mode 100644 index 00000000..f2c16dc9 --- /dev/null +++ b/clients/terminal-android/app/src/main/res/values-lb/strings.xml @@ -0,0 +1,75 @@ + + RewardFlow Terminal + + + RewardFlow Terminal + Scannt de Pairing-QR aus de Geschäftsastellungen + oder manuell aginn + API-URL + Geschäftscode + Auth-Token + Verbannen + Verbindung… + Ongëltege Pairing-QR + Server-Verbindung net méiglech + Camera-Erlabnis gëtt gebraucht fir de Pairing-QR ze scannen + + + Wielt Ären Numm + PIN aginn + Falsche PIN + PIN gespaart + %1$d an der Sync-Schlaang + Alles synchroniséiert + Läschen + Zréck + Keng Mataarbechter fir dëst Geschäft konfiguréiert + + + Kaartennummer oder E-Mail + QR scannen + Client umellen + Kee Client gewielt + Scannt e QR oder sicht no Kaart / E-Mail + Lescht Transaktiounen + Spären + Offline + Online + Kaart + Client ofwielen + + + Stempel derbäisetzen + Punkten verdéngen + Stempel anléisen + Prime anléisen + Akafsbetrag (€) + Kategorie wielen + Bestätegen + Ofbriechen + + + Neie Client umellen + Numm + E-Mail + Telefon (fakultativ) + Gebuertsdag (fakultativ) + Umellen + + + %1$d Punkten + %1$d / %2$d Stempel + + + Verbindung huet net geklappt + Keng Internet-Verbindung + Nees probéieren + Kaart net fonnt + Anléise brauch eng Internet-Verbindung + Eppes ass schif gaangen + + + Lueden… + Jo + Nee + diff --git a/clients/terminal-android/app/src/main/res/values/strings.xml b/clients/terminal-android/app/src/main/res/values/strings.xml index 4d5c22b8..38711156 100644 --- a/clients/terminal-android/app/src/main/res/values/strings.xml +++ b/clients/terminal-android/app/src/main/res/values/strings.xml @@ -1,3 +1,75 @@ RewardFlow Terminal + + + RewardFlow Terminal + Scan the setup QR from your store settings page + or enter manually + API URL + Store Code + Auth Token + Connect + Connecting… + Invalid pairing QR code + Could not connect to server + Camera permission is required to scan the pairing QR + + + Select your name + Enter your PIN + Wrong PIN + PIN locked + %1$d pending sync + All synced + Clear + Backspace + No staff configured for this store + + + Card number or email + Scan QR Code + Enroll Customer + No customer selected + Scan a QR or search by card / email + Recent Transactions + Lock + Offline + Online + Card + Clear customer + + + Add Stamp + Earn Points + Redeem Stamps + Redeem Reward + Purchase amount (€) + Select category + Confirm + Cancel + + + Enroll New Customer + Name + Email + Phone (optional) + Birthday (optional) + Enroll + + + %1$d Points + %1$d / %2$d Stamps + + + Connection failed + No internet + Try again + Card not found + Redemption requires an internet connection + Something went wrong + + + Loading… + Yes + No diff --git a/clients/terminal-android/gradle/libs.versions.toml b/clients/terminal-android/gradle/libs.versions.toml index b3a49088..30eb9262 100644 --- a/clients/terminal-android/gradle/libs.versions.toml +++ b/clients/terminal-android/gradle/libs.versions.toml @@ -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" }