refactor: rename apps/ to clients/ + update architecture docs
Some checks failed
CI / ruff (push) Successful in 20s
CI / pytest (push) Failing after 2h37m33s
CI / validate (push) Successful in 41s
CI / dependency-scanning (push) Successful in 42s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

Rename apps/ → clients/ for clarity:
- app/ (singular) = Python backend (FastAPI, server-rendered web UI)
- clients/ (plural) = standalone client applications (API consumers)

The web storefront/store/admin stays in app/ because it's server-
rendered Jinja2, not a standalone frontend. clients/ is for native
apps that connect to the API externally.

Updated:
- docs/architecture/overview.md — added clients/ to project structure
- clients/terminal-android/SETUP.md — updated path references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 18:09:24 +02:00
parent 1df1b2bfca
commit e759282116
28 changed files with 7 additions and 2 deletions

14
clients/terminal-android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/app/build
/app/release
*.apk
*.aab

View File

@@ -0,0 +1,100 @@
# RewardFlow Terminal — Android Setup
## Prerequisites
### 1. Install Android Studio
```bash
# Download Android Studio (Xubuntu/Ubuntu)
sudo snap install android-studio --classic
# Or via apt (if snap not available)
# Download from https://developer.android.com/studio
# Extract and run: ./android-studio/bin/studio.sh
```
Android Studio bundles:
- JDK 17 (no need to install separately)
- Android SDK
- Gradle
- Android Emulator
### 2. First-time Android Studio setup
1. Open Android Studio
2. Choose "Standard" installation
3. Accept SDK licenses: `Tools → SDK Manager → SDK Platforms → Android 15 (API 35)`
4. Install: `Tools → SDK Manager → SDK Tools`:
- Android SDK Build-Tools
- Android SDK Platform-Tools
- Android Emulator
- Google Play services (for ML Kit barcode scanning)
### 3. Open the project
1. `File → Open` → navigate to `clients/terminal-android/`
2. Wait for Gradle sync (first time downloads ~500MB of dependencies)
3. If prompted about Gradle JDK, select the bundled JDK 17
### 4. Create a tablet emulator
1. `Tools → Device Manager → Create Virtual Device`
2. Category: **Tablet** → pick **Pixel Tablet** or **Nexus 10**
3. System image: **API 35** (download if needed)
4. Finish
### 5. Run the app
1. Select the tablet emulator in the device dropdown
2. Click ▶️ Run
3. The app opens in landscape fullscreen
## Project structure
```
clients/terminal-android/
├── app/
│ ├── build.gradle.kts # App dependencies (like requirements.txt)
│ ├── src/main/
│ │ ├── AndroidManifest.xml # App permissions & config
│ │ ├── java/lu/rewardflow/terminal/
│ │ │ ├── RewardFlowApp.kt # Application entry point
│ │ │ ├── MainActivity.kt # Single activity (Compose)
│ │ │ ├── ui/ # Screens (Setup, PIN, Terminal)
│ │ │ ├── data/ # API, DB, models, sync
│ │ │ └── di/ # Dependency injection (Hilt)
│ │ └── res/ # Resources (strings, themes, icons)
│ └── proguard-rules.pro # Release build obfuscation rules
├── gradle/
│ ├── libs.versions.toml # Version catalog (all dependency versions)
│ └── wrapper/ # Gradle wrapper (pinned version)
├── build.gradle.kts # Root build file
├── settings.gradle.kts # Project settings
└── .gitignore
```
## Key dependencies (in gradle/libs.versions.toml)
| Library | Purpose |
|---------|---------|
| Jetpack Compose | UI framework (declarative, like SwiftUI) |
| Retrofit + Moshi | HTTP client + JSON parsing (calls the Orion API) |
| Room | SQLite ORM (offline transaction queue) |
| WorkManager | Background sync (retry pending transactions) |
| Hilt | Dependency injection |
| CameraX + ML Kit | Camera preview + QR/barcode scanning |
| DataStore | Key-value persistence (device config, auth token) |
## API connection
- **Debug builds**: connect to `http://10.0.2.2:8000` (Android emulator's localhost alias)
- **Release builds**: connect to `https://rewardflow.lu`
- Configure in `app/build.gradle.kts``buildConfigField("DEFAULT_API_URL", ...)`
## Building a release APK
```bash
cd apps/terminal-android
./gradlew assembleRelease
# APK at: app/build/outputs/apk/release/app-release.apk
```

View File

@@ -0,0 +1,108 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "lu.rewardflow.terminal"
compileSdk = 35
defaultConfig {
applicationId = "lu.rewardflow.terminal"
minSdk = 26 // Android 8.0 — covers 95%+ of tablets
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
debug {
buildConfigField("String", "DEFAULT_API_URL", "\"http://10.0.2.2:8000\"")
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("String", "DEFAULT_API_URL", "\"https://rewardflow.lu\"")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
// AndroidX Core
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons)
implementation(libs.activity.compose)
implementation(libs.navigation.compose)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.lifecycle.viewmodel.compose)
debugImplementation(libs.compose.ui.tooling)
// Networking (Retrofit + Moshi)
implementation(libs.retrofit)
implementation(libs.retrofit.moshi)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.moshi)
ksp(libs.moshi.codegen)
// Room (offline database)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// WorkManager (background sync)
implementation(libs.work.runtime.ktx)
// Hilt (dependency injection)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
implementation(libs.hilt.work)
// CameraX + ML Kit (QR scanning)
implementation(libs.camera.core)
implementation(libs.camera.camera2)
implementation(libs.camera.lifecycle)
implementation(libs.camera.view)
implementation(libs.mlkit.barcode)
// DataStore (device config persistence)
implementation(libs.datastore.preferences)
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.junit.android)
androidTestImplementation(libs.espresso.core)
}

