diff --git a/apps/terminal-android/.gitignore b/apps/terminal-android/.gitignore
new file mode 100644
index 00000000..14c22339
--- /dev/null
+++ b/apps/terminal-android/.gitignore
@@ -0,0 +1,14 @@
+*.iml
+.gradle
+/local.properties
+/.idea
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+/app/build
+/app/release
+*.apk
+*.aab
diff --git a/apps/terminal-android/SETUP.md b/apps/terminal-android/SETUP.md
new file mode 100644
index 00000000..114657be
--- /dev/null
+++ b/apps/terminal-android/SETUP.md
@@ -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
+```
diff --git a/apps/terminal-android/app/build.gradle.kts b/apps/terminal-android/app/build.gradle.kts
new file mode 100644
index 00000000..dc639381
--- /dev/null
+++ b/apps/terminal-android/app/build.gradle.kts
@@ -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)
+}
diff --git a/apps/terminal-android/app/proguard-rules.pro b/apps/terminal-android/app/proguard-rules.pro
new file mode 100644
index 00000000..abbe68a7
--- /dev/null
+++ b/apps/terminal-android/app/proguard-rules.pro
@@ -0,0 +1,7 @@
+# Moshi
+-keep class lu.rewardflow.terminal.data.model.** { *; }
+-keepclassmembers class lu.rewardflow.terminal.data.model.** { *; }
+
+# Retrofit
+-keepattributes Signature
+-keepattributes *Annotation*
diff --git a/apps/terminal-android/app/src/main/AndroidManifest.xml b/apps/terminal-android/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..a6cb5f08
--- /dev/null
+++ b/apps/terminal-android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/MainActivity.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/MainActivity.kt
new file mode 100644
index 00000000..92c3e855
--- /dev/null
+++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/MainActivity.kt
@@ -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()
+ }
+ }
+ }
+ }
+}
diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/RewardFlowApp.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/RewardFlowApp.kt
new file mode 100644
index 00000000..85d57db8
--- /dev/null
+++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/RewardFlowApp.kt
@@ -0,0 +1,7 @@
+package lu.rewardflow.terminal
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class RewardFlowApp : Application()
diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt
new file mode 100644
index 00000000..04420a4b
--- /dev/null
+++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt
@@ -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
+}
diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/AppDatabase.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/AppDatabase.kt
new file mode 100644
index 00000000..95b1b1de
--- /dev/null
+++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/AppDatabase.kt
@@ -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
+}
diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/dao/PendingTransactionDao.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/dao/PendingTransactionDao.kt
new file mode 100644
index 00000000..1ee52b87
--- /dev/null
+++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/dao/PendingTransactionDao.kt
@@ -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
+
+ @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)
+}
diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/entity/PendingTransaction.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/entity/PendingTransaction.kt
new file mode 100644
index 00000000..b200c075
--- /dev/null
+++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/entity/PendingTransaction.kt
@@ -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",
+)
diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt
new file mode 100644
index 00000000..d8e85201
--- /dev/null
+++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt
@@ -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 = 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,
+ val total: Int,
+)
+
+@JsonClass(generateAdapter = true)
+data class PinItem(
+ val id: Int,
+ val name: String,
+ val is_active: Boolean,
+ val is_locked: Boolean,
+)
diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/AppModule.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/AppModule.kt
new file mode 100644
index 00000000..6e7204c1
--- /dev/null
+++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/AppModule.kt
@@ -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()
+ }
+}
diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/DataStoreModule.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/DataStoreModule.kt
new file mode 100644
index 00000000..1ce10654
--- /dev/null
+++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/DataStoreModule.kt
@@ -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 by preferencesDataStore(name = "device_config")
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DataStoreModule {
+
+ @Provides
+ @Singleton
+ fun provideDataStore(@ApplicationContext context: Context): DataStore {
+ return context.dataStore
+ }
+}
diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/RewardFlowNavHost.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/RewardFlowNavHost.kt
new file mode 100644
index 00000000..79eb3ef0
--- /dev/null
+++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/RewardFlowNavHost.kt
@@ -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 }
+ }
+ },
+ )
+ }
+ }
+}
diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/pin/PinScreen.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/pin/PinScreen.kt
new file mode 100644
index 00000000..7f138579
--- /dev/null
+++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/pin/PinScreen.kt
@@ -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(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))
+ }
+ }
+ }
+}
diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupScreen.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupScreen.kt
new file mode 100644
index 00000000..17d1935b
--- /dev/null
+++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupScreen.kt
@@ -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)")
+ }
+ }
+ }
+}
diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupViewModel.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupViewModel.kt
new file mode 100644
index 00000000..abd8ff58
--- /dev/null
+++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupViewModel.kt
@@ -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,
+) : ViewModel() {
+
+ companion object {
+ val IS_SET_UP = booleanPreferencesKey("is_device_set_up")
+ }
+
+ val isDeviceSetUp: Flow = dataStore.data.map { prefs ->
+ prefs[IS_SET_UP] ?: false
+ }
+}
diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt
new file mode 100644
index 00000000..5959289c
--- /dev/null
+++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt
@@ -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
+ }
+ }
+ }
+ }
+}
diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/theme/Theme.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/theme/Theme.kt
new file mode 100644
index 00000000..9abcb650
--- /dev/null
+++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/theme/Theme.kt
@@ -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,
+ )
+}
diff --git a/apps/terminal-android/app/src/main/res/values/strings.xml b/apps/terminal-android/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..4d5c22b8
--- /dev/null
+++ b/apps/terminal-android/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ RewardFlow Terminal
+
diff --git a/apps/terminal-android/app/src/main/res/values/themes.xml b/apps/terminal-android/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..4ef96633
--- /dev/null
+++ b/apps/terminal-android/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/apps/terminal-android/build.gradle.kts b/apps/terminal-android/build.gradle.kts
new file mode 100644
index 00000000..7b4d71d1
--- /dev/null
+++ b/apps/terminal-android/build.gradle.kts
@@ -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
+}
diff --git a/apps/terminal-android/gradle.properties b/apps/terminal-android/gradle.properties
new file mode 100644
index 00000000..f0a2e55f
--- /dev/null
+++ b/apps/terminal-android/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/apps/terminal-android/gradle/libs.versions.toml b/apps/terminal-android/gradle/libs.versions.toml
new file mode 100644
index 00000000..1d0678d7
--- /dev/null
+++ b/apps/terminal-android/gradle/libs.versions.toml
@@ -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" }
diff --git a/apps/terminal-android/gradle/wrapper/gradle-wrapper.properties b/apps/terminal-android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..e2847c82
--- /dev/null
+++ b/apps/terminal-android/gradle/wrapper/gradle-wrapper.properties
@@ -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
diff --git a/apps/terminal-android/settings.gradle.kts b/apps/terminal-android/settings.gradle.kts
new file mode 100644
index 00000000..a533ee44
--- /dev/null
+++ b/apps/terminal-android/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolution {
+ @Suppress("UnstableApiUsage")
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "RewardFlowTerminal"
+include(":app")