From 9b629d32916f5d1c2c143984b6e40d63f3d24f25 Mon Sep 17 00:00:00 2001 From: isra el Date: Sat, 6 Jun 2026 22:09:19 +0300 Subject: [PATCH 1/3] feat(android): new Kotlin/Compose UI with onboarding, dashboard, messages, and settings Adds a full parallel Jetpack Compose UI alongside the legacy Java/XML app. Users can switch between UIs via Settings; SplashActivity routes on launch. New screens: - Onboarding wizard (QR/manual API key, device registration, permissions) - Dashboard (device status, all-time stats, subscription card, quick actions) - Messages tab (SMS history with filter chips, pagination, detail dialog) - Compose screen (multi-recipient input, send with snackbar feedback) - Settings (account, gateway, SMS, legal, system, about sections) - Splash screen with textbee logo and routing logic Infrastructure: - Compose BOM 2023.08.00, Material3, Navigation Compose, Kotlin coroutines - GatewayApiServiceKt (suspend Retrofit interface) + ApiManagerKt singleton - Kotlin DTOs for stats, subscription, user profile, messages, send SMS - Material3 theme with brand orange, dark mode safe (dynamicColor = false) Also adds MIGRATION.md tracking what's done vs what remains to port. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 +- android/BUILD_VARIANTS_SETUP.md | 107 ++++ android/MIGRATION.md | 207 +++++++ android/app/build.gradle | 38 +- android/app/src/main/AndroidManifest.xml | 19 +- .../main/java/com/vernu/sms/ApiManagerKt.kt | 20 + .../main/java/com/vernu/sms/AppConstants.java | 2 + .../vernu/sms/activities/MainActivity.java | 10 +- .../vernu/sms/dtos/GatewayStatsResponse.kt | 14 + .../com/vernu/sms/dtos/MessagesResponse.kt | 38 ++ .../vernu/sms/dtos/SubscriptionResponse.kt | 27 + .../com/vernu/sms/dtos/UserProfileResponse.kt | 12 + .../vernu/sms/helpers/HeartbeatHelper.java | 5 + .../vernu/sms/services/GatewayApiServiceKt.kt | 58 ++ .../services/StickyNotificationService.java | 6 +- .../vernu/sms/ui/dashboard/DashboardScreen.kt | 499 ++++++++++++++++ .../sms/ui/dashboard/DashboardViewModel.kt | 209 +++++++ .../com/vernu/sms/ui/main/NewMainActivity.kt | 172 ++++++ .../vernu/sms/ui/messages/ComposeScreen.kt | 178 ++++++ .../vernu/sms/ui/messages/ComposeViewModel.kt | 101 ++++ .../vernu/sms/ui/messages/MessagesScreen.kt | 360 ++++++++++++ .../sms/ui/messages/MessagesViewModel.kt | 103 ++++ .../sms/ui/onboarding/OnboardingActivity.kt | 104 ++++ .../sms/ui/onboarding/OnboardingViewModel.kt | 182 ++++++ .../onboarding/screens/CredentialsScreen.kt | 220 +++++++ .../onboarding/screens/DeviceSetupScreen.kt | 195 +++++++ .../screens/OnboardingComponents.kt | 65 +++ .../onboarding/screens/PermissionsScreen.kt | 232 ++++++++ .../onboarding/screens/SetupCompleteScreen.kt | 145 +++++ .../ui/onboarding/screens/WelcomeScreen.kt | 92 +++ .../vernu/sms/ui/settings/SettingsScreen.kt | 542 ++++++++++++++++++ .../sms/ui/settings/SettingsViewModel.kt | 215 +++++++ .../com/vernu/sms/ui/splash/SplashActivity.kt | 86 +++ .../main/java/com/vernu/sms/ui/theme/Color.kt | 20 + .../main/java/com/vernu/sms/ui/theme/Theme.kt | 84 +++ .../main/java/com/vernu/sms/ui/theme/Type.kt | 49 ++ .../src/main/res/drawable/ic_app_logo.webp | Bin 0 -> 6514 bytes .../app/src/main/res/layout/activity_main.xml | 12 + android/build.gradle | 7 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- 40 files changed, 4427 insertions(+), 14 deletions(-) create mode 100644 android/BUILD_VARIANTS_SETUP.md create mode 100644 android/MIGRATION.md create mode 100644 android/app/src/main/java/com/vernu/sms/ApiManagerKt.kt create mode 100644 android/app/src/main/java/com/vernu/sms/dtos/GatewayStatsResponse.kt create mode 100644 android/app/src/main/java/com/vernu/sms/dtos/MessagesResponse.kt create mode 100644 android/app/src/main/java/com/vernu/sms/dtos/SubscriptionResponse.kt create mode 100644 android/app/src/main/java/com/vernu/sms/dtos/UserProfileResponse.kt create mode 100644 android/app/src/main/java/com/vernu/sms/services/GatewayApiServiceKt.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardScreen.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardViewModel.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/main/NewMainActivity.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/messages/ComposeScreen.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/messages/ComposeViewModel.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/messages/MessagesScreen.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/messages/MessagesViewModel.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingActivity.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingViewModel.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/CredentialsScreen.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/DeviceSetupScreen.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/OnboardingComponents.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/PermissionsScreen.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/SetupCompleteScreen.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/WelcomeScreen.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/settings/SettingsScreen.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/settings/SettingsViewModel.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/splash/SplashActivity.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/theme/Color.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/theme/Theme.kt create mode 100644 android/app/src/main/java/com/vernu/sms/ui/theme/Type.kt create mode 100644 android/app/src/main/res/drawable/ic_app_logo.webp diff --git a/.gitignore b/.gitignore index 586d6fc..774574e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ .DS_Store *-monitor -.cursor \ No newline at end of file +.cursor +.claude/ +.omc/ \ No newline at end of file diff --git a/android/BUILD_VARIANTS_SETUP.md b/android/BUILD_VARIANTS_SETUP.md new file mode 100644 index 0000000..1b7545f --- /dev/null +++ b/android/BUILD_VARIANTS_SETUP.md @@ -0,0 +1,107 @@ +# Android Build Variants Setup (Dev vs Prod) + +This document explains how to use the dev and prod build variants for the SMS Gateway Android app. + +## Overview + +The app now supports two build variants: +- **Dev**: For development and testing +- **Prod**: For production releases + +## Key Differences + +| Feature | Dev | Prod | +|---------|-----|------| +| Package Name | `com.vernu.sms.dev` | `com.vernu.sms` | +| App Name | "SMS Gateway (Dev)" | "SMS Gateway" | +| API Base URL | `https://api-dev.textbee.dev/api/v1/` | `https://api.textbee.dev/api/v1/` | +| Firebase Config | `app/src/dev/google-services.json` | `app/src/prod/google-services.json` | +| Version Suffix | `-dev` appended | No suffix | + +## Setup Instructions + +### 1. Firebase Configuration + +#### For Production: +- The current `google-services.json` has been moved to `app/src/prod/google-services.json` +- No changes needed if you're already using the production Firebase project + +#### For Development: +- Create a new Firebase project for development +- Download the `google-services.json` for your dev project +- **Important**: Make sure the package name in Firebase is set to `com.vernu.sms.dev` +- Replace the template file at `app/src/dev/google-services.json` with your actual dev configuration + +### 2. API Configuration + +The API base URLs are now configured via build variants: +- **Dev**: `https://api-dev.textbee.dev/api/v1/` +- **Prod**: `https://api.textbee.dev/api/v1/` + +To change the dev API URL, edit the `buildConfigField` in `app/build.gradle`: +```gradle +dev { + buildConfigField "String", "API_BASE_URL", '"https://api-dev.textbee.dev/api/v1/"' +} +``` + +## Building the App + +### Using Android Studio: +1. Open the "Build Variants" panel (View → Tool Windows → Build Variants) +2. Select the desired variant: + - `devDebug` - Development build for debugging + - `devRelease` - Development release build + - `prodDebug` - Production build for debugging + - `prodRelease` - Production release build + +### Using Command Line: +```bash +# Build dev debug +./gradlew assembleDevDebug + +# Build dev release +./gradlew assembleDevRelease + +# Build prod debug +./gradlew assembleProdDebug + +# Build prod release +./gradlew assembleProdRelease +``` + +## Installation + +Both variants can be installed simultaneously on the same device since they have different package names: +- Dev app will show as "SMS Gateway (Dev)" +- Prod app will show as "SMS Gateway" + +## Environment Detection + +You can detect which environment the app is running in using: +```java +if (BuildConfig.ENVIRONMENT.equals("development")) { + // Development-specific code +} else { + // Production code +} +``` + +## Important Notes + +1. **Different Package Names**: Dev and prod apps are completely separate and can coexist +2. **Separate Data**: Each variant maintains its own app data and preferences +3. **Firebase Projects**: Use separate Firebase projects for dev and prod +4. **API Endpoints**: Ensure your backend has corresponding dev/prod endpoints +5. **Testing**: Always test the prod build before releasing + +## Troubleshooting + +### Build Errors: +- Ensure both `google-services.json` files are properly configured +- Check that package names match between Firebase console and app configuration +- Verify API URLs are accessible + +### Firebase Issues: +- Confirm the package name in Firebase matches the variant (`com.vernu.sms.dev` for dev) +- Ensure SHA-1 fingerprints are added to Firebase if using authentication \ No newline at end of file diff --git a/android/MIGRATION.md b/android/MIGRATION.md new file mode 100644 index 0000000..a1e97e1 --- /dev/null +++ b/android/MIGRATION.md @@ -0,0 +1,207 @@ +# Android Kotlin/Compose Migration + +## Overview + +TextBee Android is mid-migration from a Java/XML legacy codebase to Kotlin + Jetpack Compose. The new UI runs in parallel with the legacy UI — users can switch between them via Settings. The SplashActivity routes to the appropriate UI on launch. + +--- + +## What's Done + +### Theme +| File | Notes | +|---|---| +| `ui/theme/Color.kt` | Brand orange (`#C4620A`), full light/dark palette | +| `ui/theme/Theme.kt` | `TextBeeTheme` wrapper, `dynamicColor = false` to preserve brand color | +| `ui/theme/Type.kt` | Material3 typography scale | + +### Infrastructure +| File | Notes | +|---|---| +| `ApiManagerKt.kt` | Kotlin singleton Retrofit client, mirrors `ApiManager.java` | +| `services/GatewayApiServiceKt.kt` | Kotlin suspend-function Retrofit interface (all new UI endpoints) | +| `dtos/GatewayStatsResponse.kt` | Nullable stats DTO | +| `dtos/SubscriptionResponse.kt` | Plan, usage, renewal DTO | +| `dtos/UserProfileResponse.kt` | `name`, `email` from `/auth/who-am-i` | +| `dtos/MessagesResponse.kt` | `SmsMessage`, `PaginationMeta`, `SendSmsRequest` | + +### UI — Onboarding +| File | Notes | +|---|---| +| `ui/onboarding/OnboardingActivity.kt` | Compose NavHost shell | +| `ui/onboarding/OnboardingViewModel.kt` | Registration state + API calls | +| `ui/onboarding/screens/WelcomeScreen.kt` | Get Started / I have a Device ID | +| `ui/onboarding/screens/CredentialsScreen.kt` | QR scan + manual API key entry | +| `ui/onboarding/screens/DeviceSetupScreen.kt` | Register or reconnect device | +| `ui/onboarding/screens/PermissionsScreen.kt` | SMS + phone state permissions | +| `ui/onboarding/screens/SetupCompleteScreen.kt` | Success + navigate to dashboard | +| `ui/onboarding/screens/OnboardingComponents.kt` | Shared step indicator, etc. | + +### UI — Main App +| File | Notes | +|---|---| +| `ui/splash/SplashActivity.kt` | Routes to legacy/onboarding/main based on SharedPrefs | +| `ui/main/NewMainActivity.kt` | Bottom nav (Dashboard / Messages / Settings) + compose route | +| `ui/dashboard/DashboardScreen.kt` | Device card, stats, subscription, quick actions | +| `ui/dashboard/DashboardViewModel.kt` | Stats, subscription, user profile, gateway toggle | +| `ui/messages/MessagesScreen.kt` | Filter chips, message list, detail dialog, FAB | +| `ui/messages/MessagesViewModel.kt` | Paginated message fetch, filter state | +| `ui/messages/ComposeScreen.kt` | Multi-recipient input, message field, send with snackbar feedback | +| `ui/messages/ComposeViewModel.kt` | Send SMS, error body parsing, success/error state | +| `ui/settings/SettingsScreen.kt` | Account, Gateway, SMS, Legal, System, UI sections | +| `ui/settings/SettingsViewModel.kt` | Device name save, gateway toggle, SIM picker | + +--- + +## What's Left (Java / Legacy) + +### Core App +| File | Priority | Notes | +|---|---|---| +| `AppConstants.java` | Low | Constants only — convert when touching other things | +| `SMSGatewayApplication.java` | Low | Application class, minimal logic | +| `TextBeeUtils.java` | Medium | Heavily used utility; convert once helpers are stable | +| `ApiManager.java` | Low | Still used by legacy UI; delete after legacy removal | + +### Activities (Legacy UI) +| File | Priority | Notes | +|---|---|---| +| `activities/MainActivity.java` | High | Legacy main UI — remove after full Compose rollout | +| `activities/SMSFilterActivity.java` | High | Only user-facing Java screen still reachable from new UI | + +### Database (Room) +| File | Priority | Notes | +|---|---|---| +| `database/local/AppDatabase.java` | Medium | Room DB; convert to Kotlin for coroutine-friendly DAOs | +| `database/local/SMS.java` | Medium | Room entity | +| `database/local/SMSDao.java` | Medium | DAO — high value: Kotlin suspend queries | +| `database/local/DateConverter.java` | Low | Trivial type converter | + +### DTOs (Java) +| File | Priority | Notes | +|---|---|---| +| `dtos/RegisterDeviceInputDTO.java` | Medium | Setter-based Java — awkward from Kotlin | +| `dtos/RegisterDeviceResponseDTO.java` | Medium | Uses `Map` for `data` field | +| `dtos/HeartbeatInputDTO.java` | Low | Simple DTO | +| `dtos/HeartbeatResponseDTO.java` | Low | Simple DTO | +| `dtos/SMSDTO.java` | Medium | Core SMS payload | +| `dtos/SMSForwardResponseDTO.java` | Low | Simple DTO | +| `dtos/SimInfoDTO.java` / `SimInfoCollectionDTO.java` | Low | SIM info | + +### Helpers +| File | Priority | Notes | +|---|---|---| +| `helpers/SharedPreferenceHelper.java` | High | Called from every ViewModel — Kotlin extension would be cleaner | +| `helpers/HeartbeatHelper.java` | Medium | Heartbeat HTTP logic | +| `helpers/HeartbeatManager.java` | Medium | WorkManager scheduling | +| `helpers/SMSFilterHelper.java` | Medium | Filter rule evaluation | +| `helpers/SMSHelper.java` | Medium | SMS send/receive logic | +| `helpers/VersionTracker.java` | Low | Update check logic | + +### Models +| File | Priority | Notes | +|---|---|---| +| `models/SMSFilterRule.java` | Medium | Convert to Kotlin data class | +| `models/SMSPayload.java` | Medium | Convert to Kotlin data class | + +### Receivers +| File | Priority | Notes | +|---|---|---| +| `receivers/SMSBroadcastReceiver.java` | Medium | Receives incoming SMS, enqueues worker | +| `receivers/SMSStatusReceiver.java` | Medium | Tracks sent/delivered status | +| `receivers/BootCompletedReceiver.java` | Low | Reschedules heartbeat on boot | + +### Services +| File | Priority | Notes | +|---|---|---| +| `services/GatewayApiService.java` | High | Java Retrofit interface — delete after legacy UI removed | +| `services/StickyNotificationService.java` | Medium | Foreground service for persistent notification | +| `services/FCMService.java` | Medium | Firebase push — triggers SMS send | + +### Workers +| File | Priority | Notes | +|---|---|---| +| `workers/HeartbeatWorker.java` | Medium | Kotlin coroutines-based rewrite would simplify | +| `workers/SMSReceivedWorker.java` | Medium | Forwards received SMS to API | +| `workers/SMSStatusUpdateWorker.java` | Medium | Polls/updates SMS status | +| `workers/SmsSendWorker.java` | High | Core send logic — most complex worker | + +--- + +## Migration Roadmap + +### Phase 3 — SMS Filter Screen *(next up)* +Port `SMSFilterActivity.java` to Compose and integrate it as a nested route inside the Settings tab instead of a separate Activity. This is the only remaining Java screen reachable from the new UI. + +**Files:** +- Create `ui/settings/SMSFilterScreen.kt` + `SMSFilterViewModel.kt` +- Reuse filter logic from `SMSFilterHelper.java` (call it from Kotlin until Phase 5) +- Add `"filters"` composable route to `NewMainActivity.kt` NavHost +- Update Settings "Configure Filters" row to navigate to route instead of `startActivity` + +--- + +### Phase 4 — Data Layer +Convert the Room database and DTOs to idiomatic Kotlin. This unlocks suspend-based DAOs and removes the awkward Java setter pattern in DTOs. + +**Files:** +- `SMS.java` → `Sms.kt` (data class + `@Entity`) +- `SMSDao.java` → `SmsDao.kt` (suspend functions) +- `AppDatabase.java` → `AppDatabase.kt` +- `RegisterDeviceInputDTO.java` → Kotlin data class (remove setter-based pattern) +- `RegisterDeviceResponseDTO.java` → Kotlin (replace `Map` with proper fields) +- Remaining Java DTOs → Kotlin data classes + +--- + +### Phase 5 — Helpers & Utilities +Convert the shared infrastructure that every component depends on. Do `SharedPreferenceHelper` first since it's the most impactful. + +**Order:** +1. `SharedPreferenceHelper.java` → Kotlin object with inline extension helpers +2. `SMSFilterHelper.java` + `SMSHelper.java` → Kotlin (unblocks Phase 3 cleanup) +3. `TextBeeUtils.java` → Kotlin (split into focused util files) +4. `HeartbeatHelper.java` + `HeartbeatManager.java` → Kotlin +5. `models/SMSFilterRule.kt` + `models/SMSPayload.kt` → Kotlin data classes +6. `VersionTracker.java` → Kotlin + +--- + +### Phase 6 — Background Services & Receivers +Rewrite workers and receivers in Kotlin. Workers benefit most from coroutines — the current Java workers use callbacks and `CountDownLatch` workarounds. + +**Order:** +1. `BootCompletedReceiver.java` → Kotlin (trivial, good warmup) +2. `SMSBroadcastReceiver.java` → Kotlin +3. `SMSStatusReceiver.java` → Kotlin +4. `HeartbeatWorker.java` → Kotlin coroutine worker +5. `SmsSendWorker.java` → Kotlin coroutine worker (most complex) +6. `SMSReceivedWorker.java` → Kotlin +7. `SMSStatusUpdateWorker.java` → Kotlin +8. `StickyNotificationService.java` → Kotlin +9. `FCMService.java` → Kotlin + +--- + +### Phase 7 — Legacy UI Removal +Once Compose UI is stable and rolled out to all users, remove the legacy UI entirely. + +**Steps:** +1. Remove the "Switch to Legacy UI" row from `SettingsScreen.kt` +2. Remove `USE_NEW_UI_KEY` logic from `SplashActivity.kt` (always route to new UI) +3. Delete `activities/MainActivity.java` and its XML layouts +4. Delete `services/GatewayApiService.java` (Java Retrofit interface) +5. Delete `ApiManager.java` +6. Remove "Try New UI" button from any remaining legacy layout XML +7. Clean up `AppConstants.java` — remove `SHARED_PREFS_USE_NEW_UI_KEY` +8. Convert `SMSGatewayApplication.java` → Kotlin + +--- + +## Key Constraints to Keep in Mind + +- **`dynamicColor = false`** in `Theme.kt` — Material You overrides the brand orange on Android 12+; must stay false +- **`primaryContainer` avoided** in TopAppBar/nav — causes orange-on-orange in dark mode; use `surface` for bars, `surfaceVariant` for nav indicator +- **Java/Kotlin interop** — Java files call Kotlin objects fine; be careful with `companion object` vs `object` when called from Java +- **WorkManager workers** — must remain `ListenableWorker` subclass; Kotlin workers use `CoroutineWorker` which is the idiomatic replacement for `Worker` +- **`RegisterDeviceInputDTO`** — currently setter-based Java; Kotlin callers use `.apply { setEnabled(true) }` until Phase 4 replaces it with a proper data class diff --git a/android/app/build.gradle b/android/app/build.gradle index aaf6f87..b12eefd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,14 +1,16 @@ plugins { id 'com.android.application' + id 'com.google.gms.google-services' id 'com.google.firebase.crashlytics' + id 'org.jetbrains.kotlin.android' } android { - compileSdk 32 + compileSdk 34 defaultConfig { minSdk 24 - targetSdk 32 + targetSdk 34 versionCode 17 versionName "2.7.1" @@ -68,6 +70,18 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + + kotlinOptions { + jvmTarget = '1.8' + } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion '1.4.8' + } } dependencies { @@ -88,13 +102,29 @@ dependencies { implementation 'com.google.code.gson:gson:2.9.0' implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' - implementation 'com.journeyapps:zxing-android-embedded:4.1.0' + implementation 'com.journeyapps:zxing-android-embedded:4.3.0' implementation 'androidx.work:work-runtime:2.7.1' + // Jetpack Compose + implementation platform('androidx.compose:compose-bom:2023.08.00') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material:material-icons-extended' + debugImplementation 'androidx.compose.ui:ui-tooling' + + // Compose integration + implementation 'androidx.activity:activity-compose:1.7.2' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1' + implementation 'androidx.navigation:navigation-compose:2.7.2' + + // Coroutines + Lifecycle + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1' + // def room_version = "2.4.2" // implementation "androidx.room:room-runtime:$room_version" // annotationProcessor "androidx.room:room-compiler:$room_version" } -apply plugin: 'com.google.gms.google-services' \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b9864ee..601b57d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ + + android:exported="false" + android:foregroundServiceType="remoteMessaging"> - + + + { + SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_USE_NEW_UI_KEY, true); + Intent intent = new Intent(MainActivity.this, com.vernu.sms.ui.splash.SplashActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + }); + // SMS Send Delay setting: save 3 seconds after user stops typing int currentDelay = SharedPreferenceHelper.getSharedPreferenceInt( mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS); diff --git a/android/app/src/main/java/com/vernu/sms/dtos/GatewayStatsResponse.kt b/android/app/src/main/java/com/vernu/sms/dtos/GatewayStatsResponse.kt new file mode 100644 index 0000000..30ab25f --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/GatewayStatsResponse.kt @@ -0,0 +1,14 @@ +package com.vernu.sms.dtos + +import com.google.gson.annotations.SerializedName + +data class GatewayStatsResponse( + @SerializedName("data") val data: GatewayStatsData? = null +) + +data class GatewayStatsData( + @SerializedName("totalSentSMSCount") val totalSentSMSCount: Int? = null, + @SerializedName("totalReceivedSMSCount") val totalReceivedSMSCount: Int? = null, + @SerializedName("totalDeviceCount") val totalDeviceCount: Int? = null, + @SerializedName("totalApiKeyCount") val totalApiKeyCount: Int? = null +) diff --git a/android/app/src/main/java/com/vernu/sms/dtos/MessagesResponse.kt b/android/app/src/main/java/com/vernu/sms/dtos/MessagesResponse.kt new file mode 100644 index 0000000..49c8c8d --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/MessagesResponse.kt @@ -0,0 +1,38 @@ +package com.vernu.sms.dtos + +import com.google.gson.annotations.SerializedName + +data class MessagesResponse( + @SerializedName("data") val data: List? = null, + @SerializedName("meta") val meta: PaginationMeta? = null +) + +data class SmsMessage( + @SerializedName("_id") val id: String? = null, + @SerializedName("message") val message: String? = null, + @SerializedName("sender") val sender: String? = null, + @SerializedName("recipient") val recipient: String? = null, + @SerializedName("recipients") val recipients: List? = null, + @SerializedName("requestedAt") val requestedAt: String? = null, + @SerializedName("receivedAt") val receivedAt: String? = null, + @SerializedName("createdAt") val createdAt: String? = null, + @SerializedName("status") val status: String? = null, + @SerializedName("errorCode") val errorCode: String? = null, + @SerializedName("errorMessage") val errorMessage: String? = null +) { + val isReceived: Boolean get() = sender != null + val counterparty: String get() = if (isReceived) sender ?: "Unknown" + else recipient ?: recipients?.firstOrNull() ?: "Unknown" +} + +data class PaginationMeta( + @SerializedName("page") val page: Int? = null, + @SerializedName("limit") val limit: Int? = null, + @SerializedName("total") val total: Int? = null, + @SerializedName("totalPages") val totalPages: Int? = null +) + +data class SendSmsRequest( + @SerializedName("message") val message: String, + @SerializedName("recipients") val recipients: List +) diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SubscriptionResponse.kt b/android/app/src/main/java/com/vernu/sms/dtos/SubscriptionResponse.kt new file mode 100644 index 0000000..bedb0e9 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/SubscriptionResponse.kt @@ -0,0 +1,27 @@ +package com.vernu.sms.dtos + +import com.google.gson.annotations.SerializedName + +data class SubscriptionResponse( + @SerializedName("plan") val plan: SubscriptionPlan? = null, + @SerializedName("currentPeriodStart") val currentPeriodStart: String? = null, + @SerializedName("currentPeriodEnd") val currentPeriodEnd: String? = null, + @SerializedName("isActive") val isActive: Boolean? = null, + @SerializedName("usage") val usage: SubscriptionUsage? = null +) + +data class SubscriptionPlan( + @SerializedName("name") val name: String? = null, + @SerializedName("displayName") val displayName: String? = null +) + +data class SubscriptionUsage( + @SerializedName("processedSmsToday") val processedSmsToday: Int? = null, + @SerializedName("processedSmsLastMonth") val processedSmsLastMonth: Int? = null, + @SerializedName("dailyLimit") val dailyLimit: Int? = null, + @SerializedName("monthlyLimit") val monthlyLimit: Int? = null, + @SerializedName("dailyRemaining") val dailyRemaining: Int? = null, + @SerializedName("monthlyRemaining") val monthlyRemaining: Int? = null, + @SerializedName("dailyUsagePercentage") val dailyUsagePercentage: Int? = null, + @SerializedName("monthlyUsagePercentage") val monthlyUsagePercentage: Int? = null +) diff --git a/android/app/src/main/java/com/vernu/sms/dtos/UserProfileResponse.kt b/android/app/src/main/java/com/vernu/sms/dtos/UserProfileResponse.kt new file mode 100644 index 0000000..31b7d0d --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/UserProfileResponse.kt @@ -0,0 +1,12 @@ +package com.vernu.sms.dtos + +import com.google.gson.annotations.SerializedName + +data class UserProfileWrapper( + @SerializedName("data") val data: UserProfile? = null +) + +data class UserProfile( + @SerializedName("name") val name: String? = null, + @SerializedName("email") val email: String? = null +) diff --git a/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.java b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.java index cf27eef..ec5be03 100644 --- a/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.java +++ b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.java @@ -172,6 +172,11 @@ public class HeartbeatHelper { Log.d(TAG, "Synced device name from heartbeat: " + responseBody.name); } + SharedPreferenceHelper.setSharedPreferenceString( + context, + AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY, + String.valueOf(System.currentTimeMillis()) + ); Log.d(TAG, "Heartbeat sent successfully"); return true; } else { diff --git a/android/app/src/main/java/com/vernu/sms/services/GatewayApiServiceKt.kt b/android/app/src/main/java/com/vernu/sms/services/GatewayApiServiceKt.kt new file mode 100644 index 0000000..f529150 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/services/GatewayApiServiceKt.kt @@ -0,0 +1,58 @@ +package com.vernu.sms.services + +import com.vernu.sms.dtos.GatewayStatsResponse +import com.vernu.sms.dtos.MessagesResponse +import com.vernu.sms.dtos.RegisterDeviceInputDTO +import com.vernu.sms.dtos.RegisterDeviceResponseDTO +import com.vernu.sms.dtos.SendSmsRequest +import com.vernu.sms.dtos.SubscriptionResponse +import com.vernu.sms.dtos.UserProfileWrapper +import retrofit2.Response +import retrofit2.http.* + +interface GatewayApiServiceKt { + + @GET("auth/who-am-i") + suspend fun whoAmI( + @Header("x-api-key") apiKey: String + ): Response + + @GET("gateway/stats") + suspend fun getStats( + @Header("x-api-key") apiKey: String + ): Response + + @POST("gateway/devices") + suspend fun registerDevice( + @Header("x-api-key") apiKey: String, + @Body body: RegisterDeviceInputDTO + ): Response + + @GET("billing/current-subscription") + suspend fun getCurrentSubscription( + @Header("x-api-key") apiKey: String + ): Response + + @PATCH("gateway/devices/{deviceId}") + suspend fun updateDevice( + @Path("deviceId") deviceId: String, + @Header("x-api-key") apiKey: String, + @Body body: RegisterDeviceInputDTO + ): Response + + @GET("gateway/devices/{deviceId}/messages") + suspend fun getMessages( + @Path("deviceId") deviceId: String, + @Header("x-api-key") apiKey: String, + @Query("page") page: Int, + @Query("limit") limit: Int, + @Query("type") type: String + ): Response + + @POST("gateway/devices/{deviceId}/send-sms") + suspend fun sendSms( + @Path("deviceId") deviceId: String, + @Header("x-api-key") apiKey: String, + @Body body: SendSmsRequest + ): Response +} diff --git a/android/app/src/main/java/com/vernu/sms/services/StickyNotificationService.java b/android/app/src/main/java/com/vernu/sms/services/StickyNotificationService.java index c0659eb..0785f11 100644 --- a/android/app/src/main/java/com/vernu/sms/services/StickyNotificationService.java +++ b/android/app/src/main/java/com/vernu/sms/services/StickyNotificationService.java @@ -44,7 +44,11 @@ public class StickyNotificationService extends Service { if (stickyNotificationEnabled) { Notification notification = createNotification(); try { - startForeground(1, notification); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + startForeground(1, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING); + } else { + startForeground(1, notification); + } Log.i(TAG, "Started foreground service with sticky notification"); } catch (ForegroundServiceStartNotAllowedException e) { Log.w(TAG, "Cannot start foreground from background, stopping service: " + e.getMessage()); diff --git a/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardScreen.kt new file mode 100644 index 0000000..0412962 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardScreen.kt @@ -0,0 +1,499 @@ +package com.vernu.sms.ui.dashboard + +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MenuBook +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.SupportAgent +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vernu.sms.R +import com.vernu.sms.dtos.SubscriptionResponse +import com.vernu.sms.dtos.UserProfile +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardScreen( + viewModel: DashboardViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(id = R.drawable.ic_app_logo), + contentDescription = null, + modifier = Modifier.size(28.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "textbee.dev", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + }, + actions = { + IconButton(onClick = { viewModel.refresh() }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(4.dp)) + UserGreetingHeader(userProfile = state.userProfile) + DeviceStatusCard(state = state, onToggle = { viewModel.toggleGateway(it) }) + StatsSection(state = state) + SubscriptionCard( + subscription = state.subscription, + isLoading = state.isSubscriptionLoading, + unavailable = state.subscriptionUnavailable + ) + QuickActionsSection() + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Composable +private fun UserGreetingHeader(userProfile: UserProfile?) { + val displayName = userProfile?.name?.takeIf { it.isNotBlank() } + ?: userProfile?.email?.takeIf { it.isNotBlank() } + ?: return + + Text( + text = "Hello, $displayName", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) +} + +@Composable +private fun DeviceStatusCard( + state: DashboardState, + onToggle: (Boolean) -> Unit +) { + val statusColor = if (state.isGatewayEnabled) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + val statusText = if (state.isGatewayEnabled) "Enabled" else "Disabled" + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val hardwareModel = "${Build.BRAND.replaceFirstChar { it.uppercase() }} ${Build.MODEL}" + val customName = state.deviceName.trim() + val displayName = customName.ifEmpty { hardwareModel } + val showModel = customName.isNotEmpty() && + customName.lowercase() != hardwareModel.lowercase() + + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { + Text( + text = displayName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + if (showModel) { + Text( + text = hardwareModel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (state.deviceId.isNotEmpty()) { + Text( + text = state.deviceId, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + } + Switch( + checked = state.isGatewayEnabled, + onCheckedChange = onToggle, + enabled = !state.isTogglingGateway + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Surface( + color = statusColor.copy(alpha = 0.15f), + shape = MaterialTheme.shapes.small + ) { + Text( + text = statusText, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = statusColor, + fontWeight = FontWeight.SemiBold + ) + } + } + } +} + +@Composable +private fun SubscriptionCard( + subscription: SubscriptionResponse?, + isLoading: Boolean, + unavailable: Boolean +) { + val context = LocalContext.current + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + when { + isLoading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + unavailable || subscription == null -> { + // silently hide rather than show an error card + } + else -> { + Column(modifier = Modifier.padding(20.dp)) { + val planName = subscription.plan?.name?.uppercase() ?: "FREE" + val isFree = subscription.plan?.name?.lowercase() == "free" || + subscription.plan?.name == null + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Subscription", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(4.dp)) + Surface( + color = if (isFree) MaterialTheme.colorScheme.surfaceVariant + else MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), + shape = MaterialTheme.shapes.small + ) { + Text( + text = planName, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = if (isFree) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.primary + ) + } + } + if (isFree) { + OutlinedButton( + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/pricing")) + ) + } + ) { + Text("Upgrade") + } + } else { + TextButton( + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard/account")) + ) + } + ) { + Text("Manage") + } + } + } + + if (isFree) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Unlock higher limits, more devices & priority support", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + subscription.usage?.let { usage -> + Spacer(modifier = Modifier.height(16.dp)) + UsageRow( + label = "Today", + used = usage.processedSmsToday, + limit = usage.dailyLimit, + pct = usage.dailyUsagePercentage + ) + Spacer(modifier = Modifier.height(10.dp)) + UsageRow( + label = "This month", + used = usage.processedSmsLastMonth, + limit = usage.monthlyLimit, + pct = usage.monthlyUsagePercentage + ) + } + + subscription.currentPeriodEnd?.let { dateStr -> + formatDate(dateStr)?.let { formatted -> + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Renews $formatted", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } +} + +@Composable +private fun UsageRow(label: String, used: Int?, limit: Int?, pct: Int?) { + val isUnlimited = limit == -1 + val progress = if (isUnlimited || limit == null || limit == 0) 0f + else (pct ?: 0).coerceIn(0, 100) / 100f + + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = label, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = if (isUnlimited) "${used ?: 0} / ∞" + else "${used ?: 0} / ${limit ?: "—"}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium + ) + } + Spacer(modifier = Modifier.height(4.dp)) + if (!isUnlimited && limit != null && limit > 0) { + LinearProgressIndicator( + progress = progress, + modifier = Modifier + .fillMaxWidth() + .height(6.dp), + color = if (progress > 0.8f) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } + } +} + +@Composable +private fun StatsSection(state: DashboardState) { + Text( + text = "All-Time Stats", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + when { + state.isStatsLoading -> { + Card(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + state.statsUnavailable -> { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Text( + text = "Stats unavailable", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + else -> { + val stats = state.stats + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatCard(label = "Total Sent", value = stats?.totalSentSMS?.toString() ?: "—", modifier = Modifier.weight(1f)) + StatCard(label = "Total Received", value = stats?.totalReceivedSMS?.toString() ?: "—", modifier = Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun StatCard(label: String, value: String, modifier: Modifier = Modifier) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = value, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun QuickActionsSection() { + val context = LocalContext.current + + Text( + text = "Quick Actions", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard")) + ) + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.OpenInBrowser, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Dashboard") + } + OutlinedButton( + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/docs")) + ) + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.MenuBook, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Explore Docs") + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard/account/get-support")) + ) + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.SupportAgent, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Get Support") + } + OutlinedButton( + onClick = { + val shareText = "i've been using textbee.dev to send SMS via API from my own phone, " + + "no Twilio or paid services needed. works great for automations, alerts, " + + "notifications, or anything that needs programmatic SMS. open source and free to start\n\n" + + "https://textbee.dev" + context.startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, shareText) + }, + "Share TextBee" + ) + ) + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Share") + } + } +} + +private fun formatDate(isoDate: String): String? { + return try { + val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()) + sdf.timeZone = TimeZone.getTimeZone("UTC") + val date = sdf.parse(isoDate.take(19)) ?: return null + SimpleDateFormat("MMM d, yyyy", Locale.getDefault()).format(date) + } catch (e: Exception) { + null + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardViewModel.kt b/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..d9e05a6 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardViewModel.kt @@ -0,0 +1,209 @@ +package com.vernu.sms.ui.dashboard + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.vernu.sms.ApiManagerKt +import com.vernu.sms.AppConstants +import com.vernu.sms.TextBeeUtils +import com.vernu.sms.dtos.RegisterDeviceInputDTO +import com.vernu.sms.dtos.SubscriptionResponse +import com.vernu.sms.dtos.UserProfile +import com.vernu.sms.helpers.HeartbeatManager +import com.vernu.sms.helpers.SharedPreferenceHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class GatewayStats( + val totalSentSMS: Int?, + val totalReceivedSMS: Int?, + val totalDevices: Int?, + val totalApiKeys: Int? +) + +data class DashboardState( + val deviceName: String = "", + val deviceId: String = "", + val isGatewayEnabled: Boolean = false, + val lastHeartbeatMs: Long? = null, + val stats: GatewayStats? = null, + val isStatsLoading: Boolean = true, + val statsUnavailable: Boolean = false, + val isTogglingGateway: Boolean = false, + val subscription: SubscriptionResponse? = null, + val isSubscriptionLoading: Boolean = true, + val subscriptionUnavailable: Boolean = false, + val userProfile: UserProfile? = null +) + +class DashboardViewModel(app: Application) : AndroidViewModel(app) { + + private val context get() = getApplication().applicationContext + + private val _state = MutableStateFlow(DashboardState()) + val state: StateFlow = _state.asStateFlow() + + init { + loadLocalState() + fetchStats() + fetchSubscription() + fetchUserProfile() + } + + fun refresh() { + loadLocalState() + fetchStats() + fetchSubscription() + fetchUserProfile() + } + + private fun loadLocalState() { + val deviceName = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, "" + ) ?: "" + val deviceId = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "" + ) ?: "" + val isEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false + ) + val lastHeartbeatStr = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY, "" + ) + val lastHeartbeatMs = lastHeartbeatStr?.toLongOrNull() + + _state.update { + it.copy( + deviceName = deviceName, + deviceId = deviceId, + isGatewayEnabled = isEnabled, + lastHeartbeatMs = lastHeartbeatMs + ) + } + } + + private fun fetchStats() { + val apiKey = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_API_KEY_KEY, "" + ) ?: "" + if (apiKey.isEmpty()) { + _state.update { it.copy(isStatsLoading = false, statsUnavailable = true) } + return + } + + viewModelScope.launch { + _state.update { it.copy(isStatsLoading = true, statsUnavailable = false) } + try { + val response = ApiManagerKt.getApiService().getStats(apiKey) + if (response.isSuccessful) { + val data = response.body()?.data + _state.update { + it.copy( + isStatsLoading = false, + statsUnavailable = data == null, + stats = data?.let { d -> + GatewayStats( + totalSentSMS = d.totalSentSMSCount, + totalReceivedSMS = d.totalReceivedSMSCount, + totalDevices = d.totalDeviceCount, + totalApiKeys = d.totalApiKeyCount + ) + } + ) + } + } else { + _state.update { it.copy(isStatsLoading = false, statsUnavailable = true) } + } + } catch (e: Exception) { + _state.update { it.copy(isStatsLoading = false, statsUnavailable = true) } + TextBeeUtils.logException(e, "Dashboard stats fetch failed") + } + } + } + + private fun fetchUserProfile() { + val apiKey = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_API_KEY_KEY, "" + ) ?: "" + if (apiKey.isEmpty()) return + viewModelScope.launch { + try { + val response = ApiManagerKt.getApiService().whoAmI(apiKey) + if (response.isSuccessful) { + _state.update { it.copy(userProfile = response.body()?.data) } + } + } catch (e: Exception) { + TextBeeUtils.logException(e, "User profile fetch failed") + } + } + } + + private fun fetchSubscription() { + val apiKey = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_API_KEY_KEY, "" + ) ?: "" + if (apiKey.isEmpty()) { + _state.update { it.copy(isSubscriptionLoading = false, subscriptionUnavailable = true) } + return + } + viewModelScope.launch { + _state.update { it.copy(isSubscriptionLoading = true, subscriptionUnavailable = false) } + try { + val response = ApiManagerKt.getApiService().getCurrentSubscription(apiKey) + if (response.isSuccessful) { + _state.update { it.copy(isSubscriptionLoading = false, subscription = response.body()) } + } else { + _state.update { it.copy(isSubscriptionLoading = false, subscriptionUnavailable = true) } + } + } catch (e: Exception) { + _state.update { it.copy(isSubscriptionLoading = false, subscriptionUnavailable = true) } + TextBeeUtils.logException(e, "Subscription fetch failed") + } + } + } + + fun toggleGateway(enabled: Boolean) { + val deviceId = _state.value.deviceId + val apiKey = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_API_KEY_KEY, "" + ) ?: "" + if (deviceId.isEmpty() || apiKey.isEmpty()) return + + viewModelScope.launch { + _state.update { it.copy(isTogglingGateway = true) } + try { + val input = RegisterDeviceInputDTO().apply { setEnabled(enabled) } + val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input) + if (response.isSuccessful) { + SharedPreferenceHelper.setSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, enabled + ) + _state.update { it.copy(isGatewayEnabled = enabled) } + try { + if (enabled) { + if (SharedPreferenceHelper.getSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false + ) + ) { + TextBeeUtils.startStickyNotificationService(context) + } + HeartbeatManager.scheduleHeartbeat(context) + } else { + TextBeeUtils.stopStickyNotificationService(context) + HeartbeatManager.cancelHeartbeat(context) + } + } catch (e: Exception) { + TextBeeUtils.logException(e, "Gateway service toggle failed") + } + } + } catch (e: Exception) { + TextBeeUtils.logException(e, "Gateway toggle failed") + } finally { + _state.update { it.copy(isTogglingGateway = false) } + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/main/NewMainActivity.kt b/android/app/src/main/java/com/vernu/sms/ui/main/NewMainActivity.kt new file mode 100644 index 0000000..b7f3fd9 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/main/NewMainActivity.kt @@ -0,0 +1,172 @@ +package com.vernu.sms.ui.main + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Message +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.vernu.sms.AppConstants +import com.vernu.sms.activities.MainActivity +import com.vernu.sms.activities.SMSFilterActivity +import com.vernu.sms.helpers.HeartbeatManager +import com.vernu.sms.helpers.SharedPreferenceHelper +import com.vernu.sms.ui.dashboard.DashboardScreen +import com.vernu.sms.ui.messages.ComposeScreen +import com.vernu.sms.ui.messages.MessagesScreen +import com.vernu.sms.ui.onboarding.OnboardingActivity +import com.vernu.sms.ui.settings.SettingsScreen +import com.vernu.sms.ui.theme.TextBeeTheme + +enum class MainDestination(val label: String, val icon: ImageVector) { + DASHBOARD("Dashboard", Icons.Default.Dashboard), + MESSAGES("Messages", Icons.Default.Message), + SETTINGS("Settings", Icons.Default.Settings) +} + +class NewMainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + TextBeeTheme { + val navController = rememberNavController() + MainScaffold( + navController = navController, + onSwitchToLegacy = { + SharedPreferenceHelper.setSharedPreferenceBoolean( + this, AppConstants.SHARED_PREFS_USE_NEW_UI_KEY, false + ) + startActivity( + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + ) + }, + onNavigateToFilters = { + startActivity(Intent(this, SMSFilterActivity::class.java)) + }, + onDisconnect = { + listOf( + AppConstants.SHARED_PREFS_DEVICE_ID_KEY, + AppConstants.SHARED_PREFS_API_KEY_KEY, + AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, + AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, + AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY + ).forEach { key -> + SharedPreferenceHelper.clearSharedPreference(this, key) + } + HeartbeatManager.cancelHeartbeat(this) + startActivity( + Intent(this, OnboardingActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + ) + } + ) + } + } + } +} + +@Composable +private fun MainScaffold( + navController: NavHostController, + onSwitchToLegacy: () -> Unit, + onNavigateToFilters: () -> Unit, + onDisconnect: () -> Unit +) { + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route + val showBottomBar = currentRoute != "compose" + + Scaffold( + bottomBar = { + if (showBottomBar) { + NavigationBar( + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 4.dp + ) { + MainDestination.values().forEach { dest -> + val selected = currentRoute == dest.name + NavigationBarItem( + selected = selected, + onClick = { + navController.navigate(dest.name) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + Icon( + dest.icon, + contentDescription = dest.label, + modifier = Modifier.size(if (selected) 26.dp else 22.dp) + ) + }, + label = { + Text( + dest.label, + style = MaterialTheme.typography.labelSmall, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal + ) + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + indicatorColor = MaterialTheme.colorScheme.surfaceVariant, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } + } + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = MainDestination.DASHBOARD.name, + modifier = Modifier.padding(innerPadding) + ) { + composable(MainDestination.DASHBOARD.name) { + DashboardScreen() + } + composable(MainDestination.MESSAGES.name) { + MessagesScreen( + onNavigateToCompose = { navController.navigate("compose") } + ) + } + composable(MainDestination.SETTINGS.name) { + SettingsScreen( + onSwitchToLegacy = onSwitchToLegacy, + onNavigateToFilters = onNavigateToFilters, + onDisconnect = onDisconnect + ) + } + composable("compose") { + ComposeScreen( + onNavigateBack = { navController.popBackStack() } + ) + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/messages/ComposeScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/messages/ComposeScreen.kt new file mode 100644 index 0000000..97fcbc7 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/messages/ComposeScreen.kt @@ -0,0 +1,178 @@ +package com.vernu.sms.ui.messages + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Send +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun ComposeScreen( + viewModel: ComposeViewModel = viewModel(), + onNavigateBack: () -> Unit +) { + val state by viewModel.state.collectAsState() + var recipientInput by remember { mutableStateOf("") } + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(state.sendSuccess) { + if (state.sendSuccess) { + snackbarHostState.showSnackbar("Message sent successfully!") + viewModel.clearSuccess() + } + } + + LaunchedEffect(state.sendError) { + state.sendError?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearError() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text("New Message", fontWeight = FontWeight.SemiBold) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Recipients", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = recipientInput, + onValueChange = { recipientInput = it }, + modifier = Modifier.weight(1f), + label = { Text("Phone number") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + singleLine = true + ) + Button( + onClick = { + val trimmed = recipientInput.trim() + if (trimmed.isNotEmpty() && !state.recipients.contains(trimmed)) { + viewModel.addRecipient(trimmed) + recipientInput = "" + } + }, + enabled = recipientInput.trim().isNotEmpty() + ) { + Text("Add") + } + } + + if (state.recipients.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + state.recipients.forEach { number -> + InputChip( + selected = false, + onClick = {}, + label = { Text(number) }, + trailingIcon = { + IconButton( + onClick = { viewModel.removeRecipient(number) }, + modifier = Modifier.size(18.dp) + ) { + Icon( + Icons.Default.Close, + contentDescription = "Remove", + modifier = Modifier.size(14.dp) + ) + } + } + ) + } + } + } + + Divider() + + Text( + text = "Message", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + + OutlinedTextField( + value = state.message, + onValueChange = { viewModel.setMessage(it) }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp), + label = { Text("Type your message") }, + maxLines = 8, + supportingText = { + Text("${state.message.length} characters") + } + ) + + Button( + onClick = { viewModel.sendSms() }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isSending && + state.recipients.isNotEmpty() && + state.message.isNotEmpty() + ) { + if (state.isSending) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Sending...") + } else { + Icon( + Icons.Default.Send, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Send Message") + } + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/messages/ComposeViewModel.kt b/android/app/src/main/java/com/vernu/sms/ui/messages/ComposeViewModel.kt new file mode 100644 index 0000000..cecc25b --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/messages/ComposeViewModel.kt @@ -0,0 +1,101 @@ +package com.vernu.sms.ui.messages + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.vernu.sms.ApiManagerKt +import com.vernu.sms.AppConstants +import com.vernu.sms.dtos.SendSmsRequest +import com.vernu.sms.helpers.SharedPreferenceHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.json.JSONObject +import retrofit2.Response + +data class ComposeState( + val recipients: List = emptyList(), + val message: String = "", + val isSending: Boolean = false, + val sendError: String? = null, + val sendSuccess: Boolean = false +) + +class ComposeViewModel(app: Application) : AndroidViewModel(app) { + + private val context get() = getApplication().applicationContext + + private val _state = MutableStateFlow(ComposeState()) + val state: StateFlow = _state.asStateFlow() + + fun addRecipient(number: String) { + _state.update { it.copy(recipients = it.recipients + number) } + } + + fun removeRecipient(number: String) { + _state.update { it.copy(recipients = it.recipients - number) } + } + + fun setMessage(msg: String) { + _state.update { it.copy(message = msg) } + } + + fun clearError() { + _state.update { it.copy(sendError = null) } + } + + fun clearSuccess() { + _state.update { it.copy(sendSuccess = false, message = "") } + } + + private fun extractErrorMessage(response: Response<*>): String { + return try { + val body = response.errorBody()?.string() + if (!body.isNullOrBlank()) { + val json = JSONObject(body) + json.optString("message").takeIf { it.isNotBlank() } + ?: json.optString("error").takeIf { it.isNotBlank() } + ?: "Failed to send (${response.code()})" + } else { + "Failed to send (${response.code()})" + } + } catch (e: Exception) { + "Failed to send (${response.code()})" + } + } + + fun sendSms() { + val apiKey = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_API_KEY_KEY, "" + ) ?: "" + val deviceId = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "" + ) ?: "" + if (apiKey.isEmpty() || deviceId.isEmpty()) return + + val s = _state.value + if (s.recipients.isEmpty() || s.message.isEmpty()) return + + viewModelScope.launch { + _state.update { it.copy(isSending = true, sendError = null) } + try { + val response = ApiManagerKt.getApiService().sendSms( + deviceId, apiKey, SendSmsRequest(s.message, s.recipients) + ) + if (response.isSuccessful) { + _state.update { it.copy(isSending = false, sendSuccess = true) } + } else { + _state.update { + it.copy(isSending = false, sendError = extractErrorMessage(response)) + } + } + } catch (e: Exception) { + _state.update { + it.copy(isSending = false, sendError = "Network error. Please try again.") + } + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/messages/MessagesScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/messages/MessagesScreen.kt new file mode 100644 index 0000000..7e992d3 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/messages/MessagesScreen.kt @@ -0,0 +1,360 @@ +package com.vernu.sms.ui.messages + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Create +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vernu.sms.dtos.SmsMessage +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MessagesScreen( + viewModel: MessagesViewModel = viewModel(), + onNavigateToCompose: () -> Unit = {} +) { + val state by viewModel.state.collectAsState() + var selectedMessage by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Forum, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Messages", fontWeight = FontWeight.SemiBold) + } + }, + actions = { + IconButton(onClick = { viewModel.refresh() }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = onNavigateToCompose, + containerColor = MaterialTheme.colorScheme.primary + ) { + Icon( + Icons.Default.Create, + contentDescription = "Compose", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + listOf("all" to "All", "sent" to "Sent", "received" to "Received").forEach { (value, label) -> + FilterChip( + selected = state.filter == value, + onClick = { viewModel.setFilter(value) }, + label = { Text(label) } + ) + } + } + + when { + state.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + state.error != null -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = state.error ?: "Error", + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { viewModel.refresh() }) { + Text("Retry") + } + } + } + } + state.messages.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.Forum, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "No messages yet", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.messages) { message -> + MessageItem( + message = message, + onClick = { selectedMessage = message } + ) + } + + if (state.currentPage < state.totalPages) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + if (state.isLoadingMore) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + OutlinedButton(onClick = { viewModel.loadMore() }) { + Text("Load more") + } + } + } + } + } + + item { Spacer(modifier = Modifier.height(80.dp)) } + } + } + } + } + } + + selectedMessage?.let { msg -> + MessageDetailDialog(message = msg, onDismiss = { selectedMessage = null }) + } +} + +@Composable +private fun MessageItem(message: SmsMessage, onClick: () -> Unit) { + val accentColor = if (message.isReceived) Color(0xFF4CAF50) + else MaterialTheme.colorScheme.primary + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + Box( + modifier = Modifier + .width(4.dp) + .fillMaxHeight() + .background(accentColor) + ) + Column( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (message.isReceived) Icons.Default.ArrowDownward + else Icons.Default.ArrowUpward, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = accentColor + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = message.counterparty, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } + Text( + text = formatRelativeTime(message.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = message.message ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!message.isReceived && message.status != null) { + Spacer(modifier = Modifier.height(4.dp)) + StatusBadge(status = message.status) + } + } + } + } +} + +@Composable +private fun StatusBadge(status: String) { + val (color, label) = when (status.lowercase()) { + "delivered" -> MaterialTheme.colorScheme.tertiary to "Delivered" + "sent" -> MaterialTheme.colorScheme.primary to "Sent" + "failed" -> MaterialTheme.colorScheme.error to "Failed" + else -> MaterialTheme.colorScheme.onSurfaceVariant to "Pending" + } + Surface( + color = color.copy(alpha = 0.15f), + shape = MaterialTheme.shapes.extraSmall + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = color, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Composable +private fun MessageDetailDialog(message: SmsMessage, onDismiss: () -> Unit) { + val accentColor = if (message.isReceived) Color(0xFF4CAF50) + else MaterialTheme.colorScheme.primary + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (message.isReceived) Icons.Default.ArrowDownward + else Icons.Default.ArrowUpward, + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (message.isReceived) "Received from" else "Sent to", + style = MaterialTheme.typography.titleMedium + ) + } + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = message.counterparty, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = accentColor + ) + if (!message.isReceived && message.status != null) { + StatusBadge(status = message.status) + } + Divider() + Text( + text = message.message ?: "", + style = MaterialTheme.typography.bodyMedium + ) + val timestamp = if (message.isReceived) message.receivedAt else message.requestedAt + timestamp?.let { + Text( + text = formatFullDate(it), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Close") } + } + ) +} + +private fun formatRelativeTime(isoDate: String?): String { + if (isoDate == null) return "" + return try { + val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()) + sdf.timeZone = TimeZone.getTimeZone("UTC") + val date = sdf.parse(isoDate.take(19)) ?: return "" + val diffMs = System.currentTimeMillis() - date.time + when { + diffMs < 60_000 -> "Just now" + diffMs < 3_600_000 -> "${diffMs / 60_000}m ago" + diffMs < 86_400_000 -> "${diffMs / 3_600_000}h ago" + diffMs < 604_800_000 -> "${diffMs / 86_400_000}d ago" + else -> SimpleDateFormat("MMM d", Locale.getDefault()).format(date) + } + } catch (e: Exception) { + "" + } +} + +private fun formatFullDate(isoDate: String): String { + return try { + val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()) + sdf.timeZone = TimeZone.getTimeZone("UTC") + val date = sdf.parse(isoDate.take(19)) ?: return "" + SimpleDateFormat("MMM d, yyyy 'at' h:mm a", Locale.getDefault()).format(date) + } catch (e: Exception) { + "" + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/messages/MessagesViewModel.kt b/android/app/src/main/java/com/vernu/sms/ui/messages/MessagesViewModel.kt new file mode 100644 index 0000000..7eb7fc1 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/messages/MessagesViewModel.kt @@ -0,0 +1,103 @@ +package com.vernu.sms.ui.messages + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.vernu.sms.ApiManagerKt +import com.vernu.sms.AppConstants +import com.vernu.sms.dtos.SmsMessage +import com.vernu.sms.helpers.SharedPreferenceHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class MessagesState( + val messages: List = emptyList(), + val isLoading: Boolean = true, + val isLoadingMore: Boolean = false, + val error: String? = null, + val filter: String = "all", + val currentPage: Int = 1, + val totalPages: Int = 1, + val total: Int = 0 +) + +class MessagesViewModel(app: Application) : AndroidViewModel(app) { + + private val context get() = getApplication().applicationContext + + private val _state = MutableStateFlow(MessagesState()) + val state: StateFlow = _state.asStateFlow() + + init { + fetchMessages(reset = true) + } + + fun setFilter(filter: String) { + _state.update { it.copy(filter = filter, currentPage = 1) } + fetchMessages(reset = true) + } + + fun refresh() = fetchMessages(reset = true) + + fun loadMore() { + val s = _state.value + if (s.isLoadingMore || s.currentPage >= s.totalPages) return + _state.update { it.copy(currentPage = it.currentPage + 1) } + fetchMessages(reset = false) + } + + private fun fetchMessages(reset: Boolean) { + val apiKey = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_API_KEY_KEY, "" + ) ?: "" + val deviceId = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "" + ) ?: "" + if (apiKey.isEmpty() || deviceId.isEmpty()) { + _state.update { it.copy(isLoading = false, error = "Device not connected") } + return + } + + val page = if (reset) 1 else _state.value.currentPage + val filter = _state.value.filter + + viewModelScope.launch { + if (reset) { + _state.update { it.copy(isLoading = true, error = null) } + } else { + _state.update { it.copy(isLoadingMore = true) } + } + try { + val response = ApiManagerKt.getApiService() + .getMessages(deviceId, apiKey, page, 20, filter) + if (response.isSuccessful) { + val body = response.body() + val newMessages = body?.data ?: emptyList() + val meta = body?.meta + _state.update { + it.copy( + messages = if (reset) newMessages else it.messages + newMessages, + isLoading = false, + isLoadingMore = false, + error = null, + currentPage = page, + totalPages = meta?.totalPages ?: 1, + total = meta?.total ?: 0 + ) + } + } else { + _state.update { + it.copy(isLoading = false, isLoadingMore = false, error = "Failed to load messages") + } + } + } catch (e: Exception) { + _state.update { + it.copy(isLoading = false, isLoadingMore = false, error = "Network error") + } + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingActivity.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingActivity.kt new file mode 100644 index 0000000..f48cec9 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingActivity.kt @@ -0,0 +1,104 @@ +package com.vernu.sms.ui.onboarding + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import com.vernu.sms.ui.main.NewMainActivity +import com.vernu.sms.ui.onboarding.screens.* +import com.vernu.sms.ui.theme.TextBeeTheme + +class OnboardingActivity : ComponentActivity() { + + private val viewModel: OnboardingViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val qrLauncher = registerForActivityResult(ScanContract()) { result -> + result?.contents?.let { scanned -> + viewModel.onQrScanned(scanned.trim()) + } + } + + setContent { + TextBeeTheme { + val navController = rememberNavController() + OnboardingNavGraph( + navController = navController, + viewModel = viewModel, + onScanQr = { + qrLauncher.launch(ScanOptions().apply { + setPrompt("Scan the QR code from textbee.dev/dashboard") + setBeepEnabled(true) + setOrientationLocked(false) + }) + }, + onComplete = { + val intent = Intent(this, NewMainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivity(intent) + } + ) + } + } + } +} + +@Composable +private fun OnboardingNavGraph( + navController: NavHostController, + viewModel: OnboardingViewModel, + onScanQr: () -> Unit, + onComplete: () -> Unit +) { + NavHost(navController = navController, startDestination = "welcome") { + composable("welcome") { + WelcomeScreen( + onGetStarted = { + viewModel.setReturningUser(false) + navController.navigate("credentials") + }, + onHaveDeviceId = { + viewModel.setReturningUser(true) + navController.navigate("credentials") + } + ) + } + composable("credentials") { + CredentialsScreen( + viewModel = viewModel, + onScanQr = onScanQr, + onNext = { navController.navigate("device_setup") }, + onBack = { navController.popBackStack() } + ) + } + composable("device_setup") { + DeviceSetupScreen( + viewModel = viewModel, + onSuccess = { navController.navigate("permissions") }, + onBack = { navController.popBackStack() } + ) + } + composable("permissions") { + PermissionsScreen( + onContinue = { navController.navigate("setup_complete") }, + onBack = { navController.popBackStack() } + ) + } + composable("setup_complete") { + SetupCompleteScreen( + viewModel = viewModel, + onOpenDashboard = onComplete + ) + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingViewModel.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..4d4befb --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingViewModel.kt @@ -0,0 +1,182 @@ +package com.vernu.sms.ui.onboarding + +import android.content.Context +import android.os.Build +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.messaging.FirebaseMessaging +import com.vernu.sms.ApiManagerKt +import com.vernu.sms.AppConstants +import com.vernu.sms.BuildConfig +import com.vernu.sms.TextBeeUtils +import com.vernu.sms.dtos.RegisterDeviceInputDTO +import com.vernu.sms.dtos.SimInfoCollectionDTO +import com.vernu.sms.helpers.HeartbeatManager +import com.vernu.sms.helpers.SharedPreferenceHelper +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +data class OnboardingState( + val apiKey: String = "", + val deviceId: String = "", + val deviceName: String = "${Build.BRAND} ${Build.MODEL}", + val isReturningUser: Boolean = false, + val useExistingDeviceId: Boolean = false, + val isQrScanned: Boolean = false, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val registeredDeviceId: String? = null, + val registeredDeviceName: String? = null +) + +class OnboardingViewModel : ViewModel() { + + private val _state = MutableStateFlow(OnboardingState()) + val state: StateFlow = _state.asStateFlow() + + private val _registrationSuccess = Channel(Channel.CONFLATED) + val registrationSuccess = _registrationSuccess.receiveAsFlow() + + fun setApiKey(key: String) { + _state.update { it.copy(apiKey = key.trim(), errorMessage = null, isQrScanned = false) } + } + + fun onQrScanned(key: String) { + _state.update { it.copy(apiKey = key.trim(), errorMessage = null, isQrScanned = true) } + } + + fun setDeviceId(id: String) { + _state.update { it.copy(deviceId = id.trim(), errorMessage = null) } + } + + fun setDeviceName(name: String) { + _state.update { it.copy(deviceName = name) } + } + + fun setReturningUser(returning: Boolean) { + _state.update { it.copy(isReturningUser = returning, useExistingDeviceId = returning) } + } + + fun setUseExistingDeviceId(use: Boolean) { + _state.update { it.copy(useExistingDeviceId = use, deviceId = if (!use) "" else it.deviceId) } + } + + fun clearError() { + _state.update { it.copy(errorMessage = null) } + } + + fun registerOrUpdateDevice(context: Context) { + val current = _state.value + val apiKey = current.apiKey + val deviceId = current.deviceId + val shouldUpdate = current.isReturningUser || (current.useExistingDeviceId && deviceId.isNotEmpty()) + + if (apiKey.isEmpty()) { + _state.update { it.copy(errorMessage = "Please enter your API key.") } + return + } + if ((current.isReturningUser || current.useExistingDeviceId) && deviceId.isEmpty()) { + _state.update { it.copy(errorMessage = "Please enter your Device ID.") } + return + } + + viewModelScope.launch { + _state.update { it.copy(isLoading = true, errorMessage = null) } + try { + val fcmToken = getFcmToken() + val simInfo = SimInfoCollectionDTO().apply { + setLastUpdated(System.currentTimeMillis()) + setSims(TextBeeUtils.collectSimInfo(context)) + } + val input = RegisterDeviceInputDTO().apply { + setFcmToken(fcmToken) + setBrand(Build.BRAND) + setManufacturer(Build.MANUFACTURER) + setModel(Build.MODEL) + setBuildId(Build.ID) + setOs(Build.VERSION.BASE_OS) + setAppVersionCode(BuildConfig.VERSION_CODE) + setAppVersionName(BuildConfig.VERSION_NAME) + setName(current.deviceName.ifEmpty { "${Build.BRAND} ${Build.MODEL}" }) + setSimInfo(simInfo) + } + + val response = if (shouldUpdate) { + ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input) + } else { + ApiManagerKt.getApiService().registerDevice(apiKey, input) + } + + if (response.isSuccessful) { + val data = response.body()?.data + ?: throw IllegalStateException("missing_response") + val registeredId = data["_id"] as? String + ?: throw IllegalStateException("missing_id") + val heartbeatInterval = (data["heartbeatIntervalMinutes"] as? Double)?.toInt() ?: 30 + val name = data["name"] as? String ?: "" + + SharedPreferenceHelper.setSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, registeredId + ) + SharedPreferenceHelper.setSharedPreferenceString( + context, AppConstants.SHARED_PREFS_API_KEY_KEY, apiKey + ) + SharedPreferenceHelper.setSharedPreferenceInt( + context, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, heartbeatInterval + ) + val resolvedName = name.ifEmpty { current.deviceName } + SharedPreferenceHelper.setSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, resolvedName + ) + HeartbeatManager.scheduleHeartbeat(context) + + _state.update { + it.copy( + isLoading = false, + registeredDeviceId = registeredId, + registeredDeviceName = resolvedName + ) + } + _registrationSuccess.send(Unit) + } else { + _state.update { + it.copy( + isLoading = false, + errorMessage = when (response.code()) { + 401 -> "Invalid API key. Go back and check your key." + 404 -> "Device ID not found. Verify it in your dashboard." + in 500..599 -> "Server error. Please try again in a moment." + else -> "Request failed (${response.code()}). Please try again." + } + ) + } + } + } catch (e: Exception) { + val message = when { + e.message == "missing_id" -> "Unexpected server response. Please try again." + e.message?.contains("Unable to resolve host") == true || + e.message?.contains("timeout") == true || + e.message?.contains("connect") == true -> + "No internet connection. Check your network and retry." + else -> "Something went wrong. Please try again." + } + _state.update { it.copy(isLoading = false, errorMessage = message) } + TextBeeUtils.logException(e, "Onboarding device registration failed") + } + } + } + + private suspend fun getFcmToken(): String = suspendCoroutine { cont -> + FirebaseMessaging.getInstance().token + .addOnSuccessListener { token -> cont.resume(token) } + .addOnFailureListener { e -> cont.resumeWithException(e) } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/CredentialsScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/CredentialsScreen.kt new file mode 100644 index 0000000..925540f --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/CredentialsScreen.kt @@ -0,0 +1,220 @@ +package com.vernu.sms.ui.onboarding.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.vernu.sms.ui.onboarding.OnboardingViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CredentialsScreen( + viewModel: OnboardingViewModel, + onScanQr: () -> Unit, + onNext: () -> Unit, + onBack: () -> Unit +) { + val state by viewModel.state.collectAsState() + var selectedTab by remember { mutableStateOf(0) } + var showApiKey by remember { mutableStateOf(false) } + val tabs = listOf("Scan QR Code", "Enter Manually") + + Scaffold( + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + StepIndicator(current = 1, total = 3) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Connect your account", + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Enter your API key from textbee.dev/dashboard", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(24.dp)) + + TabRow(selectedTabIndex = selectedTab) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(title) } + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + when (selectedTab) { + 0 -> QrTab( + apiKey = state.apiKey, + isQrScanned = state.isQrScanned, + onScanQr = onScanQr, + onSwitchToManual = { selectedTab = 1 } + ) + 1 -> ManualTab( + apiKey = state.apiKey, + showApiKey = showApiKey, + onApiKeyChange = { viewModel.setApiKey(it) }, + onToggleVisibility = { showApiKey = !showApiKey } + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onNext, + enabled = state.apiKey.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Text("Next →") + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun QrTab( + apiKey: String, + isQrScanned: Boolean, + onScanQr: () -> Unit, + onSwitchToManual: () -> Unit +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Open textbee.dev/dashboard → Settings → Register Device → Show QR Code", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(24.dp)) + + if (isQrScanned && apiKey.isNotEmpty()) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "QR scanned successfully", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = apiKey.take(8) + "••••••••", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + OutlinedButton(onClick = onScanQr) { + Icon(Icons.Default.QrCodeScanner, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Scan Again") + } + } else { + Button( + onClick = onScanQr, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Icon(Icons.Default.QrCodeScanner, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Scan QR Code") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + TextButton(onClick = onSwitchToManual) { + Text("Enter manually instead") + } + } +} + +@Composable +private fun ManualTab( + apiKey: String, + showApiKey: Boolean, + onApiKeyChange: (String) -> Unit, + onToggleVisibility: () -> Unit +) { + OutlinedTextField( + value = apiKey, + onValueChange = onApiKeyChange, + label = { Text("API Key") }, + placeholder = { Text("Paste your API key here") }, + visualTransformation = if (showApiKey) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = onToggleVisibility) { + Icon( + imageVector = if (showApiKey) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showApiKey) "Hide" else "Show" + ) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + supportingText = { + Text("Find your API key at textbee.dev/dashboard") + }, + singleLine = true + ) +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/DeviceSetupScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/DeviceSetupScreen.kt new file mode 100644 index 0000000..907b778 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/DeviceSetupScreen.kt @@ -0,0 +1,195 @@ +package com.vernu.sms.ui.onboarding.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.vernu.sms.ui.onboarding.OnboardingViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceSetupScreen( + viewModel: OnboardingViewModel, + onSuccess: () -> Unit, + onBack: () -> Unit +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.registrationSuccess.collect { onSuccess() } + } + + Box(modifier = Modifier.fillMaxSize()) { + Scaffold( + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + StepIndicator(current = 2, total = 3) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = if (state.isReturningUser) "Reconnect your device" else "Set up your device", + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (state.isReturningUser) + "Enter your Device ID to reconnect this device to your account" + else + "Register this device as an SMS gateway", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(24.dp)) + + if (state.isReturningUser) { + OutlinedTextField( + value = state.deviceId, + onValueChange = { viewModel.setDeviceId(it) }, + label = { Text("Device ID") }, + placeholder = { Text("Enter your Device ID") }, + supportingText = { Text("Find it at textbee.dev/dashboard → Devices") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(16.dp)) + } else { + var expandDeviceId by remember { mutableStateOf(false) } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Checkbox( + checked = expandDeviceId, + onCheckedChange = { + expandDeviceId = it + viewModel.setUseExistingDeviceId(it) + } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "I want to reuse an existing Device ID", + style = MaterialTheme.typography.bodyMedium + ) + } + + if (expandDeviceId) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = state.deviceId, + onValueChange = { viewModel.setDeviceId(it) }, + label = { Text("Device ID") }, + placeholder = { Text("Enter your Device ID") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } + + OutlinedTextField( + value = state.deviceName, + onValueChange = { viewModel.setDeviceName(it) }, + label = { Text("Device Name (optional)") }, + placeholder = { Text("e.g. My Gateway Phone") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + state.errorMessage?.let { error -> + Spacer(modifier = Modifier.height(16.dp)) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { viewModel.clearError() }, modifier = Modifier.size(20.dp)) { + Icon( + Icons.Default.Close, + contentDescription = "Dismiss", + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(16.dp) + ) + } + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = { viewModel.registerOrUpdateDevice(context) }, + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Text(if (state.isReturningUser) "Reconnect Device" else "Register Device") + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } + + if (state.isLoading) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background.copy(alpha = 0.7f) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Connecting your device…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground + ) + } + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/OnboardingComponents.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/OnboardingComponents.kt new file mode 100644 index 0000000..bdd9f35 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/OnboardingComponents.kt @@ -0,0 +1,65 @@ +package com.vernu.sms.ui.onboarding.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun StepIndicator(current: Int, total: Int) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + repeat(total) { index -> + val step = index + 1 + val isActive = step == current + val isDone = step < current + Box( + modifier = Modifier + .size(if (isActive) 28.dp else 24.dp) + .background( + color = when { + isActive -> MaterialTheme.colorScheme.primary + isDone -> MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) + else -> MaterialTheme.colorScheme.surfaceVariant + }, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = step.toString(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = if (isActive || isDone) Color.White + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (index < total - 1) { + Box( + modifier = Modifier + .width(24.dp) + .height(2.dp) + .background( + if (isDone) MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.surfaceVariant + ) + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Step $current of $total", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/PermissionsScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/PermissionsScreen.kt new file mode 100644 index 0000000..92cac00 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/PermissionsScreen.kt @@ -0,0 +1,232 @@ +package com.vernu.sms.ui.onboarding.screens + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Sms +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PermissionsScreen( + onContinue: () -> Unit, + onBack: () -> Unit +) { + val context = LocalContext.current + + val permissions = remember { + listOf( + PermissionItem( + permission = Manifest.permission.SEND_SMS, + label = "Send SMS", + rationale = "Required to send messages from your device", + icon = Icons.Default.Sms + ), + PermissionItem( + permission = Manifest.permission.RECEIVE_SMS, + label = "Receive SMS", + rationale = "Required to receive and forward incoming messages", + icon = Icons.Default.Sms + ), + PermissionItem( + permission = Manifest.permission.READ_PHONE_STATE, + label = "Phone State", + rationale = "Required to detect SIM cards for multi-SIM support", + icon = Icons.Default.PhoneAndroid + ) + ) + } + + var grantedMap by remember { + mutableStateOf( + permissions.associate { item -> + item.permission to (ContextCompat.checkSelfPermission(context, item.permission) == PackageManager.PERMISSION_GRANTED) + } + ) + } + + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { results -> + grantedMap = grantedMap + results + } + + val allGranted = grantedMap.values.all { it } + + Scaffold( + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + StepIndicator(current = 3, total = 3) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Grant Permissions", + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "These permissions are required for the SMS gateway to work", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(24.dp)) + + if (!allGranted) { + Button( + onClick = { + val missing = permissions + .filter { grantedMap[it.permission] == false } + .map { it.permission } + .toTypedArray() + launcher.launch(missing) + }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) { + Text("Grant All Permissions") + } + Spacer(modifier = Modifier.height(16.dp)) + } + + permissions.forEach { item -> + val isGranted = grantedMap[item.permission] == true + PermissionRow( + item = item, + isGranted = isGranted, + onGrant = { launcher.launch(arrayOf(item.permission)) }, + onOpenSettings = { + context.startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + ) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onContinue, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Text(if (allGranted) "Continue" else "Continue Anyway") + } + + if (!allGranted) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Some features may be limited without all permissions", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun PermissionRow( + item: PermissionItem, + isGranted: Boolean, + onGrant: () -> Unit, + onOpenSettings: () -> Unit +) { + Card( + colors = CardDefaults.cardColors( + containerColor = if (isGranted) + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + else + MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = item.icon, + contentDescription = null, + tint = if (isGranted) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.label, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = item.rationale, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(8.dp)) + if (isGranted) { + Icon( + Icons.Default.Check, + contentDescription = "Granted", + tint = MaterialTheme.colorScheme.primary + ) + } else { + TextButton(onClick = onGrant) { + Text("Grant") + } + } + } + } +} + +private data class PermissionItem( + val permission: String, + val label: String, + val rationale: String, + val icon: ImageVector +) diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/SetupCompleteScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/SetupCompleteScreen.kt new file mode 100644 index 0000000..580b5c0 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/SetupCompleteScreen.kt @@ -0,0 +1,145 @@ +package com.vernu.sms.ui.onboarding.screens + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vernu.sms.ui.onboarding.OnboardingViewModel + +@Composable +fun SetupCompleteScreen( + viewModel: OnboardingViewModel, + onOpenDashboard: () -> Unit +) { + val state by viewModel.state.collectAsState() + val clipboardManager: ClipboardManager = LocalClipboardManager.current + + val scale = remember { Animatable(0f) } + LaunchedEffect(Unit) { + scale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(96.dp) + .scale(scale.value) + .background(MaterialTheme.colorScheme.primary, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = Color.White + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = "You're all set!", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = state.registeredDeviceName ?: "Your device", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(24.dp)) + + state.registeredDeviceId?.let { deviceId -> + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Device ID", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = deviceId, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { + clipboardManager.setText(AnnotatedString(deviceId)) + } + ) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy", + modifier = Modifier.size(18.dp) + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Your gateway is ready to use. Configure it further from the Settings tab.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(40.dp)) + + Button( + onClick = onOpenDashboard, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Text("Open Dashboard", style = MaterialTheme.typography.labelLarge) + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/WelcomeScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/WelcomeScreen.kt new file mode 100644 index 0000000..cf6a305 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/WelcomeScreen.kt @@ -0,0 +1,92 @@ +package com.vernu.sms.ui.onboarding.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun WelcomeScreen( + onGetStarted: () -> Unit, + onHaveDeviceId: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(96.dp) + .background(MaterialTheme.colorScheme.primaryContainer, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.ChatBubble, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = "textbee", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Turn your Android into a\nprogrammatic SMS gateway", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(64.dp)) + + Button( + onClick = onGetStarted, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Text("Get Started", style = MaterialTheme.typography.labelLarge) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton( + onClick = onHaveDeviceId, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Text("I have a Device ID", style = MaterialTheme.typography.labelLarge) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = "textbee.dev", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..894f5fa --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/settings/SettingsScreen.kt @@ -0,0 +1,542 @@ +package com.vernu.sms.ui.settings + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vernu.sms.BuildConfig + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onSwitchToLegacy: () -> Unit, + onNavigateToFilters: () -> Unit, + onDisconnect: () -> Unit, + viewModel: SettingsViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val snackbarHostState = remember { SnackbarHostState() } + + var showDisconnectDialog by remember { mutableStateOf(false) } + var showLegacyDialog by remember { mutableStateOf(false) } + var showAboutDialog by remember { mutableStateOf(false) } + var showDeviceNameDialog by remember { mutableStateOf(false) } + var editedDeviceName by remember(state.deviceName) { mutableStateOf(state.deviceName) } + var showDelayDialog by remember { mutableStateOf(false) } + var editedDelay by remember(state.smsSendDelaySeconds) { mutableStateOf(state.smsSendDelaySeconds.toString()) } + + LaunchedEffect(state.snackbarMessage) { + state.snackbarMessage?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearSnackbar() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Settings, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.width(8.dp)) + Text("Settings", fontWeight = FontWeight.SemiBold) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + SettingsSectionHeader("Account") + + SettingsRow( + icon = Icons.Default.Fingerprint, + title = "Device ID", + subtitle = state.deviceId.ifEmpty { "—" }, + subtitleFont = FontFamily.Monospace, + trailing = { + IconButton(onClick = { clipboard.setText(AnnotatedString(state.deviceId)) }) { + Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(18.dp)) + } + } + ) + + SettingsRow( + icon = Icons.Default.Key, + title = "API Key", + subtitle = if (state.apiKey.isEmpty()) "—" else "••••••••" + state.apiKey.takeLast(4), + trailing = { + IconButton(onClick = { clipboard.setText(AnnotatedString(state.apiKey)) }) { + Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(18.dp)) + } + } + ) + + SettingsRow( + icon = Icons.Default.Edit, + title = "Device Name", + subtitle = state.deviceName, + onClick = { + editedDeviceName = state.deviceName + showDeviceNameDialog = true + }, + trailing = { + if (state.isSavingDeviceName) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Default.ChevronRight, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + ) + + Divider(modifier = Modifier.padding(start = 56.dp)) + + SettingsRow( + icon = Icons.Default.LinkOff, + title = "Disconnect Device", + titleColor = MaterialTheme.colorScheme.error, + onClick = { showDisconnectDialog = true } + ) + + SettingsSectionHeader("Gateway") + + SettingsSwitchRow( + icon = Icons.Default.Power, + title = "Gateway Enabled", + subtitle = "Allow sending and receiving SMS", + checked = state.isGatewayEnabled, + onCheckedChange = { viewModel.setGatewayEnabled(it) } + ) + + if (state.availableSims.size > 1) { + SimSelectionRow( + sims = state.availableSims, + selectedId = state.preferredSimSubscriptionId, + onSelect = { viewModel.setPreferredSim(it) } + ) + } + + SettingsSectionHeader("SMS") + + SettingsSwitchRow( + icon = Icons.Default.MoveToInbox, + title = "Receive SMS", + subtitle = "Forward incoming SMS to backend", + checked = state.isReceiveSmsEnabled, + onCheckedChange = { viewModel.setReceiveSms(it) } + ) + + SettingsRow( + icon = Icons.Default.Timer, + title = "Send Delay", + subtitle = "${state.smsSendDelaySeconds}s between each SMS", + onClick = { + editedDelay = state.smsSendDelaySeconds.toString() + showDelayDialog = true + }, + trailing = { + Icon(Icons.Default.ChevronRight, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + ) + + SettingsRow( + icon = Icons.Default.FilterList, + title = "Configure Filters", + subtitle = "Allow/block list for incoming SMS", + onClick = onNavigateToFilters, + trailing = { + Icon(Icons.Default.ChevronRight, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + ) + + SettingsSectionHeader("System") + + SettingsSwitchRow( + icon = Icons.Default.NotificationsActive, + title = "Sticky Notification", + subtitle = "Keeps the gateway alive in the background", + checked = state.isStickyNotificationEnabled, + onCheckedChange = { viewModel.setStickyNotification(it) } + ) + + SettingsRow( + icon = Icons.Default.Info, + title = "App Version", + subtitle = "${state.appVersionName} (Build ${state.appVersionCode})" + ) + + SettingsRow( + icon = Icons.Default.AutoAwesome, + title = "About", + subtitle = "textbee.dev", + onClick = { showAboutDialog = true }, + trailing = { + Icon(Icons.Default.ChevronRight, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + ) + + SettingsRow( + icon = Icons.Default.SystemUpdate, + title = "Check for Updates", + onClick = { + val versionInfo = "${BuildConfig.VERSION_NAME}(${BuildConfig.VERSION_CODE})" + val url = "https://textbee.dev/download?currentVersion=${Uri.encode(versionInfo)}" + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + }, + trailing = { + Icon(Icons.Default.OpenInBrowser, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) + } + ) + + SettingsSectionHeader("Legal") + + SettingsRow( + icon = Icons.Default.Gavel, + title = "Terms of Service", + onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/terms-of-service"))) + }, + trailing = { + Icon(Icons.Default.OpenInBrowser, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) + } + ) + + SettingsRow( + icon = Icons.Default.Policy, + title = "Privacy Policy", + onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/privacy-policy"))) + }, + trailing = { + Icon(Icons.Default.OpenInBrowser, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) + } + ) + + SettingsSectionHeader("UI") + + SettingsRow( + icon = Icons.Default.SwapHoriz, + title = "Switch to Legacy UI", + subtitle = "Use the original interface", + onClick = { showLegacyDialog = true }, + trailing = { + Icon(Icons.Default.ChevronRight, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } + + if (showDeviceNameDialog) { + AlertDialog( + onDismissRequest = { showDeviceNameDialog = false }, + title = { Text("Edit Device Name") }, + text = { + OutlinedTextField( + value = editedDeviceName, + onValueChange = { editedDeviceName = it }, + label = { Text("Device Name") }, + singleLine = true + ) + }, + confirmButton = { + TextButton(onClick = { + viewModel.saveDeviceName(editedDeviceName) + showDeviceNameDialog = false + }) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = { showDeviceNameDialog = false }) { Text("Cancel") } + } + ) + } + + if (showDelayDialog) { + AlertDialog( + onDismissRequest = { showDelayDialog = false }, + title = { Text("SMS Send Delay") }, + text = { + Column { + Text( + "Delay between each SMS in seconds (0–3600)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = editedDelay, + onValueChange = { editedDelay = it.filter { c -> c.isDigit() } }, + label = { Text("Seconds") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true + ) + } + }, + confirmButton = { + TextButton(onClick = { + viewModel.setSmsSendDelay(editedDelay.toIntOrNull() ?: 5) + showDelayDialog = false + }) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = { showDelayDialog = false }) { Text("Cancel") } + } + ) + } + + if (showLegacyDialog) { + AlertDialog( + onDismissRequest = { showLegacyDialog = false }, + title = { Text("Switch to Legacy UI?") }, + text = { Text("You can switch back from the Legacy Settings screen anytime.") }, + confirmButton = { + TextButton(onClick = { + showLegacyDialog = false + onSwitchToLegacy() + }) { Text("Switch") } + }, + dismissButton = { + TextButton(onClick = { showLegacyDialog = false }) { Text("Cancel") } + } + ) + } + + if (showAboutDialog) { + AlertDialog( + onDismissRequest = { showAboutDialog = false }, + title = { + Text("textbee.dev", fontWeight = FontWeight.Bold) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "An open-source SMS gateway that turns your Android into a personal SMS API. " + + "Send and receive messages programmatically without relying on expensive third-party services.", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev"))) + } + ) { + Text("textbee.dev") + } + OutlinedButton( + onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/vernu/textbee"))) + } + ) { + Text("GitHub") + } + } + } + }, + confirmButton = { + TextButton(onClick = { showAboutDialog = false }) { Text("Close") } + } + ) + } + + if (showDisconnectDialog) { + AlertDialog( + onDismissRequest = { showDisconnectDialog = false }, + title = { Text("Disconnect Device?") }, + text = { Text("This will remove all credentials from this device. You'll need to reconnect to use the gateway.") }, + confirmButton = { + TextButton( + onClick = { + showDisconnectDialog = false + onDisconnect() + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { Text("Disconnect") } + }, + dismissButton = { + TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") } + } + ) + } +} + +@Composable +private fun SettingsSectionHeader(title: String) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 4.dp) + ) +} + +@Composable +private fun SettingsRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String? = null, + subtitleFont: FontFamily = FontFamily.Default, + titleColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, + onClick: (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null +) { + Row( + modifier = Modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge, color = titleColor) + subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = subtitleFont), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + trailing?.invoke() + } +} + +@Composable +private fun SettingsSwitchRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + subtitle?.let { + Text(text = it, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SimSelectionRow( + sims: List, + selectedId: Int, + onSelect: (Int) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val selectedSim = sims.find { it.subscriptionId == selectedId } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.SimCard, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = "Default SIM", style = MaterialTheme.typography.bodyLarge) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = selectedSim?.displayName ?: "Device Default", + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + textStyle = MaterialTheme.typography.bodySmall + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { Text("Device Default") }, + onClick = { + onSelect(-1) + expanded = false + } + ) + sims.forEach { sim -> + DropdownMenuItem( + text = { Text(sim.displayName) }, + onClick = { + onSelect(sim.subscriptionId) + expanded = false + } + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/vernu/sms/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..e37a0c3 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/settings/SettingsViewModel.kt @@ -0,0 +1,215 @@ +package com.vernu.sms.ui.settings + +import android.app.Application +import android.os.Build +import android.telephony.SubscriptionManager +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.vernu.sms.ApiManagerKt +import com.vernu.sms.AppConstants +import com.vernu.sms.BuildConfig +import com.vernu.sms.TextBeeUtils +import com.vernu.sms.dtos.RegisterDeviceInputDTO +import com.vernu.sms.helpers.SharedPreferenceHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.json.JSONObject +import retrofit2.Response + +data class SimOption(val subscriptionId: Int, val displayName: String) + +data class SettingsState( + val deviceId: String = "", + val apiKey: String = "", + val deviceName: String = "", + val isGatewayEnabled: Boolean = false, + val isReceiveSmsEnabled: Boolean = false, + val isStickyNotificationEnabled: Boolean = false, + val smsSendDelaySeconds: Int = AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS, + val preferredSimSubscriptionId: Int = -1, + val availableSims: List = emptyList(), + val appVersionName: String = BuildConfig.VERSION_NAME, + val appVersionCode: Int = BuildConfig.VERSION_CODE, + val isSavingDeviceName: Boolean = false, + val snackbarMessage: String? = null +) + +class SettingsViewModel(app: Application) : AndroidViewModel(app) { + + private val context get() = getApplication().applicationContext + + private val _state = MutableStateFlow(SettingsState()) + val state: StateFlow = _state.asStateFlow() + + init { + loadSettings() + } + + private fun loadSettings() { + val deviceId = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "" + ) ?: "" + val apiKey = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_API_KEY_KEY, "" + ) ?: "" + val deviceName = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, "" + ) ?: "" + val isGatewayEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false + ) + val isReceiveSms = SharedPreferenceHelper.getSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false + ) + val isSticky = SharedPreferenceHelper.getSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false + ) + val smsDelay = SharedPreferenceHelper.getSharedPreferenceInt( + context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, + AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS + ) + val preferredSim = SharedPreferenceHelper.getSharedPreferenceInt( + context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1 + ) + + val sims = try { + TextBeeUtils.getAvailableSimSlots(context).map { info -> + SimOption( + subscriptionId = info.subscriptionId, + displayName = "${info.carrierName} (SIM ${info.simSlotIndex + 1})" + ) + } + } catch (e: Exception) { + emptyList() + } + + _state.update { + it.copy( + deviceId = deviceId, + apiKey = apiKey, + deviceName = deviceName.ifEmpty { "${Build.BRAND} ${Build.MODEL}" }, + isGatewayEnabled = isGatewayEnabled, + isReceiveSmsEnabled = isReceiveSms, + isStickyNotificationEnabled = isSticky, + smsSendDelaySeconds = smsDelay, + preferredSimSubscriptionId = preferredSim, + availableSims = sims + ) + } + } + + fun setGatewayEnabled(enabled: Boolean) { + val deviceId = _state.value.deviceId + val apiKey = _state.value.apiKey + if (deviceId.isEmpty() || apiKey.isEmpty()) return + + viewModelScope.launch { + try { + val input = RegisterDeviceInputDTO().apply { setEnabled(enabled) } + val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input) + if (response.isSuccessful) { + SharedPreferenceHelper.setSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, enabled + ) + _state.update { it.copy(isGatewayEnabled = enabled) } + if (enabled) { + TextBeeUtils.startStickyNotificationService(context) + com.vernu.sms.helpers.HeartbeatManager.scheduleHeartbeat(context) + } else { + TextBeeUtils.stopStickyNotificationService(context) + com.vernu.sms.helpers.HeartbeatManager.cancelHeartbeat(context) + } + } else { + _state.update { it.copy(snackbarMessage = extractErrorMessage(response, "Failed to update gateway status")) } + } + } catch (e: Exception) { + _state.update { it.copy(snackbarMessage = "Network error. Try again.") } + TextBeeUtils.logException(e, "Gateway toggle from settings failed") + } + } + } + + fun setReceiveSms(enabled: Boolean) { + SharedPreferenceHelper.setSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, enabled + ) + _state.update { it.copy(isReceiveSmsEnabled = enabled) } + } + + fun setStickyNotification(enabled: Boolean) { + SharedPreferenceHelper.setSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, enabled + ) + try { + if (enabled) TextBeeUtils.startStickyNotificationService(context) + else TextBeeUtils.stopStickyNotificationService(context) + } catch (e: Exception) { + TextBeeUtils.logException(e, "Sticky notification toggle failed") + _state.update { it.copy(snackbarMessage = "Could not start notification service") } + } + _state.update { it.copy(isStickyNotificationEnabled = enabled) } + } + + fun setSmsSendDelay(seconds: Int) { + val clamped = seconds.coerceIn(0, 3600) + SharedPreferenceHelper.setSharedPreferenceInt( + context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, clamped + ) + _state.update { it.copy(smsSendDelaySeconds = clamped) } + } + + fun setPreferredSim(subscriptionId: Int) { + SharedPreferenceHelper.setSharedPreferenceInt( + context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, subscriptionId + ) + _state.update { it.copy(preferredSimSubscriptionId = subscriptionId) } + } + + fun saveDeviceName(name: String) { + val deviceId = _state.value.deviceId + val apiKey = _state.value.apiKey + if (deviceId.isEmpty() || apiKey.isEmpty() || name.isBlank()) return + + viewModelScope.launch { + _state.update { it.copy(isSavingDeviceName = true) } + try { + val input = RegisterDeviceInputDTO().apply { setName(name.trim()) } + val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input) + if (response.isSuccessful) { + SharedPreferenceHelper.setSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, name.trim() + ) + _state.update { it.copy(deviceName = name.trim(), snackbarMessage = "Device name saved") } + } else { + _state.update { it.copy(snackbarMessage = extractErrorMessage(response, "Failed to save device name")) } + } + } catch (e: Exception) { + _state.update { it.copy(snackbarMessage = "Network error. Try again.") } + TextBeeUtils.logException(e, "Save device name failed") + } finally { + _state.update { it.copy(isSavingDeviceName = false) } + } + } + } + + fun clearSnackbar() = _state.update { it.copy(snackbarMessage = null) } + + private fun extractErrorMessage(response: Response<*>, fallback: String): String { + return try { + val body = response.errorBody()?.string() + if (!body.isNullOrBlank()) { + val json = JSONObject(body) + json.optString("message").takeIf { it.isNotBlank() } + ?: json.optString("error").takeIf { it.isNotBlank() } + ?: fallback + } else { + fallback + } + } catch (e: Exception) { + fallback + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/splash/SplashActivity.kt b/android/app/src/main/java/com/vernu/sms/ui/splash/SplashActivity.kt new file mode 100644 index 0000000..f8ee15d --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/splash/SplashActivity.kt @@ -0,0 +1,86 @@ +package com.vernu.sms.ui.splash + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vernu.sms.R +import com.vernu.sms.AppConstants +import com.vernu.sms.activities.MainActivity +import com.vernu.sms.helpers.SharedPreferenceHelper +import com.vernu.sms.ui.main.NewMainActivity +import com.vernu.sms.ui.onboarding.OnboardingActivity +import com.vernu.sms.ui.theme.TextBeeTheme + +class SplashActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + TextBeeTheme { + SplashContent() + } + } + Handler(Looper.getMainLooper()).postDelayed({ route() }, 400) + } + + private fun route() { + val useNewUi = SharedPreferenceHelper.getSharedPreferenceBoolean( + this, AppConstants.SHARED_PREFS_USE_NEW_UI_KEY, true + ) + val deviceId = SharedPreferenceHelper.getSharedPreferenceString( + this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "" + ) + val target = when { + !useNewUi -> Intent(this, MainActivity::class.java) + deviceId.isNullOrEmpty() -> Intent(this, OnboardingActivity::class.java) + else -> Intent(this, NewMainActivity::class.java) + } + startActivity(target) + finish() + } +} + +@Composable +private fun SplashContent() { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Image( + painter = painterResource(id = R.drawable.ic_app_logo), + contentDescription = null, + modifier = Modifier.size(96.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "textbee", + style = MaterialTheme.typography.headlineLarge, + color = Color.White, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "SMS Gateway", + style = MaterialTheme.typography.bodyLarge, + color = Color.White.copy(alpha = 0.8f) + ) + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/theme/Color.kt b/android/app/src/main/java/com/vernu/sms/ui/theme/Color.kt new file mode 100644 index 0000000..7adbcea --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/theme/Color.kt @@ -0,0 +1,20 @@ +package com.vernu.sms.ui.theme + +import androidx.compose.ui.graphics.Color + +val Orange700 = Color(0xFFC4620A) +val Orange800 = Color(0xFFA04405) +val Orange600 = Color(0xFFB45309) +val Orange900 = Color(0xFF92400E) +val OrangeLight = Color(0xFFFFF7ED) + +val Gray50 = Color(0xFFF9FAFB) +val Gray100 = Color(0xFFF3F4F6) +val Gray200 = Color(0xFFE5E7EB) +val Gray400 = Color(0xFF9CA3AF) +val Gray600 = Color(0xFF4B5563) +val Gray900 = Color(0xFF111827) + +val Green500 = Color(0xFF22C55E) +val Red500 = Color(0xFFEF4444) +val Blue500 = Color(0xFF3B82F6) diff --git a/android/app/src/main/java/com/vernu/sms/ui/theme/Theme.kt b/android/app/src/main/java/com/vernu/sms/ui/theme/Theme.kt new file mode 100644 index 0000000..f4bb082 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/theme/Theme.kt @@ -0,0 +1,84 @@ +package com.vernu.sms.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +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.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val LightColorScheme = lightColorScheme( + primary = Orange700, + onPrimary = Color.White, + primaryContainer = OrangeLight, + onPrimaryContainer = Orange900, + secondary = Orange600, + onSecondary = Color.White, + secondaryContainer = OrangeLight, + onSecondaryContainer = Orange900, + background = Gray50, + onBackground = Gray900, + surface = Color.White, + onSurface = Gray900, + surfaceVariant = Gray100, + onSurfaceVariant = Gray600, + outline = Gray200, + error = Red500, +) + +private val DarkColorScheme = darkColorScheme( + primary = Orange600, + onPrimary = Color.White, + primaryContainer = Orange900, + onPrimaryContainer = OrangeLight, + secondary = Orange700, + onSecondary = Color.White, + background = Gray900, + onBackground = Gray100, + surface = Color(0xFF1F2937), + onSurface = Gray100, + surfaceVariant = Color(0xFF374151), + onSurfaceVariant = Gray400, + outline = Color(0xFF4B5563), + error = Red500, +) + +@Composable +fun TextBeeTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/theme/Type.kt b/android/app/src/main/java/com/vernu/sms/ui/theme/Type.kt new file mode 100644 index 0000000..81e2d61 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/theme/Type.kt @@ -0,0 +1,49 @@ +package com.vernu.sms.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + headlineLarge = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 36.sp + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 30.sp + ), + titleLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 26.sp + ), + titleMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp + ), + labelLarge = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp + ), + labelSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp + ) +) diff --git a/android/app/src/main/res/drawable/ic_app_logo.webp b/android/app/src/main/res/drawable/ic_app_logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..9749c70dd9f11d9327974f3d0868b548e1c27f83 GIT binary patch literal 6514 zcmV-&8I9&rNk&F$82|uRMM6+kP&iCp82|t;U%(d-O)zLANlIpVcxUl1_y7$N{ht7R zCW!<;K#^v~Hho&DTQL!xwzXZ`e7JUjx3bo8crVEpOMdN=Gy@A)kt8Wr9;2S~FXbLH zelfEHw~-_%QXMmxaQ>Ua%*-#o$e@uVMSb-C1s?P>YWVbj0>B?p8`Vpwx#zoU|67Zy z^#cIVM}d9Z`b+ya-Mij70sz1vG{4n5rE@R7Lyj&&0P0j_)=x@yefQh+falY=9mofM zV1^EODX%+w+L8760p4Ev3_cFwqaSNSLEAP=8vd|%8AQYc@EA3k0JbZKn{)swL(A&e z6144UBuSr~S=7a|%*@Qp%#0^K^6q_lj(cKeW@ct)Y-TLxEK1NJZC91EW6xj5@MO4K z!=&(zm$!1V|6L8wWK~4a*0#xzZ2ggunZ0b=wr!nh+t!hB2kQd%k!{T-HT_s`&C8*|uuiwr!=9OKW3n zX6E?TUkH|A2buSQF?y>ddyZ^dwQbwBQqHB-*2b8F7Q<$S5Mzuf3PSAI7G-I&d2a`69D30jGBZJ0HrYp7;Ru^kQg2;grodD;ti~F>OwGp(Ou|^@uL6|l48s^er*2Z!_#b5*HONSb z~%L#ii2`FRC#%B9OVv^@|1NXswd2s3RccX& z6jv539lGG(T0em*p6b*ro>#1&RI77QvfB5=q+g2XLmaHZOry-~5-2_i(H z!)JBr>x|H{<^BG-0|P?+^bDZ9RbCIkZ(A&lC*yf1b05`ay3#A`spmBM$vRK#Z3mZQ z>*whkS2x_Nbq&d$s*M{pngvzCflxoBjD`whlzKHTL{xEcB~lsSSZ9R^HHYU$?DkW| z+}Y|F>#7xh?dV+!@~Igjp@W=w0!U>WKDoW;rD0D@{}sxR&RPH0hwn1?+$6Z@C>CFq zLPPmu_hnOV>#1DXI$qSSl|_g@G?Cvyi6Ck;TwNAn)NeP8iP}wuLuh6+3klC05)2F&wqU_Ri#!(>~fm_29N0ZJ@Kw z&(l$})uf0t!DNh9ZvO1H;4n@|cxElQHdGMo+QktCQ5GoTu~z9r-b@Q|ID80;kusBO zmN=3^fwd%CYsK2?GcA97wPB5$Nt6VHI3*Z*zp@4e%=F^(oLh+(0q0KTPN}dL%Hs1_ zI592l86U(EI;xiYRLdebOW{lBeiN5Rz|3(?tjShov#qzhEVi>|Yk4C0;`_H3Tlq1+DG=ko#7|#At zYN8f`L(=kEyat1C1h7vTk@3K7W^$1_Q5>=G+gkye%E-!pst?!RFD3Tz9PZA0ty#UM z7$K`R)3KYl?flogTZf30sU{IJlBr2cAl0uYUP*nvzF^j(x@@hnu{KMmXllZ~ZxtNCdDcMIXSlcaeu?S2{L6ptCZ21Bvy~AoE6nvq$#`OJF>PP`SHVRZaic*h zvO~s=UYn3SjC$TMyI+|6_J*ysY_4A{9h?bRUCxx=X1SMu2 zAQlFO7T=_@hJ=!}^V3WcAkP-W(A%#GD>h97z`?+xxX& z_q)HpzR~c--`KlpqCv;Ee+b!xD4Y!AmhfATVnLcY_Mxs;&_WXFKHN5cO9Pus%|w(f zMiH`wM}%yVB&e<*bT#3o`D2=g0B|E0F#-6I6e@)5b3XR<-UFX6PMZ%KhQkd#t?1)r z_knWc;8EG6Nz3Na_RsHcZ2aQSg8K=jWlV&Gl+!&Oh7cZ_a7aWs zgc;#q^FnyD!Tbvf^ljn4_PRAf)zCkJB|Z{hr{Z)|^3vCbRAyBg5IgX`^=TF%(izpH zOGzWTqqL(z|1kCA9S820X3iRm(bTz^`>XD5_9Ah{{1*q)2`g&YUc6fLUUDCT_s8j> zoi$_idRbTploY%)1dL2*A~C7NV=bN*vh1dw)O{YW`e?)8Ff~kz46sq??cqTPDMf>> zVkHLZEN58=i<6OZOa!Mv6}XBO|2doR%!93ILp%zhk-7b3%#6`OsHwX2^y2rcrfa2X z-vdjiR9PzwRcf4iAprttL@31Lt_M?o*MTx8)$ZjRiO_3w4jjTKsB#US2oNZ~x-3Q){BDgnAt~!)lkB5iN`q z8&Y##^|{Wf_C;iN`wig?RHI^|Aj3sJxBcDcK2PRl#POzGe|>G~FHDnL8aW0L(h-RU z))Zre5{?-p1b`qyAQoqzUdCU!FwjQFRdGyz<2w1gZ_yWi;HVpo@>EDvD6?Y3R+eyx z42H#)QUAO%#$^^f6hgJ&;~T@qLXTj-ss?OY^T=@WdXTfYbJ-EgoNBMT9*6e5N^?yX z8!AL8=8Egm@U9V>87T+=Mq;$#82#$}-J|9Z+PLqIZN5|)odPhwpG@qtE%VS56UY|r z3I`)ByHzQ~!~mB*-EG!>fvGGd4jGCSiQTxWM-UW+1ST!as$;PoRGI8PPm{Up^H@vL zy8)LSy$(3<*o$zbSS*RdDmVm3Y?&it1ps*HLSakUF%x-J5R9T*9rM;Gh6V-oVxMoF zE8oY4TcQWu+RGGnDp&0$2)o_Nox;;aR}>-J>M$zARR*e(c9fwRg-8Gay1M)yt*8o% zu{{FP@NGjvQ&vU9qe`}^RR1r`weOXYXkYn|Q=lY36OuGvXmw)9rmm~=ykP=tDt{9i z(zzcYXJwmp+nkzcbgH$h?!d0y<%ks(9k@c73>;8ZQ2D1uQKV{rezy{ku%NQvAq`*Q z=OFZ?Zb8pk7+3WJL4PqLdx%=X5NYoj@6?7cwUxTw*kY(KjerZSbWcHpP6;uFLOEY7 zl!&%*@9t=S!D5i)3%9C9L`36ZjmkUNj-pCmW%ybVAEZc4=-B((`Or@qKXBt(`!aK_ z34?!&)b~IwGe>dK;wTo=-ScRe+Rfu~NTD;vUwFMH#T!Ae6-FM@D}=&z7--lhFvyK! zHXblo)WfAOL=N-f;JW`NN`Kqx&pQ*V$gzsn3Lpg6%)Y}xMYm%=h_oKo(8BOhoyR%c zUp%MAHz-kbtIx^wSpPX@o;6`H;4H#|tsh3HVf!W^^5t+a@1vknYCKI~Y1j zH=AIk`wEXu9+O?$TU((cMsjPS9;a&vF6Z3&@KMi~e|mo_J;xhlskQ$TOi>-{$qQ*J zgRDTn7gucUhz;o=D6Ongg8tf(*J#VD5~7$_WB&JjP4gBr0noUlWAk-KxleEzOyg}! zyk$1bjg7E6S^MU;H{7-Abs-zG9-=MM6eUW1@8I%!SwoKJpRZqf;oW)-5BssX7FY3x7LZmwlzhs<<$>ph*53UruWkMEKe@GAA@*@~ z@V~!0uFuy{iU1<$^hO{8dI3bDnZnUATP9v_J@2MvP-$IdA#xc}>(^%E_ucZE-#L6M zqv>=?pJBIrWGBb0SuyCS?OR*(H@iiai-XGo)|D+olP{#b*dY{3j7#Wp5G0F^m10P0 zN?|g~s5K}x`G=2 zX`TybgruTpcr`;KC^;5~GEbWg7rhn|DW%|@W9~NebHY;H znWEKEd#o9D%auGq_A2$dWYxXSJNvN+U8B%6?LMOveUeDw{l;b|F!0t^N>iI)3m`(C zENaW{ZSgu=g@7B<5j0A@_Yr7A{7U8)(upH0!HGFDj2$3*@^^S=SRVY{AGDfNx`rOI z0W@w3o2SF%S(s|`SBfliTOC4@TvdE;Jd%EcWgk<$0#x5@QcEu9;t zTu6jI&O0 zPylQbo^Xw$+uzEv2LNe?GoBeUQcNN7wq=_ub_qwL)R=};bNnW=@(%|B1Z92u$WIQt zh8J}*{};^tnMP9715ZyzUFUJZu^dz&24^m=(^TxreZoWp0JN$bPXFrEsLaJIY*C~G z^y-+@0;(H^7ucH#%;A_{<+$I+e?WE!?=#TAX*5lQ~?8F)mkaj5gRKoqT+%}Qzc80o9htBC&Ra-3|RO% z`J($KRZ|YCzcXyLz2$-yaQh`uEmOj_W_6;*8sfH>*qp)t>gn?>Cd1r^F z#H-ln;wakyAl(wbl6^5j2Hs*GnQjh5B7^cuTuC=MV3;rL9^vM z4opbKbRh%4lD1=mrBKmekR1dBnKSCDGpm|HLUFaSHhP=0k^GSPp`pbLFo6<<|9NT@ z&Vjgu`JrD9n?WTF{~YsOu^&?c6&Aw9anWSE0u&TCt%|Hv04dTQ^gp-6>K^rjcBxZ^ zVnO5YPyI@L#x#+=0|jt3plZ_A{3-`%=el=fT&E=08pv!j{Rv4|MNw~`T+B~;fvDK9 zj9_GUr4Rky=hj=iTkG*pO=>8Rim5}ZiLgYf3_uxqi7v*oQP;=NQ!8Ms5$W+{NHQI$BFHbKL4=XQ6*Yy%s-z&hWF z)iNaW>c-hx`YU1)!-^YTsEWz#1t{In($*xZXoxgU{9h;j2LNsGW%}c0X>vV?mgcmznky@O&nI$XGRM)MyXJ>quE&Hrqv!O zah&=`nPY4v-yv@|YeSv_X|AWPPB?kO!`Ghr#%p==YkS3Oxp-Xz$Vm_L82A_YHyB76 zZw`d5-&2_%tYA^@pM0w08DAOpx{C`VgA_dU|i=;t0IpX?s|j&B1H z&Z+s&E5qC#9!*axdZD4Bb^9V@aQo+2{P&wrMWm_Wbgl zmWvVncrQivhYu_##ghOIN&W3@{{P>&OT_G%i@4$y5vdxb`SH&lf2zHT&a`#LRSZ|D zu0@xe++#67klRMt9M*q~(qq6t@LGF=srR{`T6_pi!nbI|%XdEJCmY%wPygea-|)9r zKJh=#EP1VIL=iVoMT%j}auObo5OpOb-0$}5%zi1u2Ju@tjQh}yR*~_8y;{of^8er8 zRQfc0_kZuKcnxxLn;HTX6cTU*o>_T-T(5gHh1cA6**?#Z$M|>H=PsJ=P^KP0L@(T3 zjsJgmZO3-0XyGF+h3k0p*FwBHbQuo?Kk!q^O zmcv(*F}fq9w!iy>PyX$BIfe%P#(%8w&(EH#5QO1P;f-gid2Xse!-Z=j7 z%9pD)I|;{UIOJda#85?|V-PHQ*C2=+YLfbJ>ZoD>-BP8J*-?G;qyK)4=K|%4VRzrR zADBVLra}1C7c`+FHF0%V@esP_s>gl!?t?o!9us9D1pmfi)a{REp;Hoc_@PhFp${zy zK=(`zb>cRMuKc5oxnOyM-{bE*F-SovW$9fNol^U3>iwzIK;vx4qjAJW%chNk(_y#?33>q|XlA4v>9+FW=2<9Cqjlu`X^gaN zD@#v44H^YBYIkh^C`4laAdq_=m6iD<+Y6 zl-8qAPfw|hqp$LhUvKU|_Pnu>M#UIc%C0HLzWMtNpZ0egNiw8Fi^kH})gx!uCeYWh z?z6Xld+bRUIb)(i8n->)5fDa3-t*475B9Z>qCo#q@lf3^maRKaJTB7ld)xo~;RoIs z89DvpvmBI>PT69vW-r)N*j<0E&^-P+Yr4?}g;i_gsxS^=X_VSHu5lWZ==*=?XJ7e! zemz#+XvSRpNd^Wn+J>Cc7P=k}>;A$w%U-_wZijBT<(dmR(1J_IRbwqFG^9c-HS*vb za_Dc^`Nyxn{Ny0kyIW2li@(fZg_US>sf&7OOyQgCO{Y9{^KG|WXZ6(3?~OE_G>zvgA4XQ*jk*}C69^T)Q{e8ln_JC(j(f`egY8k)1(1*50@xVx~4&ws$I zyDhoVf@{p{oY6k1ZG3iglg%(tOof;)%neQTPj(Eo>}dE^-4EN5<cdd=v(+f6GgNs|GuTswEQT=%GU!<>v4hl_m~B2r!;#~ynFZ3@$cz5+rF zVugz&@k+Pv&1GDXN6k1TiBL#m(zIK9C6kI_v^1U_>r%r5qDYd&Nw00^l4;>8Nu_hi YlUy2&5SOOL@IWdQ3WcPhp`l?^0THFIaR2}S literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index f20bd45..7a7827b 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -792,6 +792,18 @@ android:textAllCaps="false" android:paddingHorizontal="0dp" style="@style/Widget.AppCompat.Button.Borderless" /> + +