View File

@@ -0,0 +1,7 @@
# Moshi
-keep class lu.rewardflow.terminal.data.model.** { *; }
-keepclassmembers class lu.rewardflow.terminal.data.model.** { *; }
# Retrofit
-keepattributes Signature
-keepattributes *Annotation*

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Network access for API calls -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Camera for QR/barcode scanning -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:name=".RewardFlowApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.RewardFlowTerminal"
tools:targetApi="35">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize"
android:lockTaskMode="if_whitelisted">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,33 @@
package lu.rewardflow.terminal
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import dagger.hilt.android.AndroidEntryPoint
import lu.rewardflow.terminal.ui.RewardFlowNavHost
import lu.rewardflow.terminal.ui.theme.RewardFlowTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
RewardFlowTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
RewardFlowNavHost()
}
}
}
}
}

View File

@@ -0,0 +1,7 @@
package lu.rewardflow.terminal
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class RewardFlowApp : Application()

View File

@@ -0,0 +1,57 @@
package lu.rewardflow.terminal.data.api
import lu.rewardflow.terminal.data.model.*
import retrofit2.http.*
/**
* Retrofit interface for the Orion Loyalty Store API.
*
* Mirrors the endpoints at /api/v1/store/loyalty/*.
* Auth via Bearer token (obtained during device setup).
*/
interface LoyaltyApi {
// ── Program ─────────────────────────────────────────────────────────
@GET("api/v1/store/loyalty/program")
suspend fun getProgram(): ProgramResponse
// ── Card Lookup ─────────────────────────────────────────────────────
@GET("api/v1/store/loyalty/cards/lookup")
suspend fun lookupCard(@Query("q") query: String): CardLookupResponse
@GET("api/v1/store/loyalty/cards/{cardId}")
suspend fun getCardDetail(@Path("cardId") cardId: Int): CardDetailResponse
// ── Enrollment ──────────────────────────────────────────────────────
@POST("api/v1/store/loyalty/cards/enroll")
suspend fun enrollCustomer(@Body request: EnrollRequest): CardResponse
// ── Stamps ──────────────────────────────────────────────────────────
@POST("api/v1/store/loyalty/stamp")
suspend fun addStamp(@Body request: StampRequest): StampResponse
@POST("api/v1/store/loyalty/stamp/redeem")
suspend fun redeemStamps(@Body request: StampRedeemRequest): StampRedeemResponse
// ── Points ──────────────────────────────────────────────────────────
@POST("api/v1/store/loyalty/points/earn")
suspend fun earnPoints(@Body request: PointsEarnRequest): PointsEarnResponse
@POST("api/v1/store/loyalty/points/redeem")
suspend fun redeemPoints(@Body request: PointsRedeemRequest): PointsRedeemResponse
// ── PINs (for caching staff PINs locally) ───────────────────────────
@GET("api/v1/store/loyalty/pins")
suspend fun listPins(): PinListResponse
// ── Auth ────────────────────────────────────────────────────────────
@POST("api/v1/store/auth/login")
suspend fun login(@Body request: LoginRequest): LoginResponse
}

