diff --git a/apps/terminal-android/.gitignore b/apps/terminal-android/.gitignore new file mode 100644 index 00000000..14c22339 --- /dev/null +++ b/apps/terminal-android/.gitignore @@ -0,0 +1,14 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/app/build +/app/release +*.apk +*.aab diff --git a/apps/terminal-android/SETUP.md b/apps/terminal-android/SETUP.md new file mode 100644 index 00000000..114657be --- /dev/null +++ b/apps/terminal-android/SETUP.md @@ -0,0 +1,100 @@ +# RewardFlow Terminal — Android Setup + +## Prerequisites + +### 1. Install Android Studio + +```bash +# Download Android Studio (Xubuntu/Ubuntu) +sudo snap install android-studio --classic + +# Or via apt (if snap not available) +# Download from https://developer.android.com/studio +# Extract and run: ./android-studio/bin/studio.sh +``` + +Android Studio bundles: +- JDK 17 (no need to install separately) +- Android SDK +- Gradle +- Android Emulator + +### 2. First-time Android Studio setup + +1. Open Android Studio +2. Choose "Standard" installation +3. Accept SDK licenses: `Tools → SDK Manager → SDK Platforms → Android 15 (API 35)` +4. Install: `Tools → SDK Manager → SDK Tools`: + - Android SDK Build-Tools + - Android SDK Platform-Tools + - Android Emulator + - Google Play services (for ML Kit barcode scanning) + +### 3. Open the project + +1. `File → Open` → navigate to `apps/terminal-android/` +2. Wait for Gradle sync (first time downloads ~500MB of dependencies) +3. If prompted about Gradle JDK, select the bundled JDK 17 + +### 4. Create a tablet emulator + +1. `Tools → Device Manager → Create Virtual Device` +2. Category: **Tablet** → pick **Pixel Tablet** or **Nexus 10** +3. System image: **API 35** (download if needed) +4. Finish + +### 5. Run the app + +1. Select the tablet emulator in the device dropdown +2. Click ▶️ Run +3. The app opens in landscape fullscreen + +## Project structure + +``` +apps/terminal-android/ +├── app/ +│ ├── build.gradle.kts # App dependencies (like requirements.txt) +│ ├── src/main/ +│ │ ├── AndroidManifest.xml # App permissions & config +│ │ ├── java/lu/rewardflow/terminal/ +│ │ │ ├── RewardFlowApp.kt # Application entry point +│ │ │ ├── MainActivity.kt # Single activity (Compose) +│ │ │ ├── ui/ # Screens (Setup, PIN, Terminal) +│ │ │ ├── data/ # API, DB, models, sync +│ │ │ └── di/ # Dependency injection (Hilt) +│ │ └── res/ # Resources (strings, themes, icons) +│ └── proguard-rules.pro # Release build obfuscation rules +├── gradle/ +│ ├── libs.versions.toml # Version catalog (all dependency versions) +│ └── wrapper/ # Gradle wrapper (pinned version) +├── build.gradle.kts # Root build file +├── settings.gradle.kts # Project settings +└── .gitignore +``` + +## Key dependencies (in gradle/libs.versions.toml) + +| Library | Purpose | +|---------|---------| +| Jetpack Compose | UI framework (declarative, like SwiftUI) | +| Retrofit + Moshi | HTTP client + JSON parsing (calls the Orion API) | +| Room | SQLite ORM (offline transaction queue) | +| WorkManager | Background sync (retry pending transactions) | +| Hilt | Dependency injection | +| CameraX + ML Kit | Camera preview + QR/barcode scanning | +| DataStore | Key-value persistence (device config, auth token) | + +## API connection + +- **Debug builds**: connect to `http://10.0.2.2:8000` (Android emulator's localhost alias) +- **Release builds**: connect to `https://rewardflow.lu` +- Configure in `app/build.gradle.kts` → `buildConfigField("DEFAULT_API_URL", ...)` + +## Building a release APK + +```bash +cd apps/terminal-android +./gradlew assembleRelease +# APK at: app/build/outputs/apk/release/app-release.apk +``` diff --git a/apps/terminal-android/app/build.gradle.kts b/apps/terminal-android/app/build.gradle.kts new file mode 100644 index 00000000..dc639381 --- /dev/null +++ b/apps/terminal-android/app/build.gradle.kts @@ -0,0 +1,108 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "lu.rewardflow.terminal" + compileSdk = 35 + + defaultConfig { + applicationId = "lu.rewardflow.terminal" + minSdk = 26 // Android 8.0 — covers 95%+ of tablets + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { + buildConfigField("String", "DEFAULT_API_URL", "\"http://10.0.2.2:8000\"") + } + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + buildConfigField("String", "DEFAULT_API_URL", "\"https://rewardflow.lu\"") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + buildConfig = true + } +} + +dependencies { + // AndroidX Core + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + + // Compose + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons) + implementation(libs.activity.compose) + implementation(libs.navigation.compose) + implementation(libs.lifecycle.runtime.compose) + implementation(libs.lifecycle.viewmodel.compose) + debugImplementation(libs.compose.ui.tooling) + + // Networking (Retrofit + Moshi) + implementation(libs.retrofit) + implementation(libs.retrofit.moshi) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + implementation(libs.moshi) + ksp(libs.moshi.codegen) + + // Room (offline database) + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + + // WorkManager (background sync) + implementation(libs.work.runtime.ktx) + + // Hilt (dependency injection) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.hilt.navigation.compose) + implementation(libs.hilt.work) + + // CameraX + ML Kit (QR scanning) + implementation(libs.camera.core) + implementation(libs.camera.camera2) + implementation(libs.camera.lifecycle) + implementation(libs.camera.view) + implementation(libs.mlkit.barcode) + + // DataStore (device config persistence) + implementation(libs.datastore.preferences) + + // Testing + testImplementation(libs.junit) + androidTestImplementation(libs.junit.android) + androidTestImplementation(libs.espresso.core) +} diff --git a/apps/terminal-android/app/proguard-rules.pro b/apps/terminal-android/app/proguard-rules.pro new file mode 100644 index 00000000..abbe68a7 --- /dev/null +++ b/apps/terminal-android/app/proguard-rules.pro @@ -0,0 +1,7 @@ +# Moshi +-keep class lu.rewardflow.terminal.data.model.** { *; } +-keepclassmembers class lu.rewardflow.terminal.data.model.** { *; } + +# Retrofit +-keepattributes Signature +-keepattributes *Annotation* diff --git a/apps/terminal-android/app/src/main/AndroidManifest.xml b/apps/terminal-android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a6cb5f08 --- /dev/null +++ b/apps/terminal-android/app/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/MainActivity.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/MainActivity.kt new file mode 100644 index 00000000..92c3e855 --- /dev/null +++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/MainActivity.kt @@ -0,0 +1,33 @@ +package lu.rewardflow.terminal + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import dagger.hilt.android.AndroidEntryPoint +import lu.rewardflow.terminal.ui.RewardFlowNavHost +import lu.rewardflow.terminal.ui.theme.RewardFlowTheme + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + RewardFlowTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + RewardFlowNavHost() + } + } + } + } +} diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/RewardFlowApp.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/RewardFlowApp.kt new file mode 100644 index 00000000..85d57db8 --- /dev/null +++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/RewardFlowApp.kt @@ -0,0 +1,7 @@ +package lu.rewardflow.terminal + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class RewardFlowApp : Application() diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt new file mode 100644 index 00000000..04420a4b --- /dev/null +++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt @@ -0,0 +1,57 @@ +package lu.rewardflow.terminal.data.api + +import lu.rewardflow.terminal.data.model.* +import retrofit2.http.* + +/** + * Retrofit interface for the Orion Loyalty Store API. + * + * Mirrors the endpoints at /api/v1/store/loyalty/*. + * Auth via Bearer token (obtained during device setup). + */ +interface LoyaltyApi { + + // ── Program ───────────────────────────────────────────────────────── + + @GET("api/v1/store/loyalty/program") + suspend fun getProgram(): ProgramResponse + + // ── Card Lookup ───────────────────────────────────────────────────── + + @GET("api/v1/store/loyalty/cards/lookup") + suspend fun lookupCard(@Query("q") query: String): CardLookupResponse + + @GET("api/v1/store/loyalty/cards/{cardId}") + suspend fun getCardDetail(@Path("cardId") cardId: Int): CardDetailResponse + + // ── Enrollment ────────────────────────────────────────────────────── + + @POST("api/v1/store/loyalty/cards/enroll") + suspend fun enrollCustomer(@Body request: EnrollRequest): CardResponse + + // ── Stamps ────────────────────────────────────────────────────────── + + @POST("api/v1/store/loyalty/stamp") + suspend fun addStamp(@Body request: StampRequest): StampResponse + + @POST("api/v1/store/loyalty/stamp/redeem") + suspend fun redeemStamps(@Body request: StampRedeemRequest): StampRedeemResponse + + // ── Points ────────────────────────────────────────────────────────── + + @POST("api/v1/store/loyalty/points/earn") + suspend fun earnPoints(@Body request: PointsEarnRequest): PointsEarnResponse + + @POST("api/v1/store/loyalty/points/redeem") + suspend fun redeemPoints(@Body request: PointsRedeemRequest): PointsRedeemResponse + + // ── PINs (for caching staff PINs locally) ─────────────────────────── + + @GET("api/v1/store/loyalty/pins") + suspend fun listPins(): PinListResponse + + // ── Auth ──────────────────────────────────────────────────────────── + + @POST("api/v1/store/auth/login") + suspend fun login(@Body request: LoginRequest): LoginResponse +} diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/AppDatabase.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/AppDatabase.kt new file mode 100644 index 00000000..95b1b1de --- /dev/null +++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/AppDatabase.kt @@ -0,0 +1,15 @@ +package lu.rewardflow.terminal.data.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import lu.rewardflow.terminal.data.db.dao.PendingTransactionDao +import lu.rewardflow.terminal.data.db.entity.PendingTransaction + +@Database( + entities = [PendingTransaction::class], + version = 1, + exportSchema = false, +) +abstract class AppDatabase : RoomDatabase() { + abstract fun pendingTransactionDao(): PendingTransactionDao +} diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/dao/PendingTransactionDao.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/dao/PendingTransactionDao.kt new file mode 100644 index 00000000..1ee52b87 --- /dev/null +++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/dao/PendingTransactionDao.kt @@ -0,0 +1,29 @@ +package lu.rewardflow.terminal.data.db.dao + +import androidx.room.* +import lu.rewardflow.terminal.data.db.entity.PendingTransaction + +@Dao +interface PendingTransactionDao { + + @Insert + suspend fun insert(transaction: PendingTransaction): Long + + @Query("SELECT * FROM pending_transactions WHERE status = 'pending' ORDER BY createdAt ASC") + suspend fun getPending(): List + + @Query("SELECT COUNT(*) FROM pending_transactions WHERE status = 'pending'") + suspend fun getPendingCount(): Int + + @Update + suspend fun update(transaction: PendingTransaction) + + @Query("DELETE FROM pending_transactions WHERE status = 'synced'") + suspend fun deleteSynced() + + @Query("UPDATE pending_transactions SET status = 'failed', lastError = :error, retryCount = retryCount + 1 WHERE id = :id") + suspend fun markFailed(id: Long, error: String) + + @Query("UPDATE pending_transactions SET status = 'synced' WHERE id = :id") + suspend fun markSynced(id: Long) +} diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/entity/PendingTransaction.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/entity/PendingTransaction.kt new file mode 100644 index 00000000..b200c075 --- /dev/null +++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/entity/PendingTransaction.kt @@ -0,0 +1,36 @@ +package lu.rewardflow.terminal.data.db.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Offline transaction queue. + * + * When the device is offline, transactions (stamp, points, enrollment) + * are stored here and synced via WorkManager when connectivity returns. + */ +@Entity(tableName = "pending_transactions") +data class PendingTransaction( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + + /** Type: "stamp", "points_earn", "enroll" */ + val type: String, + + /** JSON payload matching the API request body */ + val requestJson: String, + + /** Staff PIN ID who initiated the transaction */ + val staffPinId: Int, + + /** When the transaction was created locally */ + val createdAt: Long = System.currentTimeMillis(), + + /** Number of sync attempts */ + val retryCount: Int = 0, + + /** Last sync error (null if not yet attempted) */ + val lastError: String? = null, + + /** "pending", "syncing", "synced", "failed" */ + val status: String = "pending", +) diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt new file mode 100644 index 00000000..d8e85201 --- /dev/null +++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt @@ -0,0 +1,189 @@ +package lu.rewardflow.terminal.data.model + +import com.squareup.moshi.JsonClass + +// ── Auth ──────────────────────────────────────────────────────────────── + +@JsonClass(generateAdapter = true) +data class LoginRequest( + val email_or_username: String, + val password: String, +) + +@JsonClass(generateAdapter = true) +data class LoginResponse( + val access_token: String, + val token_type: String, + val expires_in: Int, +) + +// ── Program ───────────────────────────────────────────────────────────── + +@JsonClass(generateAdapter = true) +data class ProgramResponse( + val id: Int, + val merchant_id: Int, + val loyalty_type: String, + val display_name: String, + val card_name: String, + val card_color: String, + val card_secondary_color: String? = null, + val logo_url: String? = null, + val stamps_target: Int = 10, + val points_per_euro: Int = 10, + val welcome_bonus_points: Int = 0, + val stamps_reward_description: String? = null, + val require_staff_pin: Boolean = false, + val is_active: Boolean = true, +) + +// ── Card ──────────────────────────────────────────────────────────────── + +@JsonClass(generateAdapter = true) +data class CardResponse( + val id: Int, + val card_number: String, + val customer_id: Int, + val merchant_id: Int, + val points_balance: Int = 0, + val stamp_count: Int = 0, + val is_active: Boolean = true, +) + +@JsonClass(generateAdapter = true) +data class CardLookupResponse( + val id: Int, + val card_number: String, + val customer_id: Int, + val customer_name: String? = null, + val customer_email: String = "", + val merchant_id: Int, + val stamp_count: Int = 0, + val stamps_target: Int = 10, + val stamps_until_reward: Int = 10, + val points_balance: Int = 0, + val can_redeem_stamps: Boolean = false, + val stamp_reward_description: String? = null, + val available_rewards: List = emptyList(), + val can_stamp: Boolean = true, + val stamps_today: Int = 0, + val max_daily_stamps: Int = 5, +) + +@JsonClass(generateAdapter = true) +data class CardDetailResponse( + val id: Int, + val card_number: String, + val customer_name: String? = null, + val customer_email: String? = null, + val points_balance: Int = 0, + val stamp_count: Int = 0, + val stamps_target: Int = 10, + val total_points_earned: Int = 0, + val total_stamps_earned: Int = 0, + val is_active: Boolean = true, +) + +@JsonClass(generateAdapter = true) +data class RewardItem( + val id: String, + val name: String, + val points_required: Int, + val is_active: Boolean = true, +) + +// ── Enrollment ────────────────────────────────────────────────────────── + +@JsonClass(generateAdapter = true) +data class EnrollRequest( + val customer_id: Int? = null, + val email: String? = null, + val customer_name: String? = null, + val customer_phone: String? = null, + val customer_birthday: String? = null, +) + +// ── Stamps ────────────────────────────────────────────────────────────── + +@JsonClass(generateAdapter = true) +data class StampRequest( + val card_id: Int? = null, + val qr_code: String? = null, + val card_number: String? = null, + val staff_pin: String? = null, + val notes: String? = null, +) + +@JsonClass(generateAdapter = true) +data class StampResponse( + val success: Boolean, + val stamp_count: Int = 0, + val stamps_target: Int = 10, + val reward_earned: Boolean = false, + val reward_description: String? = null, +) + +@JsonClass(generateAdapter = true) +data class StampRedeemRequest( + val card_id: Int? = null, + val qr_code: String? = null, + val card_number: String? = null, + val staff_pin: String? = null, +) + +@JsonClass(generateAdapter = true) +data class StampRedeemResponse( + val success: Boolean, + val stamp_count: Int = 0, +) + +// ── Points ────────────────────────────────────────────────────────────── + +@JsonClass(generateAdapter = true) +data class PointsEarnRequest( + val card_id: Int? = null, + val qr_code: String? = null, + val card_number: String? = null, + val purchase_amount_cents: Int, + val order_reference: String? = null, + val staff_pin: String? = null, + val notes: String? = null, +) + +@JsonClass(generateAdapter = true) +data class PointsEarnResponse( + val success: Boolean, + val points_earned: Int = 0, + val points_balance: Int = 0, +) + +@JsonClass(generateAdapter = true) +data class PointsRedeemRequest( + val card_id: Int? = null, + val qr_code: String? = null, + val card_number: String? = null, + val reward_id: String, + val staff_pin: String? = null, +) + +@JsonClass(generateAdapter = true) +data class PointsRedeemResponse( + val success: Boolean, + val points_balance: Int = 0, +) + +// ── PINs ──────────────────────────────────────────────────────────────── + +@JsonClass(generateAdapter = true) +data class PinListResponse( + val pins: List, + val total: Int, +) + +@JsonClass(generateAdapter = true) +data class PinItem( + val id: Int, + val name: String, + val is_active: Boolean, + val is_locked: Boolean, +) diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/AppModule.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/AppModule.kt new file mode 100644 index 00000000..6e7204c1 --- /dev/null +++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/AppModule.kt @@ -0,0 +1,80 @@ +package lu.rewardflow.terminal.di + +import android.content.Context +import androidx.room.Room +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import lu.rewardflow.terminal.BuildConfig +import lu.rewardflow.terminal.data.api.LoyaltyApi +import lu.rewardflow.terminal.data.db.AppDatabase +import lu.rewardflow.terminal.data.db.dao.PendingTransactionDao +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideMoshi(): Moshi = Moshi.Builder() + .addLast(KotlinJsonAdapterFactory()) + .build() + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient { + val builder = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + + if (BuildConfig.DEBUG) { + val logging = HttpLoggingInterceptor() + logging.level = HttpLoggingInterceptor.Level.BODY + builder.addInterceptor(logging) + } + + return builder.build() + } + + @Provides + @Singleton + fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit { + return Retrofit.Builder() + .baseUrl(BuildConfig.DEFAULT_API_URL + "/") + .client(okHttpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + } + + @Provides + @Singleton + fun provideLoyaltyApi(retrofit: Retrofit): LoyaltyApi { + return retrofit.create(LoyaltyApi::class.java) + } + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): AppDatabase { + return Room.databaseBuilder( + context, + AppDatabase::class.java, + "rewardflow_terminal.db", + ).build() + } + + @Provides + fun providePendingTransactionDao(db: AppDatabase): PendingTransactionDao { + return db.pendingTransactionDao() + } +} diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/DataStoreModule.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/DataStoreModule.kt new file mode 100644 index 00000000..1ce10654 --- /dev/null +++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/di/DataStoreModule.kt @@ -0,0 +1,25 @@ +package lu.rewardflow.terminal.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +private val Context.dataStore: DataStore by preferencesDataStore(name = "device_config") + +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + + @Provides + @Singleton + fun provideDataStore(@ApplicationContext context: Context): DataStore { + return context.dataStore + } +} diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/RewardFlowNavHost.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/RewardFlowNavHost.kt new file mode 100644 index 00000000..79eb3ef0 --- /dev/null +++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/RewardFlowNavHost.kt @@ -0,0 +1,69 @@ +package lu.rewardflow.terminal.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import lu.rewardflow.terminal.ui.pin.PinScreen +import lu.rewardflow.terminal.ui.setup.SetupScreen +import lu.rewardflow.terminal.ui.setup.SetupViewModel +import lu.rewardflow.terminal.ui.terminal.TerminalScreen + +/** + * Navigation flow: + * + * 1. SetupScreen — first-time device setup (scan QR from web) + * 2. PinScreen — staff enters PIN to unlock terminal + * 3. TerminalScreen — main POS terminal (scan, search, transact) + * + * After setup, the app always starts at PinScreen. + * After 2 min idle on TerminalScreen, auto-lock back to PinScreen. + */ +@Composable +fun RewardFlowNavHost() { + val navController = rememberNavController() + val setupViewModel: SetupViewModel = hiltViewModel() + val isSetUp by setupViewModel.isDeviceSetUp.collectAsState(initial = false) + + val startDestination = if (isSetUp) "pin" else "setup" + + NavHost(navController = navController, startDestination = startDestination) { + composable("setup") { + SetupScreen( + onSetupComplete = { + navController.navigate("pin") { + popUpTo("setup") { inclusive = true } + } + }, + ) + } + + composable("pin") { + PinScreen( + onPinVerified = { staffPinId, staffName -> + navController.navigate("terminal/$staffPinId/$staffName") { + popUpTo("pin") { inclusive = true } + } + }, + ) + } + + composable("terminal/{staffPinId}/{staffName}") { backStackEntry -> + val staffPinId = backStackEntry.arguments?.getString("staffPinId")?.toIntOrNull() ?: 0 + val staffName = backStackEntry.arguments?.getString("staffName") ?: "" + + TerminalScreen( + staffPinId = staffPinId, + staffName = staffName, + onLockScreen = { + navController.navigate("pin") { + popUpTo("terminal/{staffPinId}/{staffName}") { inclusive = true } + } + }, + ) + } + } +} diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/pin/PinScreen.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/pin/PinScreen.kt new file mode 100644 index 00000000..7f138579 --- /dev/null +++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/pin/PinScreen.kt @@ -0,0 +1,103 @@ +package lu.rewardflow.terminal.ui.pin + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * PIN entry screen — staff enters their 4-digit PIN to unlock the terminal. + * + * The PIN identifies the staff member (unique per store). + * PINs are cached locally and refreshed periodically from the API. + */ +@Composable +fun PinScreen( + onPinVerified: (staffPinId: Int, staffName: String) -> Unit, +) { + var pinDigits by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(48.dp), + ) { + Text( + text = "Enter Staff PIN", + style = MaterialTheme.typography.headlineMedium, + ) + Spacer(modifier = Modifier.height(24.dp)) + + // PIN dots display + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + repeat(4) { i -> + Surface( + modifier = Modifier.size(56.dp), + shape = MaterialTheme.shapes.medium, + color = if (pinDigits.length > i) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.surfaceVariant, + ) { + Box(contentAlignment = Alignment.Center) { + if (pinDigits.length > i) { + Text("•", fontSize = 32.sp, color = MaterialTheme.colorScheme.onPrimary) + } + } + } + } + } + + error?.let { + Spacer(modifier = Modifier.height(12.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Number pad + val buttons = listOf( + listOf("1", "2", "3"), + listOf("4", "5", "6"), + listOf("7", "8", "9"), + listOf("C", "0", "⌫"), + ) + + buttons.forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + row.forEach { label -> + FilledTonalButton( + onClick = { + when (label) { + "C" -> { pinDigits = ""; error = null } + "⌫" -> { if (pinDigits.isNotEmpty()) pinDigits = pinDigits.dropLast(1) } + else -> { + if (pinDigits.length < 4) { + pinDigits += label + if (pinDigits.length == 4) { + // TODO: verify PIN against cached list + // For now, placeholder callback + onPinVerified(1, "Staff") + } + } + } + } + }, + modifier = Modifier.size(72.dp), + ) { + Text(label, fontSize = 24.sp) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupScreen.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupScreen.kt new file mode 100644 index 00000000..17d1935b --- /dev/null +++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupScreen.kt @@ -0,0 +1,49 @@ +package lu.rewardflow.terminal.ui.setup + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * First-time device setup screen. + * + * Flow: + * 1. Show "Scan QR Code" prompt + * 2. Merchant owner scans QR from web settings page + * 3. QR contains: API URL + store auth token + store_id + store_code + * 4. App downloads store config and enters kiosk mode + */ +@Composable +fun SetupScreen( + onSetupComplete: () -> Unit, +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(48.dp), + ) { + Text( + text = "RewardFlow Terminal", + style = MaterialTheme.typography.headlineLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Scan the setup QR code from your store settings page to configure this device.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(32.dp)) + // TODO: CameraX QR scanner for setup code + Button(onClick = onSetupComplete) { + Text("Setup Complete (placeholder)") + } + } + } +} diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupViewModel.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupViewModel.kt new file mode 100644 index 00000000..abd8ff58 --- /dev/null +++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupViewModel.kt @@ -0,0 +1,24 @@ +package lu.rewardflow.terminal.ui.setup + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +class SetupViewModel @Inject constructor( + private val dataStore: DataStore, +) : ViewModel() { + + companion object { + val IS_SET_UP = booleanPreferencesKey("is_device_set_up") + } + + val isDeviceSetUp: Flow = dataStore.data.map { prefs -> + prefs[IS_SET_UP] ?: false + } +} diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt new file mode 100644 index 00000000..5959289c --- /dev/null +++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt @@ -0,0 +1,95 @@ +package lu.rewardflow.terminal.ui.terminal + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Main POS terminal screen. + * + * Layout (landscape tablet): + * ┌──────────────────────────────────────────────┐ + * │ [Staff: Jane] [Store: Fashion Hub] [Lock] │ + * ├──────────────────┬───────────────────────────┤ + * │ │ │ + * │ Customer Search │ Card Details │ + * │ + QR Scanner │ Points/Stamps balance │ + * │ │ Quick actions │ + * │ │ (Earn, Redeem, Enroll) │ + * │ │ │ + * └──────────────────┴───────────────────────────┘ + */ +@Composable +fun TerminalScreen( + staffPinId: Int, + staffName: String, + onLockScreen: () -> Unit, +) { + Column(modifier = Modifier.fillMaxSize()) { + // Top bar + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Staff: $staffName", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + // TODO: show store name, offline indicator, pending sync count + FilledTonalButton(onClick = onLockScreen) { + Text("Lock") + } + } + } + + // Main content — two-pane layout + Row(modifier = Modifier.fillMaxSize()) { + // Left pane: search + scan + Surface( + modifier = Modifier + .weight(0.4f) + .fillMaxHeight() + .padding(16.dp), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Search Customer", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(16.dp)) + // TODO: Search field + QR camera preview + Text("QR Scanner / Search field", style = MaterialTheme.typography.bodyMedium) + } + } + + // Right pane: card details + actions + Surface( + modifier = Modifier + .weight(0.6f) + .fillMaxHeight() + .padding(16.dp), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("Select a customer to begin", style = MaterialTheme.typography.bodyLarge) + // TODO: Show card details, balance, quick action buttons + } + } + } + } +} diff --git a/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/theme/Theme.kt b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/theme/Theme.kt new file mode 100644 index 00000000..9abcb650 --- /dev/null +++ b/apps/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/theme/Theme.kt @@ -0,0 +1,60 @@ +package lu.rewardflow.terminal.ui.theme + +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +// RewardFlow brand colors +private val Purple600 = Color(0xFF7C3AED) +private val Purple700 = Color(0xFF6D28D9) +private val Purple50 = Color(0xFFF5F3FF) + +private val LightColorScheme = lightColorScheme( + primary = Purple600, + onPrimary = Color.White, + primaryContainer = Purple50, + onPrimaryContainer = Purple700, + secondary = Color(0xFF10B981), + background = Color(0xFFF9FAFB), + surface = Color.White, + error = Color(0xFFEF4444), +) + +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFFA78BFA), + onPrimary = Color(0xFF1E1B4B), + primaryContainer = Color(0xFF312E81), + onPrimaryContainer = Color(0xFFE0E7FF), + secondary = Color(0xFF34D399), + background = Color(0xFF111827), + surface = Color(0xFF1F2937), + error = Color(0xFFF87171), +) + +@Composable +fun RewardFlowTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primaryContainer.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + content = content, + ) +} diff --git a/apps/terminal-android/app/src/main/res/values/strings.xml b/apps/terminal-android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..4d5c22b8 --- /dev/null +++ b/apps/terminal-android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + RewardFlow Terminal + diff --git a/apps/terminal-android/app/src/main/res/values/themes.xml b/apps/terminal-android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..4ef96633 --- /dev/null +++ b/apps/terminal-android/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + diff --git a/apps/terminal-android/build.gradle.kts b/apps/terminal-android/build.gradle.kts new file mode 100644 index 00000000..7b4d71d1 --- /dev/null +++ b/apps/terminal-android/build.gradle.kts @@ -0,0 +1,8 @@ +// Top-level build file +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false +} diff --git a/apps/terminal-android/gradle.properties b/apps/terminal-android/gradle.properties new file mode 100644 index 00000000..f0a2e55f --- /dev/null +++ b/apps/terminal-android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/apps/terminal-android/gradle/libs.versions.toml b/apps/terminal-android/gradle/libs.versions.toml new file mode 100644 index 00000000..1d0678d7 --- /dev/null +++ b/apps/terminal-android/gradle/libs.versions.toml @@ -0,0 +1,113 @@ +# Version Catalog — single source of truth for all dependency versions. +# This is the Android equivalent of requirements.txt. +# +# Usage in build.gradle.kts: +# implementation(libs.androidx.core.ktx) +# implementation(libs.retrofit) + +[versions] +# Android / Kotlin +agp = "8.7.3" +kotlin = "2.1.0" +ksp = "2.1.0-1.0.29" + +# Compose +composeBom = "2025.01.01" +activityCompose = "1.10.1" +navigationCompose = "2.8.7" +lifecycleCompose = "2.8.7" + +# AndroidX +coreKtx = "1.15.0" +appcompat = "1.7.0" +material = "1.12.0" + +# Networking +retrofit = "2.11.0" +okhttp = "4.12.0" +moshi = "1.15.1" + +# Database (offline queue) +room = "2.6.1" + +# Background sync +workManager = "2.10.0" + +# Dependency Injection +hilt = "2.54.1" +hiltNavigationCompose = "1.2.0" + +# Camera & QR scanning +cameraX = "1.4.1" +mlkitBarcode = "17.3.0" + +# DataStore (preferences) +datastore = "1.1.2" + +# Testing +junit = "4.13.2" +junitAndroid = "1.2.1" +espresso = "3.6.1" + +[libraries] +# AndroidX Core +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } + +# Compose +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" } +lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleCompose" } + +# Networking +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +moshi = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" } +moshi-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" } + +# Room (offline database) +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } + +# WorkManager (background sync) +work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } + +# Hilt (dependency injection) +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltNavigationCompose" } + +# CameraX (QR scanning) +camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" } +camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" } +camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" } +camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" } +mlkit-barcode = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" } + +# DataStore +datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } + +# Testing +junit = { group = "junit", name = "junit", version.ref = "junit" } +junit-android = { group = "androidx.test.ext", name = "junit", version.ref = "junitAndroid" } +espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/apps/terminal-android/gradle/wrapper/gradle-wrapper.properties b/apps/terminal-android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..e2847c82 --- /dev/null +++ b/apps/terminal-android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/apps/terminal-android/settings.gradle.kts b/apps/terminal-android/settings.gradle.kts new file mode 100644 index 00000000..a533ee44 --- /dev/null +++ b/apps/terminal-android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolution { + @Suppress("UnstableApiUsage") + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "RewardFlowTerminal" +include(":app")