feat: scaffold Android terminal POS app (RewardFlow Terminal)
Some checks failed
CI / ruff (push) Successful in 25s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running

Native Android tablet app for loyalty POS terminal. Replaces web
terminal for merchants who need device lockdown, camera QR scanning,
and offline operation.

Project at apps/terminal-android/ — Kotlin, Jetpack Compose, calls
the same /api/v1/store/loyalty/* API (no backend changes).

Scaffold includes:
- Gradle build (Kotlin DSL) with version catalog (libs.versions.toml)
- Hilt DI, Retrofit + Moshi networking, Room offline DB
- CameraX + ML Kit barcode scanning dependencies
- DataStore for device config persistence
- WorkManager for background sync
- Three-screen navigation: Setup → PIN → Terminal
- Stub screens with layout structure (ready to implement)
- API models matching all loyalty store endpoints
- PendingTransaction entity + DAO for offline queue
- RewardFlow brand theme (purple, light/dark)
- Landscape-only, fullscreen, Lock Task Mode ready
- SETUP.md with Android Studio installation guide
- .gitignore for Android build artifacts

Tech decisions:
- Min SDK 26 (Android 8.0, 95%+ tablet coverage)
- Firebase App Distribution for v1, Play Store later
- Staff PIN auth (no username/password on POS)
- One-time device setup via QR code from web settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 18:02:42 +02:00
parent 51a2114e02
commit 1df1b2bfca
27 changed files with 1296 additions and 0 deletions

14
apps/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 `apps/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
```
apps/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")