View File

@@ -0,0 +1,15 @@
package lu.rewardflow.terminal.data.db
import androidx.room.Database
import androidx.room.RoomDatabase
import lu.rewardflow.terminal.data.db.dao.PendingTransactionDao
import lu.rewardflow.terminal.data.db.entity.PendingTransaction
@Database(
entities = [PendingTransaction::class],
version = 1,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun pendingTransactionDao(): PendingTransactionDao
}

View File

@@ -0,0 +1,29 @@
package lu.rewardflow.terminal.data.db.dao
import androidx.room.*
import lu.rewardflow.terminal.data.db.entity.PendingTransaction
@Dao
interface PendingTransactionDao {
@Insert
suspend fun insert(transaction: PendingTransaction): Long
@Query("SELECT * FROM pending_transactions WHERE status = 'pending' ORDER BY createdAt ASC")
suspend fun getPending(): List<PendingTransaction>
@Query("SELECT COUNT(*) FROM pending_transactions WHERE status = 'pending'")
suspend fun getPendingCount(): Int
@Update
suspend fun update(transaction: PendingTransaction)
@Query("DELETE FROM pending_transactions WHERE status = 'synced'")
suspend fun deleteSynced()
@Query("UPDATE pending_transactions SET status = 'failed', lastError = :error, retryCount = retryCount + 1 WHERE id = :id")
suspend fun markFailed(id: Long, error: String)
@Query("UPDATE pending_transactions SET status = 'synced' WHERE id = :id")
suspend fun markSynced(id: Long)
}

View File

@@ -0,0 +1,36 @@
package lu.rewardflow.terminal.data.db.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Offline transaction queue.
*
* When the device is offline, transactions (stamp, points, enrollment)
* are stored here and synced via WorkManager when connectivity returns.
*/
@Entity(tableName = "pending_transactions")
data class PendingTransaction(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
/** Type: "stamp", "points_earn", "enroll" */
val type: String,
/** JSON payload matching the API request body */
val requestJson: String,
/** Staff PIN ID who initiated the transaction */
val staffPinId: Int,
/** When the transaction was created locally */
val createdAt: Long = System.currentTimeMillis(),
/** Number of sync attempts */
val retryCount: Int = 0,
/** Last sync error (null if not yet attempted) */
val lastError: String? = null,
/** "pending", "syncing", "synced", "failed" */
val status: String = "pending",
)

View File

