refactor: rename apps/ to clients/ + update architecture docs
Some checks failed
Some checks failed
Rename apps/ → clients/ for clarity: - app/ (singular) = Python backend (FastAPI, server-rendered web UI) - clients/ (plural) = standalone client applications (API consumers) The web storefront/store/admin stays in app/ because it's server- rendered Jinja2, not a standalone frontend. clients/ is for native apps that connect to the API externally. Updated: - docs/architecture/overview.md — added clients/ to project structure - clients/terminal-android/SETUP.md — updated path references Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
14
clients/terminal-android/.gitignore
vendored
Normal file
14
clients/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
clients/terminal-android/SETUP.md
Normal file
100
clients/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 `clients/terminal-android/`
|
||||
2. Wait for Gradle sync (first time downloads ~500MB of dependencies)
|
||||
3. If prompted about Gradle JDK, select the bundled JDK 17
|
||||
|
||||
### 4. Create a tablet emulator
|
||||
|
||||
1. `Tools → Device Manager → Create Virtual Device`
|
||||
2. Category: **Tablet** → pick **Pixel Tablet** or **Nexus 10**
|
||||
3. System image: **API 35** (download if needed)
|
||||
4. Finish
|
||||
|
||||
### 5. Run the app
|
||||
|
||||
1. Select the tablet emulator in the device dropdown
|
||||
2. Click ▶️ Run
|
||||
3. The app opens in landscape fullscreen
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
clients/terminal-android/
|
||||
├── app/
|
||||
│ ├── build.gradle.kts # App dependencies (like requirements.txt)
|
||||
│ ├── src/main/
|
||||
│ │ ├── AndroidManifest.xml # App permissions & config
|
||||
│ │ ├── java/lu/rewardflow/terminal/
|
||||
│ │ │ ├── RewardFlowApp.kt # Application entry point
|
||||
│ │ │ ├── MainActivity.kt # Single activity (Compose)
|
||||
│ │ │ ├── ui/ # Screens (Setup, PIN, Terminal)
|
||||
│ │ │ ├── data/ # API, DB, models, sync
|
||||
│ │ │ └── di/ # Dependency injection (Hilt)
|
||||
│ │ └── res/ # Resources (strings, themes, icons)
|
||||
│ └── proguard-rules.pro # Release build obfuscation rules
|
||||
├── gradle/
|
||||
│ ├── libs.versions.toml # Version catalog (all dependency versions)
|
||||
│ └── wrapper/ # Gradle wrapper (pinned version)
|
||||
├── build.gradle.kts # Root build file
|
||||
├── settings.gradle.kts # Project settings
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
## Key dependencies (in gradle/libs.versions.toml)
|
||||
|
||||
| Library | Purpose |
|
||||
|---------|---------|
|
||||
| Jetpack Compose | UI framework (declarative, like SwiftUI) |
|
||||
| Retrofit + Moshi | HTTP client + JSON parsing (calls the Orion API) |
|
||||
| Room | SQLite ORM (offline transaction queue) |
|
||||
| WorkManager | Background sync (retry pending transactions) |
|
||||
| Hilt | Dependency injection |
|
||||
| CameraX + ML Kit | Camera preview + QR/barcode scanning |
|
||||
| DataStore | Key-value persistence (device config, auth token) |
|
||||
|
||||
## API connection
|
||||
|
||||
- **Debug builds**: connect to `http://10.0.2.2:8000` (Android emulator's localhost alias)
|
||||
- **Release builds**: connect to `https://rewardflow.lu`
|
||||
- Configure in `app/build.gradle.kts` → `buildConfigField("DEFAULT_API_URL", ...)`
|
||||
|
||||
## Building a release APK
|
||||
|
||||
```bash
|
||||
cd apps/terminal-android
|
||||
./gradlew assembleRelease
|
||||
# APK at: app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
108
clients/terminal-android/app/build.gradle.kts
Normal file
108
clients/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
clients/terminal-android/app/proguard-rules.pro
vendored
Normal file
7
clients/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
clients/terminal-android/app/src/main/AndroidManifest.xml
Normal file
38
clients/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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<style name="Theme.RewardFlowTerminal" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
8
clients/terminal-android/build.gradle.kts
Normal file
8
clients/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
clients/terminal-android/gradle.properties
Normal file
4
clients/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
clients/terminal-android/gradle/libs.versions.toml
Normal file
113
clients/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
clients/terminal-android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
clients/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
clients/terminal-android/settings.gradle.kts
Normal file
18
clients/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