feat: scaffold Android terminal POS app (RewardFlow Terminal)
Some checks failed
Some checks failed
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:
14
apps/terminal-android/.gitignore
vendored
Normal file
14
apps/terminal-android/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
/app/build
|
||||
/app/release
|
||||
*.apk
|
||||
*.aab
|
||||
100
apps/terminal-android/SETUP.md
Normal file
100
apps/terminal-android/SETUP.md
Normal 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
|
||||
```
|
||||
108
apps/terminal-android/app/build.gradle.kts
Normal file
108
apps/terminal-android/app/build.gradle.kts
Normal 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)
|
||||
}
|
||||
7
apps/terminal-android/app/proguard-rules.pro
vendored
Normal file
7
apps/terminal-android/app/proguard-rules.pro
vendored
Normal 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*
|
||||
38
apps/terminal-android/app/src/main/AndroidManifest.xml
Normal file
38
apps/terminal-android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package lu.rewardflow.terminal
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class RewardFlowApp : Application()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">RewardFlow Terminal</string>
|
||||
</resources>
|
||||
5
apps/terminal-android/app/src/main/res/values/themes.xml
Normal file
5
apps/terminal-android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<style name="Theme.RewardFlowTerminal" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
8
apps/terminal-android/build.gradle.kts
Normal file
8
apps/terminal-android/build.gradle.kts
Normal 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
|
||||
}
|
||||
4
apps/terminal-android/gradle.properties
Normal file
4
apps/terminal-android/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
113
apps/terminal-android/gradle/libs.versions.toml
Normal file
113
apps/terminal-android/gradle/libs.versions.toml
Normal 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" }
|
||||
7
apps/terminal-android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
apps/terminal-android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||
18
apps/terminal-android/settings.gradle.kts
Normal file
18
apps/terminal-android/settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolution {
|
||||
@Suppress("UnstableApiUsage")
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "RewardFlowTerminal"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user