@@ -0,0 +1,189 @@
package lu.rewardflow.terminal.data.model
import com.squareup.moshi.JsonClass
// ── Auth ────────────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class LoginRequest(
val email_or_username: String,
val password: String,
)
@JsonClass(generateAdapter = true)
data class LoginResponse(
val access_token: String,
val token_type: String,
val expires_in: Int,
)
// ── Program ─────────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class ProgramResponse(
val id: Int,
val merchant_id: Int,
val loyalty_type: String,
val display_name: String,
val card_name: String,
val card_color: String,
val card_secondary_color: String? = null,
val logo_url: String? = null,
val stamps_target: Int = 10,
val points_per_euro: Int = 10,
val welcome_bonus_points: Int = 0,
val stamps_reward_description: String? = null,
val require_staff_pin: Boolean = false,
val is_active: Boolean = true,
)
// ── Card ────────────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class CardResponse(
val id: Int,
val card_number: String,
val customer_id: Int,
val merchant_id: Int,
val points_balance: Int = 0,
val stamp_count: Int = 0,
val is_active: Boolean = true,
)
@JsonClass(generateAdapter = true)
data class CardLookupResponse(
val id: Int,
val card_number: String,
val customer_id: Int,
val customer_name: String? = null,
val customer_email: String = "",
val merchant_id: Int,
val stamp_count: Int = 0,
val stamps_target: Int = 10,
val stamps_until_reward: Int = 10,
val points_balance: Int = 0,
val can_redeem_stamps: Boolean = false,
val stamp_reward_description: String? = null,
val available_rewards: List<RewardItem> = emptyList(),
val can_stamp: Boolean = true,
val stamps_today: Int = 0,
val max_daily_stamps: Int = 5,
)
@JsonClass(generateAdapter = true)
data class CardDetailResponse(
val id: Int,
val card_number: String,
val customer_name: String? = null,
val customer_email: String? = null,
val points_balance: Int = 0,
val stamp_count: Int = 0,
val stamps_target: Int = 10,
val total_points_earned: Int = 0,
val total_stamps_earned: Int = 0,
val is_active: Boolean = true,
)
@JsonClass(generateAdapter = true)
data class RewardItem(
val id: String,
val name: String,
val points_required: Int,
val is_active: Boolean = true,
)
// ── Enrollment ──────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class EnrollRequest(
val customer_id: Int? = null,
val email: String? = null,
val customer_name: String? = null,
val customer_phone: String? = null,
val customer_birthday: String? = null,
)
// ── Stamps ──────────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class StampRequest(
val card_id: Int? = null,
val qr_code: String? = null,
val card_number: String? = null,
val staff_pin: String? = null,
val notes: String? = null,
)
@JsonClass(generateAdapter = true)
data class StampResponse(
val success: Boolean,
val stamp_count: Int = 0,
val stamps_target: Int = 10,
val reward_earned: Boolean = false,
val reward_description: String? = null,
)
@JsonClass(generateAdapter = true)
data class StampRedeemRequest(
val card_id: Int? = null,
val qr_code: String? = null,
val card_number: String? = null,
val staff_pin: String? = null,
)
@JsonClass(generateAdapter = true)
data class StampRedeemResponse(
val success: Boolean,
val stamp_count: Int = 0,
)
// ── Points ──────────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class PointsEarnRequest(
val card_id: Int? = null,
val qr_code: String? = null,
val card_number: String? = null,
val purchase_amount_cents: Int,
val order_reference: String? = null,
val staff_pin: String? = null,
val notes: String? = null,
)
@JsonClass(generateAdapter = true)
data class PointsEarnResponse(
val success: Boolean,
val points_earned: Int = 0,
val points_balance: Int = 0,
)
@JsonClass(generateAdapter = true)
data class PointsRedeemRequest(
val card_id: Int? = null,
val qr_code: String? = null,
val card_number: String? = null,
val reward_id: String,
val staff_pin: String? = null,
)
@JsonClass(generateAdapter = true)
data class PointsRedeemResponse(
val success: Boolean,
val points_balance: Int = 0,
)
// ── PINs ────────────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class PinListResponse(
val pins: List<PinItem>,
val total: Int,
)
@JsonClass(generateAdapter = true)
data class PinItem(
val id: Int,
val name: String,
val is_active: Boolean,
val is_locked: Boolean,
)

View File

@@ -0,0 +1,80 @@
package lu.rewardflow.terminal.di
import android.content.Context
import androidx.room.Room
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
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 okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideMoshi(): Moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
.build()
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
val builder = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
if (BuildConfig.DEBUG) {
val logging = HttpLoggingInterceptor()
logging.level = HttpLoggingInterceptor.Level.BODY
builder.addInterceptor(logging)
}
return builder.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.DEFAULT_API_URL + "/")
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
}
@Provides
@Singleton
fun provideLoyaltyApi(retrofit: Retrofit): LoyaltyApi {
return retrofit.create(LoyaltyApi::class.java)
}
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"rewardflow_terminal.db",
).build()
}
@Provides
fun providePendingTransactionDao(db: AppDatabase): PendingTransactionDao {
return db.pendingTransactionDao()
}
}

View File

@@ -0,0 +1,25 @@
package lu.rewardflow.terminal.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "device_config")
@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> {
return context.dataStore
}
}

View File

