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:
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>
|
||||
Reference in New Issue
Block a user