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

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>