@@ -0,0 +1,69 @@
package lu.rewardflow.terminal.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import lu.rewardflow.terminal.ui.pin.PinScreen
import lu.rewardflow.terminal.ui.setup.SetupScreen
import lu.rewardflow.terminal.ui.setup.SetupViewModel
import lu.rewardflow.terminal.ui.terminal.TerminalScreen
/**
* Navigation flow:
*
* 1. SetupScreen — first-time device setup (scan QR from web)
* 2. PinScreen — staff enters PIN to unlock terminal
* 3. TerminalScreen — main POS terminal (scan, search, transact)
*
* After setup, the app always starts at PinScreen.
* After 2 min idle on TerminalScreen, auto-lock back to PinScreen.
*/
@Composable
fun RewardFlowNavHost() {
val navController = rememberNavController()
val setupViewModel: SetupViewModel = hiltViewModel()
val isSetUp by setupViewModel.isDeviceSetUp.collectAsState(initial = false)
val startDestination = if (isSetUp) "pin" else "setup"
NavHost(navController = navController, startDestination = startDestination) {
composable("setup") {
SetupScreen(
onSetupComplete = {
navController.navigate("pin") {
popUpTo("setup") { inclusive = true }
}
},
)
}
composable("pin") {
PinScreen(
onPinVerified = { staffPinId, staffName ->
navController.navigate("terminal/$staffPinId/$staffName") {
popUpTo("pin") { inclusive = true }
}
},
)
}
composable("terminal/{staffPinId}/{staffName}") { backStackEntry ->
val staffPinId = backStackEntry.arguments?.getString("staffPinId")?.toIntOrNull() ?: 0
val staffName = backStackEntry.arguments?.getString("staffName") ?: ""
TerminalScreen(
staffPinId = staffPinId,
staffName = staffName,
onLockScreen = {
navController.navigate("pin") {
popUpTo("terminal/{staffPinId}/{staffName}") { inclusive = true }
}
},
)
}
}
}

View File

@@ -0,0 +1,103 @@
package lu.rewardflow.terminal.ui.pin
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* PIN entry screen — staff enters their 4-digit PIN to unlock the terminal.
*
* The PIN identifies the staff member (unique per store).
* PINs are cached locally and refreshed periodically from the API.
*/
@Composable
fun PinScreen(
onPinVerified: (staffPinId: Int, staffName: String) -> Unit,
) {
var pinDigits by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(48.dp),
) {
Text(
text = "Enter Staff PIN",
style = MaterialTheme.typography.headlineMedium,
)
Spacer(modifier = Modifier.height(24.dp))
// PIN dots display
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(4) { i ->
Surface(
modifier = Modifier.size(56.dp),
shape = MaterialTheme.shapes.medium,
color = if (pinDigits.length > i)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant,
) {
Box(contentAlignment = Alignment.Center) {
if (pinDigits.length > i) {
Text("", fontSize = 32.sp, color = MaterialTheme.colorScheme.onPrimary)
}
}
}
}
}
error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(modifier = Modifier.height(32.dp))
// Number pad
val buttons = listOf(
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf("C", "0", ""),
)
buttons.forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
row.forEach { label ->
FilledTonalButton(
onClick = {
when (label) {
"C" -> { pinDigits = ""; error = null }
"" -> { if (pinDigits.isNotEmpty()) pinDigits = pinDigits.dropLast(1) }
else -> {
if (pinDigits.length < 4) {
pinDigits += label
if (pinDigits.length == 4) {
// TODO: verify PIN against cached list
// For now, placeholder callback
onPinVerified(1, "Staff")
}
}
}
}
},
modifier = Modifier.size(72.dp),
) {
Text(label, fontSize = 24.sp)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}

View File

@@ -0,0 +1,49 @@
package lu.rewardflow.terminal.ui.setup
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* First-time device setup 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
*/
@Composable
fun SetupScreen(
onSetupComplete: () -> Unit,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(48.dp),
) {
Text(
text = "RewardFlow Terminal",
style = MaterialTheme.typography.headlineLarge,
)
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)")
}
}
}
}

View File

@@ -0,0 +1,24 @@
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@HiltViewModel
class SetupViewModel @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : ViewModel() {
companion object {
val IS_SET_UP = booleanPreferencesKey("is_device_set_up")
}
val isDeviceSetUp: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[IS_SET_UP] ?: false
}
}

View File

@@ -0,0 +1,95 @@
package lu.rewardflow.terminal.ui.terminal
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Main POS terminal screen.
*
* Layout (landscape tablet):
* ┌──────────────────────────────────────────────┐
* │ [Staff: Jane] [Store: Fashion Hub] [Lock] │
* ├──────────────────┬───────────────────────────┤
* │ │ │
* │ Customer Search │ Card Details │
* │ + QR Scanner │ Points/Stamps balance │
* │ │ Quick actions │
* │ │ (Earn, Redeem, Enroll) │
* │ │ │
* └──────────────────┴───────────────────────────┘
*/
@Composable
fun TerminalScreen(
staffPinId: Int,
staffName: String,
onLockScreen: () -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// Top bar
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Staff: $staffName",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
// TODO: show store name, offline indicator, pending sync count
FilledTonalButton(onClick = onLockScreen) {
Text("Lock")
}
}
}
// Main content — two-pane layout
Row(modifier = Modifier.fillMaxSize()) {
// Left pane: search + scan
Surface(
modifier = Modifier
.weight(0.4f)
.fillMaxHeight()
.padding(16.dp),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Search Customer", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(16.dp))
// TODO: Search field + QR camera preview
Text("QR Scanner / Search field", style = MaterialTheme.typography.bodyMedium)
}
}
// Right pane: card details + actions
Surface(
modifier = Modifier
.weight(0.6f)
.fillMaxHeight()
.padding(16.dp),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Select a customer to begin", style = MaterialTheme.typography.bodyLarge)
// TODO: Show card details, balance, quick action buttons
}
}
}
}
}

View File

@@ -0,0 +1,60 @@
package lu.rewardflow.terminal.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
// RewardFlow brand colors
private val Purple600 = Color(0xFF7C3AED)
private val Purple700 = Color(0xFF6D28D9)
private val Purple50 = Color(0xFFF5F3FF)
private val LightColorScheme = lightColorScheme(
primary = Purple600,
onPrimary = Color.White,
primaryContainer = Purple50,
onPrimaryContainer = Purple700,
secondary = Color(0xFF10B981),
background = Color(0xFFF9FAFB),
surface = Color.White,
error = Color(0xFFEF4444),
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFFA78BFA),
onPrimary = Color(0xFF1E1B4B),
primaryContainer = Color(0xFF312E81),
onPrimaryContainer = Color(0xFFE0E7FF),
secondary = Color(0xFF34D399),
background = Color(0xFF111827),
surface = Color(0xFF1F2937),
error = Color(0xFFF87171),
)
@Composable
fun RewardFlowTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primaryContainer.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
content = content,
)
}

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">RewardFlow Terminal</string>
</resources>

View File

@@ -0,0 +1,5 @@
<resources>
<style name="Theme.RewardFlowTerminal" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:windowFullscreen">true</item>
</style>
</resources>

View File

@@ -0,0 +1,8 @@
// Top-level build file
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
}

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

View File

@@ -0,0 +1,113 @@
# Version Catalog — single source of truth for all dependency versions.
# This is the Android equivalent of requirements.txt.
#
# Usage in build.gradle.kts:
# implementation(libs.androidx.core.ktx)
# implementation(libs.retrofit)
[versions]
# Android / Kotlin
agp = "8.7.3"
kotlin = "2.1.0"
ksp = "2.1.0-1.0.29"
# Compose
composeBom = "2025.01.01"
activityCompose = "1.10.1"
navigationCompose = "2.8.7"
lifecycleCompose = "2.8.7"
# AndroidX
coreKtx = "1.15.0"
appcompat = "1.7.0"
material = "1.12.0"
# Networking
retrofit = "2.11.0"
okhttp = "4.12.0"
moshi = "1.15.1"
# Database (offline queue)
room = "2.6.1"
# Background sync
workManager = "2.10.0"
# Dependency Injection
hilt = "2.54.1"
hiltNavigationCompose = "1.2.0"
# Camera & QR scanning
cameraX = "1.4.1"
mlkitBarcode = "17.3.0"
# DataStore (preferences)
datastore = "1.1.2"
# Testing
junit = "4.13.2"
junitAndroid = "1.2.1"
espresso = "3.6.1"
[libraries]
# AndroidX Core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
# Compose
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleCompose" }
# Networking
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
moshi = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
moshi-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" }
# Room (offline database)
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# WorkManager (background sync)
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
# Hilt (dependency injection)
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltNavigationCompose" }
# CameraX (QR scanning)
camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" }
camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" }
camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" }
camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" }
mlkit-barcode = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" }
# DataStore
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
# Testing
junit = { group = "junit", name = "junit", version.ref = "junit" }
junit-android = { group = "androidx.test.ext", name = "junit", version.ref = "junitAndroid" }
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolution {
@Suppress("UnstableApiUsage")
repositories {
google()
mavenCentral()
}
}
rootProject.name = "RewardFlowTerminal"
include(":app")